Compare commits

...
Author SHA1 Message Date
Vojtech NovakandClaude Opus 5.5 8b12a03494 [eas-cli] Add error codes to the --json error output
A program that reads the --json error must still match the message
to tell a project that is not linked from a run that is not logged in.

- EasCommandError takes an optional code.
- getUnconfiguredProjectError: EAS_PROJECT_NOT_CONFIGURED.
- Non-interactive login prompt: EAS_LOGIN_REQUIRED (oclif's own code
  option on Errors.error).
- EasCommand.catch prints the code of an EasCommandError or CLIError.
  GraphQL error codes stay out: they belong to the server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:46 +01:00
Vojtech NovakandClaude Opus 5.5 0873b04166 [eas-cli] Print a JSON error on stdout when a --json command fails
A command run with --json printed nothing on stdout when it failed, so
a program had to read the human message.

- EasCommand.run enables JSON output before the context is resolved,
  so context messages and errors stay off stdout. build:list enabled it
  only after getContextAsync.
- EasCommand.catch prints {"error": {"message", "requestId"?}}. The
  request ID is the one the human message already shows. Once per
  process, because update:rollback runs other commands in-process.
- Errors.exit(1) carries no reason, so its message is
  "<command> command failed." instead of "EEXIT: 1".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:46 +01:00
Vojtech NovakandClaude Opus 5.5 8070663faa [eas-cli] Write the reason a command failed to stderr (#4592)
* [eas-cli] Write the reason a command failed to stderr

EasCommand.catch printed the reason with Log.error, which writes to
stdout. oclif then printed "<command> command failed." to stderr. A
program that reads stderr for the error found only that generic line.

Only the catch changes. Other Log.error and Log.warn calls stay on
stdout, because some of them are not failures and the spinner keeps
non-TTY output off stderr on purpose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Drop the errorToStderr unit test and doc comment

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Color the failure reason by stderr's color support

chalk's default instance takes its color level from stdout, so
`eas ... 2>err.log` in a terminal wrote color codes to the file.

Also spy on errorToStderr in the already-printed catch test. The catch
no longer calls Log.error, so the test passed even without the
messageAlreadyPrinted guard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:39 +01:00
Vojtech NovakandClaude Opus 5.5 e38a5a88d2 [eas-cli] Print the eas-cli dependency notice only when stderr is a terminal (#4591)
The notice is advice for a person. A program that runs eas-cli reads
stderr for the command's error and found this notice first.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:39 +01:00
Gabe DebesandClaude Opus 5.5 82845def18 [eas-cli] Fix simulator control action help (#4681)
* [eas-cli] Fix simulator control action help

* [eas-cli] Exercise real simulator help lookup and hooks

* [eas-cli] Batch source-loaded help lifecycle probes

* [build-tools] Expect headless Appium default on iOS in the launch test

#4622 added this test while #4699 added appium:isHeadless to the iOS
default capabilities. Each PR passed CI without the other, so main fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 17:00:03 -07:00
Gabe DebesandClaude Opus 5.5 476b4e9b09 [build-tools] Run Appium iOS sessions with a headless Simulator (#4699)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 16:42:34 -07:00
Gabe Debes 9315aebef5 [build-tools] run apsd cleanup in parallel for iOS Simulator sessions (#4706)
* [build-tools] Keep APSD setup off preview readiness

* [build-tools] Run APSD setup alongside app launch

* [build-tools] Lowercase apsd in the changeset
2026-10-26 16:30:46 -07:00
Gabe Debes 786d8bac85 [build-tools] Start Appium with the prepared Full Disk Access helper (#4622) 2026-10-26 15:41:02 -07:00
Gabe Debes 8f67105e5a [build-tools] Improve simulator sessions failed app downloads error (#4703)
* [build-tools] Explain application archive download failures

* [build-tools] Update download error expectations

* [build-tools] Use neutral archive download advice
2026-10-26 15:06:37 -07:00
Will Schurman 🦦 3bfb405c87 [eas-cli] Fix graphql CI step on stacked PRs (#4763)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Noticed that the required "Ensure GraphQL schema and generated code" CI job wasn't running on stacked PRs, and thus prevented stacked land.

# How

Allow it to run on stacked PRs by removing the `main` condition from PR.

# Test Plan

This PR was stacked and it ran!
2026-10-26 14:59:35 -07:00
Will Schurman 🦦 73335e8233 [eas-cli] Handle unauthorized error during build command (#4760)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

As requested by @sjkim-expo in #4734, when the user has insufficient permissions to create a build due to the required role being higher than the user's granted role, we can show a better error message.

# How

Handle the `UNAUTHORIZED` error code and show a better error.

Note that this doesn't yet gracefully handle protected environment variable errors or protected credential errors.

# Test Plan

`neas build` for granted role lower than required role from eas.json

Output:
```
Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role on the account "real-org-will", but the credentials of the actor that started the job are scoped to the "Developer" role. Re-authenticate to grant the credentials a scope that includes the "Release Manager" role, or request a lower role.
Request ID: bd12f9af-8950-498b-a69b-5d89255ca4f4

Run eas whoami to check the user you are logged in as and your permission grant. Run eas login to change the user or permission grant. To request a lower role for the build's EXPO_TOKEN, set permissions.account.role in the build profile in eas.json.
```
2026-10-26 14:31:37 -07:00
Will Schurman 🦦 9af03f901e [eas-cli] Add better description to re-auth for scoped auth (#4759)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Now that there's scoping, when a permissions error occurs and the user is scoped, they are told to run `eas login`.

But, when running `eas login` the experience was suboptimal:
```
You are already logged in as wschurman.
  ✔ Do you want to continue? … yes
```

# How

Update this message to be more descriptive and instructing to continue if the user wants to change scope.

# Test Plan

`neas login`

```
You are already logged in as wschurman.

  Accounts:
  • wschurman (Role: Owner)
  • expo (Role: Admin, limited to Viewer by the current credentials)

  The permissions granted during authentication limit your access to some accounts. Continue to log in again and change the
  permission grant.
  ✔ Do you want to continue? … yes
```
2026-10-26 14:07:42 -07:00
Gabe Debes 13ab40e271 [build-tools] Support idle timeouts for iOS web preview sessions (#4731)
* [build-tools] Support idle timeouts for iOS web preview sessions

* [build-tools] Reject Android web preview idle timeouts
2026-10-26 12:23:37 -07:00
Will Schurman 🦦 355f3f2012 [eas-cli] Send requested role from eas.json to build mutation (#4734)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Builds on top of #4732 to send the new field to graphql for build creation. The eas.json field has been deployed on the server.

# How

Read the requested role from config, send to mutation if specified.

# Test Plan

Run the new test.

Manual test:
1. Add `"permissions": { "account": { "role": "runner" } }` to eas.json
2. `neas build`, see build runs!
2026-10-26 11:51:46 -07:00
Gabe Debes 736e1aeb90 [build-tools] Handle device sessions stopped during startup (#4716) 2026-10-26 11:31:14 -07:00
Expo CI 58d372a096 bump STAGING and PRODUCTION to 26.0.0 in cli-versions.json 2026-10-26 16:52:09 +00:00
Expo CI 56d01c4129 v26.0.0 2026-10-26 16:14:04 +00:00
Stanisław Chmiela bcb5d96ea8 [cocoapods-proxy] Add Sonatype snapshot CocoaPods proxy support (#3736)
## Summary

Adds `central.sonatype.com` support to the packaged `expo-cocoapods-proxy` gem used by iOS workers.

## Change

- Adds `central.sonatype.com` to the `Pod::Downloader::Http` proxy allowlist.
- Adds spec coverage for Sonatype repository artifact URLs.

## Code Requiring This

This PR is for CocoaPods `:http` pod source downloads. It does not proxy React Native's `Net::HTTP` metadata lookup.

React Native returns Sonatype artifact URLs to CocoaPods as `:http` pod sources here:

- RNCore returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/scripts/cocoapods/rncore.rb#L170-L173
- ReactNativeDependencies returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/scripts/cocoapods/rndependencies.rb#L205-L208
- Hermes returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/sdks/hermes-engine/hermes-utils.rb#L190-L193

CocoaPods/cocoapods-downloader then uses `Pod::Downloader::Http` for those `:http` params:

- CocoaPods passes the pod source params to `Downloader.for_target(...).download`: https://github.com/CocoaPods/CocoaPods/blob/1.12.1/lib/cocoapods/downloader.rb#L107-L110
- `cocoapods-downloader` maps `:http` to `Http`: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader.rb#L22-L30
- `Downloader.for_target` instantiates that downloader with the `:http` URL: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader.rb#L51-L64
- `Pod::Downloader::Http#download_file` uses curl for that URL: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader/http.rb#L12-L22

## Validation

- `rspec` in `packages/expo-cocoapods-proxy`: 16 examples, 0 failures
- `git diff --check`
2026-10-26 15:51:09 +01:00
111 changed files with 1629 additions and 566 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Regenerate graphql code
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides.
@@ -1,6 +0,0 @@
---
'@expo/eas-build-job': minor
'@expo/build-tools': patch
---
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Keep the launched app open when an Appium iOS session starts.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Run Appium through the prepared Full Disk Access helper when available on macOS.
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`.
@@ -1,5 +0,0 @@
---
'@expo/eas-json': minor
---
Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`.
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Retry transient failures when reporting that a device run session has started.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': major
---
Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': none
---
Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Add `code` to the `--json` error: `EAS_PROJECT_NOT_CONFIGURED` when the project is not linked to EAS, and `EAS_LOGIN_REQUIRED` when a non-interactive run is not logged in.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Print `{"error": {"message", "requestId"?}}` on stdout when a command run with `--json` fails, and keep all other output off stdout from the start of the command.
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Show account roles and credential permission limits in `eas login` when you are already logged in.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Identify the build or application archive when a download fails, and explain when the archive is no longer available.
-6
View File
@@ -1,6 +0,0 @@
---
'@expo/build-tools': patch
'@expo/steps': patch
---
Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run.
-6
View File
@@ -1,6 +0,0 @@
---
'eas-cli': none
'@expo/build-tools': none
---
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
@@ -1,5 +0,0 @@
---
'@expo/worker': patch
---
Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Run apsd setup in the background without delaying iOS app launch or preview readiness.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Fix simulator control action help routing and show help when a flag is incomplete.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Handle a device session stopped during startup as normal cleanup instead of reporting a startup error.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Unify signed URL uploading to a single implementation and use it everywhere
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/worker': patch
---
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
-6
View File
@@ -1,6 +0,0 @@
---
'@expo/eas-build-job': major
'@expo/build-tools': major
---
Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports.
-1
View File
@@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
+32
View File
@@ -2,6 +2,38 @@
This is the log of notable changes to EAS CLI and related packages.
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
### 🛠 Breaking changes
- [eas-build-job][build-tools] Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- [eas-build-job][build-tools] Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- [eas-json] Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
- [build-tools] Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [eas-cli] Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- [build-tools] Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- [build-tools][steps] Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
- [worker] Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors. ([#3452](https://github.com/expo/eas-cli/pull/3452) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
- [worker] Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages. ([#4698](https://github.com/expo/eas-cli/pull/4698) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- [eas-cli] Make command descriptions in `eas --help` consistently lowercase without trailing periods. ([#4385](https://github.com/expo/eas-cli/pull/4385) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). ([#4714](https://github.com/expo/eas-cli/pull/4714) by [@ramonclaudio](https://github.com/ramonclaudio))
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
"STAGING": "26.0.0",
"PRODUCTION": "26.0.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "25.3.0",
"version": "26.0.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+19
View File
@@ -1,5 +1,24 @@
# @expo/build-tools
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### Patch Changes
- Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
## 25.3.0
### Minor Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "25.3.0",
"version": "26.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,17 +42,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/downloader": "26.0.0",
"@expo/eas-build-job": "26.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "25.3.0",
"@expo/logger": "26.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/steps": "26.0.0",
"@expo/template-file": "26.0.0",
"@expo/turtle-spawn": "26.0.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -98,7 +98,7 @@ beforeEach(() => {
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
@@ -162,6 +162,59 @@ afterEach(() => {
});
describe('downloadBuild', () => {
it.each([404, 410])('identifies the unavailable build on HTTP %s', async status => {
const buildId = randomUUID();
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest
.mocked(fetch)
.mockResolvedValue(new ActualResponse('<Error>NoSuchKey</Error>', { status }));
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient: createMockGraphqlClient({ applicationArchiveUrl: APPLICATION_ARCHIVE_URL }),
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
message:
`Failed to download the application archive for build ${buildId} (HTTP ${status}). ` +
'The archive is unavailable. Check that the archive still exists and try again.',
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each([404, 410, 500])(
'identifies the application URL without credentials on HTTP %s',
async status => {
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest.mocked(fetch).mockResolvedValue(new ActualResponse('download failed', { status }));
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl:
'https://user:[email protected]/app.tar.gz?token=secret#fragment',
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
});
const prefix = `Failed to download the archive at https://example.test/app.tar.gz (HTTP ${status})`;
await expect(download).rejects.toThrow(
status === 500
? `${prefix}: download failed`
: `${prefix}. The archive is unavailable. ` +
'Check that the URL is correct and the archive still exists, then try again.'
);
await expect(download).rejects.not.toThrow(/password|secret|fragment/);
expect(fetch).toHaveBeenCalledTimes(1);
}
);
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -165,7 +165,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
@@ -176,6 +176,16 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.restoreAllMocks();
});
it('releases resources without monitoring when the session stopped during startup', async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await run();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopPreview).toHaveBeenCalledTimes(1);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(lifetime.aborted).toBe(true);
});
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
@@ -103,6 +103,18 @@ async function runAsync(
}
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
it('cleans up a stopped startup without starting live-session polling', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(pollAgentDeviceArtifactsForUploadAsync).not.toHaveBeenCalled();
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalled();
});
beforeEach(async () => {
jest.clearAllMocks();
@@ -147,7 +159,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
return host;
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
@@ -1,6 +1,7 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
@@ -202,6 +203,78 @@ describe(installAppiumAsync, () => {
});
describe('createStartAppiumRemoteSessionBuildFunction', () => {
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, true],
[BuildRuntimePlatform.LINUX, false],
])('starts Appium on %s with helper present=%s', async (runtimePlatform, helperPresent) => {
const helper = '/usr/local/libexec/turtle-tcc-fda';
if (helperPresent) {
fs.mkdirSync(path.dirname(helper), { recursive: true });
fs.writeFileSync(helper, 'prepared helper');
}
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'simulator' }] as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' }] as never);
// Stop at the launch boundary; no readiness requests or remote session are needed.
const launched = new Error('launch captured');
jest.mocked(spawnDetached).mockImplementation(() => {
throw launched;
});
const env = {
DEVICE_RUN_SESSION_ID: 'session',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
PATH: '/configured/node/bin:/usr/bin',
DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer',
};
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: { ...env, NGROK_AUTHTOKEN: 'token' },
} as never
)
).rejects.toBe(launched);
expect(spawnDetached).toHaveBeenCalledTimes(1);
const launch = jest.mocked(spawnDetached).mock.calls[0][0];
const appiumBinPath = path.join(launch.env.APPIUM_HOME!, 'node_modules', '.bin', 'appium');
const useHelper = runtimePlatform === BuildRuntimePlatform.DARWIN && helperPresent;
expect(launch).toEqual({
command: useHelper ? helper : appiumBinPath,
args: [
...(useHelper ? [appiumBinPath] : []),
'--address',
'127.0.0.1',
'--port',
'4723',
'--base-path',
'/',
'--log-level',
'error',
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
runtimePlatform === BuildRuntimePlatform.DARWIN
? '{"appium:eventTimings":true,"appium:isHeadless":true}'
: '{"appium:eventTimings":true}',
],
env: { ...env, APPIUM_HOME: launch.env.APPIUM_HOME },
logger,
});
});
it('declares the launch inputs so serve-sim can launch the app', () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
@@ -361,6 +434,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:isHeadless': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
});
@@ -447,6 +521,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
);
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:isHeadless': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
});
@@ -128,7 +128,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
@@ -84,11 +84,52 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
stopAsync.mockResolvedValue(undefined);
});
it('uses preview activity to reset the configured idle timeout', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
const observedAt = new Date();
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'leaves the idle timer disabled when omitted on %s',
async runtimePlatform => {
await runAsync(runtimePlatform);
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
}
);
it('leaves the idle timer disabled for zero', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
max_idle_time_minutes: { value: 0 },
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
});
it('rejects an Android idle timeout before starting the host', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
max_idle_time_minutes: { value: 5 },
})
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
@@ -268,6 +309,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
@@ -312,8 +312,22 @@ export async function downloadBuildAsync(
const response = await fetch(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
const archiveUrl = new URL(downloadUrl);
const source = params.buildId
? `application archive for build ${params.buildId}`
: `archive at ${archiveUrl.origin}${archiveUrl.pathname}`;
if (response.status === 404 || response.status === 410) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
`Failed to download the ${source} (HTTP ${response.status}). ` +
'The archive is unavailable. ' +
(params.buildId
? 'Check that the archive still exists and try again.'
: 'Check that the URL is correct and the archive still exists, then try again.')
);
}
throw new Error(
`Unexpected response from server (${response.status}): ${textResult.value}`
`Failed to download the ${source} (HTTP ${response.status}): ${textResult.value}`
);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
@@ -225,7 +225,7 @@ export async function runAgentDeviceRemoteSessionAsync(
tasks.signal.throwIfAborted();
logger.info(tasks.summary());
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -239,6 +239,9 @@ export async function runAgentDeviceRemoteSessionAsync(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
tasks.signal.throwIfAborted();
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
@@ -65,6 +65,7 @@ const APPIUM_HOST = '127.0.0.1';
const APPIUM_PORT = 4723;
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
const DEFAULT_APPIUM_VERSION = '^3';
const FDA_HELPER = '/usr/local/libexec/turtle-tcc-fda';
const AppiumInstalledDriversSchema = z.record(
z.string(),
@@ -225,9 +226,12 @@ export function createStartAppiumRemoteSessionBuildFunction(
});
setupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
command: appiumBinPath,
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
args: [
...(useFdaHelper ? [appiumBinPath] : []),
'--address',
APPIUM_HOST,
'--port',
@@ -242,7 +246,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
JSON.stringify({
'appium:eventTimings': true,
// Without it, XCUITest restarted the booted Simulator at session start, which
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
...wdaCapabilities,
}),
],
env: appiumEnv,
logger,
@@ -278,7 +288,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
authtoken: ngrokAuthtoken,
});
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -296,6 +306,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -360,7 +360,7 @@ export function createStartArgentRemoteSessionBuildFunction(
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -374,6 +374,9 @@ export function createStartArgentRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -1,3 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -57,6 +58,11 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
@@ -68,9 +74,20 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const maxIdleTimeMinutes = inputs.max_idle_time_minutes?.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
if (
maxIdleTimeMinutes !== undefined &&
maxIdleTimeMinutes > 0 &&
runtimePlatform !== BuildRuntimePlatform.DARWIN
) {
throw new UserError(
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
@@ -129,7 +146,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -147,6 +164,9 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -154,6 +174,14 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
}
: undefined,
});
} finally {
await sessionHost.finishAsync();
@@ -170,6 +170,53 @@ describe('with a device run session token', () => {
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('handles the structured stopped code without matching the message', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse(
{
errors: [
{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'A different explanation.' },
],
},
400
)
);
await expect(
uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
})
).resolves.toBe('stopped');
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id was stopped during startup.'
);
});
it.each([undefined, 'VALIDATION_ERROR'])('preserves an ordinary 400 with code %s', async code => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(
jsonResponse(
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
400
)
);
await expect(
uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
})
).rejects.toBeInstanceOf(SystemError);
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('fails the start with a system error that does not contain the token', async () => {
const ctx = createCtx({ withToken: true });
jest
@@ -364,7 +411,9 @@ describe('with a device run session token', () => {
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow('Unexpected response from server (500): stop here');
).rejects.toThrow(
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
);
expectWorkerApiCall('application', 'GET');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
@@ -589,7 +638,9 @@ describe('without a device run session token', () => {
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow('Unexpected response from server (500)');
).rejects.toThrow(
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
);
expect(ctx.graphqlClient.query).toHaveBeenCalled();
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
@@ -1413,7 +1413,7 @@ it('does not read the iOS egress handoff for an Android host', async () => {
});
it.each([true, false])(
'waits for setup before launch or boot-only readiness (app: %s)',
'runs setup in parallel with app launch and readiness, then joins it at teardown (app: %s)',
async hasApp => {
const setup = deferred<void>();
const prepared = deferred<ServeSimApplicationOptions | void>();
@@ -1468,70 +1468,78 @@ it.each([true, false])(
? { installAppPath: '/tmp/App.app', launchAppIdentifier: 'dev.example.other' }
: undefined
);
if (!hasApp) {
let queried = false;
ready.resolve({
ok: true,
json: async () => {
queried = true;
return { status: 'ready', device: 'emulator-5554' };
},
} as never);
let reported = false;
void starting.then(() => {
reported = true;
});
while (!queried) {
await new Promise(resolve => setImmediate(resolve));
if (hasApp) {
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
await new Promise(resolve => setImmediate(resolve));
expect(reported).toBe(false);
expect(runServeSimActionAsync).toHaveBeenCalledTimes(2);
} else {
expect(runServeSimActionAsync).not.toHaveBeenCalled();
setup.resolve();
const host = await starting;
expect(runServeSimActionAsync).not.toHaveBeenCalled();
await host.finishAsync();
return;
}
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
await new Promise(resolve => setImmediate(resolve));
expect(runServeSimActionAsync).toHaveBeenCalledTimes(1);
setup.resolve();
while (jest.mocked(runServeSimActionAsync).mock.calls.length < 2) {
await new Promise(resolve => setTimeout(resolve, 10));
}
expect(
jest
.mocked(runServeSimActionAsync)
.mock.calls.map(([options]) => [options.action, options.params])
).toEqual([
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
[
'app.launch',
{
udid: 'emulator-5554',
bundleId: 'dev.example.other',
launchArgs: [],
openUrl: undefined,
},
],
]);
expect(runServeSimActionAsync).toHaveBeenCalledWith(
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
);
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
'/tmp/App.app',
env.TMPDIR ?? require('node:os').tmpdir()
).toEqual(
hasApp
? [
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
[
'app.launch',
{
udid: 'emulator-5554',
bundleId: 'dev.example.other',
launchArgs: [],
openUrl: undefined,
},
],
]
: []
);
if (hasApp) {
expect(runServeSimActionAsync).toHaveBeenCalledWith(
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
);
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
'/tmp/App.app',
env.TMPDIR ?? require('node:os').tmpdir()
);
}
let queried = false;
ready.resolve({
ok: true,
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
json: async () => {
queried = true;
return { status: 'ready', device: 'emulator-5554' };
},
} as never);
const host = await starting;
await host.finishAsync();
await expect(access(directory)).rejects.toThrow();
let reported = false;
void starting.then(() => {
reported = true;
});
while (!queried) {
await new Promise(resolve => setImmediate(resolve));
}
await new Promise(resolve => setImmediate(resolve));
try {
expect(reported).toBe(true);
const host = await starting;
let finished = false;
const finishing = host.finishAsync().then(() => {
finished = true;
});
await new Promise(resolve => setImmediate(resolve));
expect(finished).toBe(false);
setup.resolve();
await finishing;
} finally {
setup.resolve();
await (await starting).finishAsync();
}
if (hasApp) {
await expect(access(directory)).rejects.toThrow();
}
}
);
@@ -1676,7 +1684,8 @@ it('warns and continues launching when disabling push fails', async () => {
await host.finishAsync();
});
it('cancels pending setup without launching and observes its late failure', async () => {
it('cancels pending app preparation and observes late setup failure', async () => {
const application = deferred<ServeSimApplicationOptions>();
const controller = new AbortController();
let rejectSetup!: (error: Error) => void;
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue(
@@ -1690,7 +1699,7 @@ it('cancels pending setup without launching and observes its late failure', asyn
logger,
timeoutMs: 2_000,
iosSimulatorUdid: 'emulator-5554',
launchAppIdentifier: 'dev.example.app',
application: application.promise,
signal: controller.signal,
});
const rejected = expect(starting).rejects.toThrow('session canceled');
@@ -91,7 +91,8 @@ export class DeviceRunSessionWorkerApiError extends Error {
message: string,
/** The HTTP status of the response, or undefined when no response arrived. */
readonly status: number | undefined,
options?: ErrorOptions
options?: ErrorOptions,
readonly code?: string
) {
super(message, options);
this.name = 'DeviceRunSessionWorkerApiError';
@@ -267,7 +268,9 @@ export class DeviceRunSessionWorkerApi {
: response.statusText;
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
response.status
response.status,
undefined,
apiError?.success ? apiError.data.errors[0]?.code : undefined
);
}
if (!bodyResult.ok) {
@@ -577,7 +577,7 @@ async function startDeviceSessionHostInternalAsync(
}
let stagedAppDirectory: string | undefined;
let beforeLaunch: ReturnType<typeof asyncResult<void>> | undefined;
let simulatorSetup: ReturnType<typeof asyncResult<void>> | undefined;
const readinessController = new AbortController();
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
let previewToken: string | undefined;
@@ -670,7 +670,7 @@ async function startDeviceSessionHostInternalAsync(
onStopped: successful => onFinished(successful && !previewFailed),
})
.finally(async () => {
await beforeLaunch;
await simulatorSetup;
if (stagedAppDirectory) {
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
}
@@ -711,7 +711,8 @@ async function startDeviceSessionHostInternalAsync(
);
}
signal?.throwIfAborted();
beforeLaunch = asyncResult(
// APSD cleanup runs alongside app startup and readiness; teardown joins the task.
simulatorSetup = asyncResult(
startup.untilAborted(
startup.run('Simulator setup', async taskLogger => {
try {
@@ -749,7 +750,6 @@ async function startDeviceSessionHostInternalAsync(
signal?.throwIfAborted();
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
}
(await beforeLaunch)?.enforceValue();
if (app.launchAppIdentifier) {
signal?.throwIfAborted();
await action('app.launch', {
@@ -31,14 +31,17 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
env: BuildStepEnv;
signal?: AbortSignal;
}): Promise<void> {
}): Promise<'started' | 'stopped'> {
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
const localEgress = await readLocalEgressHandoffAsync();
signal?.throwIfAborted();
await uploadRemoteSessionConfigAsync({
const sessionStart = await uploadRemoteSessionConfigAsync({
...options,
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
});
if (sessionStart === 'stopped') {
return sessionStart;
}
if (localEgress && !signal?.aborted) {
// Guard refusals from here on show up under this step in the job log,
// alongside the monitor's reports, instead of under the boot step.
@@ -57,4 +60,5 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
...(localEgress.platform === 'android' ? { scanSimulatorConnections: false } : {}),
});
}
return sessionStart;
}
@@ -31,6 +31,7 @@ import { createProcessOutput } from './processOutput';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import {
type DeviceRunSessionStatus,
DeviceRunSessionWorkerApiError,
requireDeviceRunSessionWorkerApi,
} from './deviceRunSessionWorkerApi';
@@ -554,7 +555,7 @@ export async function uploadRemoteSessionConfigAsync({
deviceRunSessionId: string;
remoteConfig: Record<string, unknown>;
logger: bunyan;
}): Promise<void> {
}): Promise<'started' | 'stopped'> {
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
@@ -564,7 +565,16 @@ export async function uploadRemoteSessionConfigAsync({
{ remoteConfig },
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
);
return 'started';
} catch (err) {
if (
err instanceof DeviceRunSessionWorkerApiError &&
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
) {
logger.info(`Device run session ${deviceRunSessionId} was stopped during startup.`);
return 'stopped';
}
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
@@ -1,5 +1,9 @@
# create-eas-build-function
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "25.3.0",
"version": "26.0.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "25.3.0"
"@expo/steps": "26.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+4
View File
@@ -1,5 +1,9 @@
# @expo/downloader
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "25.3.0",
"version": "26.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,9 @@
# eas-build-cache-provider
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "25.3.0",
"version": "26.0.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+10
View File
@@ -1,5 +1,15 @@
# @expo/eas-build-job
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
## 25.3.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "25.3.0",
"version": "26.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "25.3.0",
"@expo/logger": "26.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/turtle-spawn": "26.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+10
View File
@@ -1,5 +1,15 @@
# eas-cli
## 26.0.0
### Patch Changes
- Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
## 25.3.0
### Minor Changes
+198 -198
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "25.3.0",
"version": "26.0.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,12 +48,12 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "25.3.0",
"@expo/eas-json": "25.3.0",
"@expo/eas-build-job": "26.0.0",
"@expo/eas-json": "26.0.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "25.3.0",
"@expo/logger": "26.0.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.3.0",
"@expo/steps": "26.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -0,0 +1,83 @@
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { createRequire } = require('node:module');
const root = fs.realpathSync(process.argv[2]);
const cli = path.join(root, 'packages/eas-cli');
const cliRequire = createRequire(path.join(cli, 'package.json'));
cliRequire('ts-node').register({ project: path.join(cli, 'tsconfig.json'), transpileOnly: true });
const { Config } = cliRequire('@oclif/core');
const EasCommand = require(path.join(cli, 'src/commandUtils/EasCommand')).default;
const Control = require(path.join(cli, 'src/commands/simulator/control')).default;
const EasHelp = require(path.join(cli, 'src/help')).default;
const cases = JSON.parse(process.argv[3]);
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'eas-help-lifecycle-'));
const events = [];
globalThis.__helpLifecycleEvents = events;
// Replace command work (auth/device I/O), keeping discovery, parsing and hooks real.
EasCommand.prototype.run = async function () {
events.push({ event: 'run', id: this.id });
};
EasCommand.prototype.finally = async function () {};
(async () => {
try {
fs.writeFileSync(
path.join(directory, 'package.json'),
JSON.stringify({
name: 'eas-help-lifecycle-fixture',
version: '1.0.0',
oclif: {
bin: 'eas',
commands: { strategy: 'explicit', target: './commands.cjs' },
hooks: { prerun: './prerun.cjs', postrun: './postrun.cjs' },
},
})
);
fs.writeFileSync(
path.join(directory, 'commands.cjs'),
`exports.default = { 'simulator:control': require(${JSON.stringify(path.join(cli, 'src/commands/simulator/control'))}).default };`
);
for (const event of ['prerun', 'postrun']) {
fs.writeFileSync(
path.join(directory, `${event}.cjs`),
`module.exports = async function({Command}) { globalThis.__helpLifecycleEvents.push({event:${JSON.stringify(event)},id:Command.id}); };`
);
}
process.env.XDG_DATA_HOME = directory;
process.env.XDG_CACHE_HOME = directory;
const config = await Config.load({ root: directory });
const results = [];
for (const args of cases) {
// Published manifests discover metadata without loading the command class.
// Clear eager discovery's assignment to exercise that cold-start boundary.
delete Control.id;
const start = events.length;
const write = process.stdout.write;
let stdout = '',
error;
process.stdout.write = (chunk, encoding, callback) => {
stdout += String(chunk);
const done = typeof encoding === 'function' ? encoding : callback;
done?.();
return true;
};
try {
await new EasHelp(config).showHelp(args);
} catch (err) {
error = err.message;
} finally {
process.stdout.write = write;
}
results.push({ stdout, events: events.slice(start), error });
}
console.log('LIFECYCLE_RESULT ' + JSON.stringify(results));
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
})().catch(err => {
console.error(err);
process.exitCode = 1;
});
@@ -0,0 +1,51 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const root = path.resolve(__dirname, '../../../..');
const fixture = path.join(__dirname, 'fixtures/simulator-control-lifecycle.cjs');
const actionHelp = [
['simulator:control', 'rotate', '--help'],
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
];
const incompleteFlags = [
['simulator:control', 'screenshot.capture', '--help', '--output'],
['sim:control', 'rotate', '-h', '--id'],
];
let results: { stdout: string; events: unknown[]; error?: string }[];
beforeAll(async () => {
// Load TypeScript once, outside per-case timeouts, including on coverage runners.
const { stdout } = await execFileAsync(
process.execPath,
[fixture, root, JSON.stringify([...actionHelp, ...incompleteFlags])],
{ timeout: 20_000 }
);
results = JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!);
}, 30_000);
describe('simulator action help through real oclif lookup and hooks', () => {
it.each(actionHelp.map((argv, index) => ({ argv, index })))(
'keeps canonical command identity and executes lifecycle hooks: $argv',
({ index }) => {
expect(results[index].error).toBeUndefined();
expect(results[index].events).toEqual([
{ event: 'prerun', id: 'simulator:control' },
{ event: 'run', id: 'simulator:control' },
{ event: 'postrun', id: 'simulator:control' },
]);
}
);
it.each(incompleteFlags.map((argv, index) => ({ argv, index })))(
'shows ordinary help without starting the command for an incomplete flag: $argv',
({ index }) => {
const { stdout, events, error } = results[index + actionHelp.length];
expect(error).toBeUndefined();
expect(stdout).toContain('USAGE');
expect(events).toEqual([]);
}
);
});
+4 -3
View File
@@ -1,6 +1,5 @@
import { Config, Help } from '@oclif/core';
import SimulatorControl from '../commands/simulator/control';
import EasHelp from '../help';
describe(EasHelp, () => {
@@ -9,7 +8,7 @@ describe(EasHelp, () => {
let showHelp: jest.SpyInstance;
beforeEach(() => {
run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve());
run = jest.spyOn(config, 'runCommand').mockResolvedValue(Promise.resolve());
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
});
@@ -21,12 +20,14 @@ describe(EasHelp, () => {
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
])('routes action help through the command: %j', async (...argv) => {
await new EasHelp(config).showHelp(argv);
expect(run).toHaveBeenCalledWith(argv.slice(1), config);
expect(run).toHaveBeenCalledWith('simulator:control', argv.slice(1));
expect(showHelp).not.toHaveBeenCalled();
});
it.each([
['simulator:control', '--help'],
['simulator:control', 'screenshot.capture', '--help', '--output'],
['sim:control', 'rotate', '-h', '--id'],
['sim:control', '-h'],
['build', '--help'],
['simulator:control', 'clipboard.write', '--', '--help'],
@@ -1,5 +1,6 @@
import { Platform } from '@expo/eas-build-job';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
@@ -21,6 +22,7 @@ import {
EasBuildFreeTierLimitExceededError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError,
EasBuildUnauthorizedError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from '../errors';
@@ -758,6 +760,38 @@ describe(Build.name, () => {
});
});
});
describe('for UNAUTHORIZED', () => {
it('points to the permission grant and permissions.account.role in eas.json', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError(
'Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role',
'UNAUTHORIZED'
);
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildUnauthorizedError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildUnauthorizedError,
'Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role\n' +
`Request ID: ${mockRequestId}\n\n` +
`Run ${chalk.bold(
'eas whoami'
)} to check the user you are logged in as and your permission grant. Run ${chalk.bold(
'eas login'
)} to change the user or permission grant.\n\n` +
`If your build profile in eas.json uses ${chalk.bold(
'permissions.account.role'
)}, it may mean your credentials do not grant the requested role.`
);
});
});
});
describe('error is unexpected graphQL-related error', () => {
+14
View File
@@ -25,6 +25,7 @@ import {
EasBuildProjectArchiveUploadError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError,
EasBuildUnauthorizedError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from './errors';
@@ -47,6 +48,7 @@ import {
logSourceForBuild,
} from './logs';
import { collectMetadataAsync } from './metadata';
import { resolveJobExpoTokenRole } from './utils/expoTokenRole';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
LocalFile,
@@ -130,6 +132,7 @@ function resolveBuildParamsInput<T extends Platform>(
resourceClass: ctx.resourceClass,
sdkVersion: metadata.sdkVersion,
reactNativeVersion: metadata.reactNativeVersion,
expoTokenRole: resolveJobExpoTokenRole(ctx.buildProfile),
};
}
@@ -300,6 +303,17 @@ export async function handleBuildRequestErrorAsync(
throw new EasBuildTooManyPendingBuildsError(
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
);
} else if (graphQLErrorCode === 'UNAUTHORIZED') {
const requestId = graphQLErrors[0]?.extensions?.requestId;
throw new EasBuildUnauthorizedError(
`${graphQLErrors[0]?.message}${requestId ? `\nRequest ID: ${requestId}` : ''}\n\nRun ${chalk.bold(
'eas whoami'
)} to check the user you are logged in as and your permission grant. Run ${chalk.bold(
'eas login'
)} to change the user or permission grant.\n\nIf your build profile in eas.json uses ${chalk.bold(
'permissions.account.role'
)}, it may mean your credentials do not grant the requested role.`
);
} else if (Array.isArray(error?.graphQLErrors)) {
const errorDetails = formatBuildRequestErrorDetails(error, graphQLErrors);
throw new Error(
+2
View File
@@ -22,4 +22,6 @@ export class EasBuildDownForMaintenanceError extends EasCommandError {}
export class EasBuildTooManyPendingBuildsError extends EasCommandError {}
export class EasBuildUnauthorizedError extends EasCommandError {}
export class EasBuildProjectArchiveUploadError extends EasCommandError {}
@@ -0,0 +1,33 @@
import { Platform } from '@expo/eas-build-job';
import {
BuildProfile,
CredentialsSource,
JobExpoTokenRole as EasJsonJobExpoTokenRole,
} from '@expo/eas-json';
import { JobExpoTokenRole } from '../../../graphql/generated';
import { resolveJobExpoTokenRole } from '../expoTokenRole';
function createBuildProfile(
permissions?: BuildProfile<Platform.ANDROID>['permissions']
): BuildProfile<Platform.ANDROID> {
return {
credentialsSource: CredentialsSource.REMOTE,
distribution: 'store',
permissions,
};
}
describe(resolveJobExpoTokenRole, () => {
it('returns undefined when the build profile does not request a role', () => {
expect(resolveJobExpoTokenRole(createBuildProfile())).toBeUndefined();
});
it.each([
[EasJsonJobExpoTokenRole.RUNNER, JobExpoTokenRole.Runner],
[EasJsonJobExpoTokenRole.DEVELOPER, JobExpoTokenRole.Developer],
[EasJsonJobExpoTokenRole.RELEASE_MANAGER, JobExpoTokenRole.ReleaseManager],
])('maps the %s role to %s', (role, expected) => {
expect(resolveJobExpoTokenRole(createBuildProfile({ account: { role } }))).toBe(expected);
});
});
@@ -0,0 +1,20 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, JobExpoTokenRole as EasJsonJobExpoTokenRole } from '@expo/eas-json';
import { JobExpoTokenRole } from '../../graphql/generated';
const easJsonJobExpoTokenRoleToJobExpoTokenRoleMapping: Record<
EasJsonJobExpoTokenRole,
JobExpoTokenRole
> = {
[EasJsonJobExpoTokenRole.RUNNER]: JobExpoTokenRole.Runner,
[EasJsonJobExpoTokenRole.DEVELOPER]: JobExpoTokenRole.Developer,
[EasJsonJobExpoTokenRole.RELEASE_MANAGER]: JobExpoTokenRole.ReleaseManager,
};
export function resolveJobExpoTokenRole<T extends Platform>(
profile: BuildProfile<T>
): JobExpoTokenRole | undefined {
const role = profile.permissions?.account.role;
return role ? easJsonJobExpoTokenRoleToJobExpoTokenRoleMapping[role] : undefined;
}
@@ -1,4 +1,4 @@
import { Command } from '@oclif/core';
import { Command, Errors } from '@oclif/core';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import { GraphQLError } from 'graphql/error';
@@ -31,6 +31,7 @@ import Log, { link } from '../log';
import Sentry from '../sentry';
import SessionManager from '../user/SessionManager';
import { getActorDisplayName } from '../user/User';
import { enableJsonOutput, printJsonErrorOutput } from '../utils/json';
import { Client } from '../vcs/vcs';
export type ContextInput<
@@ -257,9 +258,24 @@ export default abstract class EasCommand extends Command {
action: `eas ${this.id}`,
});
// Before the context is resolved, so its messages and errors stay off stdout too.
if (this.jsonOutputRequested()) {
enableJsonOutput();
}
return await this.runAsync();
}
/** Mirrors oclif's `jsonEnabled`, for commands that declare their own `json` flag. */
private jsonOutputRequested(): boolean {
if (!this.ctor.flags?.json) {
return false;
}
const jsonIndex = this.argv.indexOf('--json');
const passThroughIndex = this.argv.indexOf('--');
return jsonIndex !== -1 && (passThroughIndex === -1 || jsonIndex < passThroughIndex);
}
// eslint-disable-next-line async-protect/async-suffix
override async finally(err: Error): Promise<any> {
await this.analytics.flushAsync();
@@ -270,10 +286,19 @@ export default abstract class EasCommand extends Command {
protected override catch(err: Error): Promise<any> {
const commandId = this.id ?? 'unknown';
let baseMessage = `${commandId} command failed.`;
if (err instanceof EasCommandError) {
if (!err.messageAlreadyPrinted) {
Log.error(err.message);
let message = err.message;
let code: string | undefined;
let requestId: string | undefined;
if (err instanceof Errors.ExitError) {
// Errors.exit(1) carries no reason; the code that called it printed the reason already.
Log.errorToStderr(err.message);
message = baseMessage;
} else if (err instanceof EasCommandError || err instanceof Errors.CLIError) {
if (!(err instanceof EasCommandError && err.messageAlreadyPrinted)) {
Log.errorToStderr(err.message);
}
// Only codes that eas-cli sets. GraphQL error codes belong to the server and stay out.
code = err.code;
} else if (err instanceof CombinedError && err?.graphQLErrors) {
const cleanGQLErrorsMessage = err?.graphQLErrors
.map((graphQLError: GraphQLError) => {
@@ -321,10 +346,15 @@ export default abstract class EasCommand extends Command {
const cleanMessage = err.networkError
? `${cleanGQLErrorsMessage}\n${err.networkError.message}`
: cleanGQLErrorsMessage;
Log.error(cleanMessage);
Log.errorToStderr(cleanMessage);
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
message = cleanMessage;
requestId = err.graphQLErrors[0]?.extensions?.requestId as string | undefined;
} else {
Log.error(err.message);
Log.errorToStderr(err.message);
}
if (this.jsonOutputRequested()) {
printJsonErrorOutput({ message, code, requestId });
}
Log.debug(err);
Sentry.withScope(scope => {
@@ -24,6 +24,7 @@ jest.mock('../../analytics/AnalyticsManager', () => {
};
});
jest.mock('../../log');
jest.mock('../../utils/json');
const mockRequestId = uuidv4();
@@ -200,7 +201,7 @@ describe('EasCommand', () => {
it('logs the message', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const error = new Error('Unexpected, internal error message');
@@ -218,7 +219,7 @@ describe('EasCommand', () => {
it('skips the message when the command already printed it', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logDebugSpy = jest.spyOn(Log, 'debug');
const { EasCommandError } = require('../errors');
const Sentry = jest.requireMock('../../sentry').default;
@@ -242,7 +243,7 @@ describe('EasCommand', () => {
it('logs the cleaned message if needed', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
@@ -262,7 +263,7 @@ describe('EasCommand', () => {
it('logs the cleaned message with request ID if present', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
@@ -297,7 +298,7 @@ describe('EasCommand', () => {
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
@@ -335,6 +336,146 @@ describe('EasCommand', () => {
expect(logDebugSpy).toBeCalledWith(error);
});
describe('with --json', () => {
const createJsonTestEasCommand = (): any => {
const TestEasCommand = createTestEasCommand();
const { Flags } = require('@oclif/core');
TestEasCommand.flags = { json: Flags.boolean() };
return TestEasCommand;
};
it('enables JSON output before the command runs', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { enableJsonOutput } = jest.requireMock('../../utils/json');
let enabledBeforeRun = false;
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(async () => {
enabledBeforeRun = enableJsonOutput.mock.calls.length > 0;
});
await TestEasCommand.run(['--json'], mockConfig);
expect(enabledBeforeRun).toBe(true);
});
it('prints the error message', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
const { EasCommandError } = require('../errors');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
throw new EasCommandError('EAS project not configured.');
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).toHaveBeenCalledWith({
message: 'EAS project not configured.',
code: undefined,
requestId: undefined,
});
});
it('prints the code of an EasCommandError', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
const { EasCommandError } = require('../errors');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
throw new EasCommandError('EAS project not configured.', {
code: 'EAS_PROJECT_NOT_CONFIGURED',
});
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).toHaveBeenCalledWith(
expect.objectContaining({ code: 'EAS_PROJECT_NOT_CONFIGURED' })
);
});
it('prints the code of an oclif error', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
const { Errors } = require('@oclif/core');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
Errors.error('Log in first.', { code: 'EAS_LOGIN_REQUIRED' });
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Log in first.', code: 'EAS_LOGIN_REQUIRED' })
);
});
it('prints the command failure, not EEXIT, for Errors.exit', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
const { Errors } = require('@oclif/core');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
Errors.exit(1);
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).toHaveBeenCalledWith({
message: `${TestEasCommand.id} command failed.`,
code: undefined,
requestId: undefined,
});
});
it('prints the GraphQL request ID, not the server error code', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
const { CombinedError } = require('@urql/core');
const { GraphQLError } = require('graphql/error');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
throw new CombinedError({
graphQLErrors: [
new GraphQLError('Entity not authorized', null, null, null, null, null, {
errorCode: 'UNAUTHORIZED_ERROR',
requestId: mockRequestId,
}),
],
});
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).toHaveBeenCalledWith(
expect.objectContaining({ requestId: mockRequestId })
);
expect(printJsonErrorOutput.mock.calls[0][0].code).toBeUndefined();
});
it.each([
['without --json', []],
['with --json after --', ['--', '--json']],
])('prints no JSON error %s', async (_, argv) => {
const TestEasCommand = createJsonTestEasCommand();
const { enableJsonOutput, printJsonErrorOutput } = jest.requireMock('../../utils/json');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
throw new Error('foo');
});
await expect(TestEasCommand.run(argv, mockConfig)).rejects.toThrow();
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonErrorOutput).not.toHaveBeenCalled();
});
it('prints no JSON error when the command has no json flag', async () => {
const TestEasCommand = createTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
throw new Error('foo');
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).not.toHaveBeenCalled();
});
});
it('re-throws the error with default base message', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
@@ -11,8 +11,16 @@ jest.mock('fs');
jest.mock('../../../../prompts');
const originalStderrIsTTY = process.stderr.isTTY;
beforeEach(() => {
jest.resetAllMocks();
// The dependency notice prints once per process and only to a terminal, so keep it unspent.
process.stderr.isTTY = false;
});
afterAll(() => {
process.stderr.isTTY = originalStderrIsTTY;
});
describe(findProjectRootAsync, () => {
@@ -115,3 +123,32 @@ describe(findProjectDirAndVerifyProjectSetupAsync, () => {
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
});
});
describe('eas-cli dependency notice', () => {
beforeEach(() => {
vol.reset();
vol.fromJSON(
{
'./eas.json': JSON.stringify({}),
'./package.json': JSON.stringify({ devDependencies: { 'eas-cli': '*' } }),
},
'/app'
);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('prints the notice only when stderr is a terminal', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
process.stderr.isTTY = false;
await findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' });
expect(warn).not.toHaveBeenCalled();
process.stderr.isTTY = true;
await findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' });
expect(warn).toHaveBeenCalledWith(expect.stringContaining('in your project dependencies'));
});
});
@@ -513,6 +513,16 @@ describe(getProjectIdAsync, () => {
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
});
it('gives the non-interactive error the EAS_PROJECT_NOT_CONFIGURED code', async () => {
await expect(
getProjectIdAsync(
sessionManager,
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: true }
)
).rejects.toMatchObject({ code: 'EAS_PROJECT_NOT_CONFIGURED' });
});
it('defers to the project fetch in non-interactive mode when the account is already known', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
@@ -127,7 +127,8 @@ export async function findProjectDirAndVerifyProjectSetupAsync({
} = {}): Promise<string> {
const projectDir = await findProjectRootAsync({ cwd });
await applyCliConfigAsync(projectDir);
if (!ranEnsureEasCliIsNotInDependencies) {
// The notice is advice for a person. Programs that run eas-cli read stderr for its errors.
if (!ranEnsureEasCliIsNotInDependencies && process.stderr.isTTY) {
ranEnsureEasCliIsNotInDependencies = true;
await ensureEasCliIsNotInDependenciesAsync(projectDir);
}
+4 -1
View File
@@ -1,12 +1,15 @@
export class EasCommandError extends Error {
/** True when the command already printed the message, so `EasCommand.catch` must not print it again. */
public readonly messageAlreadyPrinted: boolean;
/** A stable identifier, printed in the `--json` error output so programs need not match the message. */
public readonly code?: string;
constructor(
message: string,
{ messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {}
{ messageAlreadyPrinted = false, code }: { messageAlreadyPrinted?: boolean; code?: string } = {}
) {
super(message);
this.messageAlreadyPrinted = messageAlreadyPrinted;
this.code = code;
}
}
+3 -2
View File
@@ -8,7 +8,7 @@ export function isNonInteractiveByDefault(): boolean {
export const EasNonInteractiveAndJsonFlags = {
json: Flags.boolean({
description:
'Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.',
'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "code"?, "requestId"?}}. Implies --non-interactive.',
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
@@ -87,7 +87,8 @@ export const EASNonInteractiveFlag = {
export const EasJsonOnlyFlag = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
description:
'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "code"?, "requestId"?}}.',
}),
};
@@ -1,7 +1,12 @@
import chalk from 'chalk';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { getMockViewerUserPermission } from '../../../__tests__/viewerUserPermission';
import { Permission, Role } from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync, promptAsync } from '../../../prompts';
import SessionManager from '../../../user/SessionManager';
import { Actor } from '../../../user/User';
import { resumeDeviceLoginAsync, startDeviceLoginAsync } from '../../../user/deviceLogin';
import AccountLogin from '../login';
@@ -33,12 +38,12 @@ const sessionManager = new SessionManager({
const originalIsTTY = process.stdin.isTTY;
const originalCI = process.env.CI;
function command(args: string[]): AccountLogin {
function command(args: string[], actor: Actor | null = null): AccountLogin {
const instance = new AccountLogin(['--device', ...args], getMockOclifConfig());
// @ts-expect-error Mock the protected context without initializing a real session.
jest.spyOn(instance, 'getContextAsync').mockResolvedValue({
sessionManager,
maybeLoggedIn: { actor: null },
maybeLoggedIn: { actor },
});
return instance;
}
@@ -179,3 +184,48 @@ it('requires device login for non-interactive sign-in', async () => {
'Use eas login --device --non-interactive to log in without prompts.'
);
});
describe('when already logged in', () => {
const ownerPermissions = getMockViewerUserPermission(Role.Owner).permissions;
const limitedMessage =
'The permissions granted during authentication limit your access to some accounts. Log in again and change the permission grant.';
function userActor(
effectivePermissions: Actor['accounts'][number]['viewerUserPermission2']['effectivePermissions']
): Actor {
return {
__typename: 'User',
id: 'user-id',
email: '[email protected]',
username: 'jane',
accounts: [
{
id: 'jane-id',
name: 'jane',
viewerUserPermission2: { permissions: ownerPermissions, effectivePermissions },
},
],
isExpoAdmin: false,
featureGates: {},
};
}
beforeEach(() => {
jest.mocked(startDeviceLoginAsync).mockResolvedValue(pending);
});
it('shows the roles that the current credentials limit', async () => {
await command(['--non-interactive'], userActor([Permission.View])).runAsync();
expect(Log.warn).toHaveBeenCalledWith(`You are already logged in as ${chalk.bold('jane')}.`);
expect(Log.log).toHaveBeenCalledWith(
`• jane (Role: Owner, ${chalk.yellow('limited to Viewer by the current credentials')})`
);
expect(Log.log).toHaveBeenCalledWith(limitedMessage);
});
it('does not list accounts when the credentials do not limit them', async () => {
await command(['--non-interactive'], userActor(ownerPermissions)).runAsync();
expect(Log.log).not.toHaveBeenCalledWith('Accounts:');
expect(Log.log).not.toHaveBeenCalledWith(limitedMessage);
});
});
@@ -7,6 +7,7 @@ import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import SessionManager from '../../user/SessionManager';
import { getActorDisplayName } from '../../user/User';
import { logAccountPermissions } from '../../user/accountPermissions';
import {
isDeviceLoginFailure,
resumeDeviceLoginAsync,
@@ -77,6 +78,13 @@ export default class AccountLogin extends EasCommand {
if (actor && !resume) {
Log.warn(`You are already logged in as ${chalk.bold(getActorDisplayName(actor))}.`);
const { hasLimitedAccount } = logAccountPermissions(actor);
if (hasLimitedAccount) {
Log.newLine();
Log.log(
'The permissions granted during authentication limit your access to some accounts. Log in again and change the permission grant.'
);
}
const shouldContinue =
nonInteractive ||
+9 -87
View File
@@ -1,10 +1,9 @@
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { Permission, Role } from '../../graphql/generated';
import Log from '../../log';
import { Actor, getActorDisplayName } from '../../user/User';
import { getRoleForPermissions } from '../../user/roles';
import { getActorDisplayName } from '../../user/User';
import { logAccountPermissions } from '../../user/accountPermissions';
export default class AccountView extends EasCommand {
static override description = 'show the username you are logged in as';
@@ -28,95 +27,18 @@ export default class AccountView extends EasCommand {
Log.log(actor.email);
}
// personal account is included, only show if more accounts that personal account
// but do show personal account in list if there are more, or if the credentials used for
// this command limit the permissions on it
const accountExcludingPersonalAccount = actor.accounts.filter(
account => !('username' in actor) || account.name !== actor.username
);
const hasLimitedAccount = actor.accounts.some(AccountView.isLimitedByCredentials);
if (accountExcludingPersonalAccount.length > 0 || hasLimitedAccount) {
const { hasLimitedAccount } = logAccountPermissions(actor);
if (hasLimitedAccount) {
Log.newLine();
Log.log('Accounts:');
actor.accounts.forEach(account => {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
const roleLabel = AccountView.getLabelForPermissions(permissions);
if (AccountView.isLimitedByCredentials(account)) {
const effectiveRoleLabel = AccountView.getLabelForPermissions(effectivePermissions);
Log.log(
`• ${account.name} (Role: ${roleLabel}, ${chalk.yellow(
`limited to ${effectiveRoleLabel} by the current credentials`
)})`
);
} else {
Log.log(`• ${account.name} (Role: ${roleLabel})`);
}
});
if (hasLimitedAccount) {
Log.newLine();
Log.log(
`The permissions granted during authentication limit your access to some accounts. Run ${chalk.bold(
'eas login'
)} to change the permission grant.`
);
}
Log.log(
`The permissions granted during authentication limit your access to some accounts. Run ${chalk.bold(
'eas login'
)} to change the permission grant.`
);
}
} else {
Log.warn('Not logged in');
process.exit(1);
}
}
/**
* Whether the scopes of the credential used for this command remove any of the permissions that
* the viewer's role grants on the account.
*/
private static isLimitedByCredentials(account: Actor['accounts'][0]): boolean {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
return permissions.some(permission => !effectivePermissions.includes(permission));
}
private static getLabelForPermissions(permissions: readonly Permission[]): string {
const role = getRoleForPermissions(permissions);
if (role !== Role.Custom) {
return AccountView.getLabelForRole(role);
}
if (permissions.length === 0) {
return 'No permissions';
}
return `Custom: ${permissions
.map(permission => permission.toLowerCase().replaceAll('_', ' '))
.join(', ')}`;
}
private static getLabelForRole(role: Role): string {
switch (role) {
case Role.Owner:
return 'Owner';
case Role.Admin:
return 'Admin';
case Role.Developer:
return 'Developer';
case Role.ReleaseManager:
return 'Release Manager';
case Role.Runner:
return 'Runner';
case Role.ViewOnly:
return 'Viewer';
case Role.Custom:
case Role.HasAdmin:
case Role.NotAdmin:
return 'Custom';
// A role added to the API after this version of the CLI was published. TypeScript narrows
// `role` to `never` here, but the server can still send one, so render its name rather than
// returning undefined.
default: {
const unknownRole: string = role;
return unknownRole
.split('_')
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ');
}
}
}
}
+12 -6
View File
@@ -6,13 +6,19 @@ export default class EasHelp extends Help {
const [command, ...args] = argv;
if (command === 'simulator:control' || command === 'sim:control') {
const { default: SimulatorControl } = await import('./commands/simulator/control');
const parsed = await Parser.parse(args, {
args: SimulatorControl.args,
flags: SimulatorControl.flags,
strict: false,
});
let parsed;
try {
parsed = await Parser.parse(args, {
args: SimulatorControl.args,
flags: SimulatorControl.flags,
strict: false,
});
} catch {
await super.showHelp(argv);
return;
}
if (parsed.args.action && parsed.flags.help) {
await SimulatorControl.run(args, this.config);
await this.config.runCommand('simulator:control', args);
return;
}
}
+6
View File
@@ -39,6 +39,12 @@ export default class Log {
Log.consoleLog(...Log.withTextColor(args, chalk.red));
}
public static errorToStderr(...args: any[]): void {
Log.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console
console.error(...Log.withTextColor(args, chalk.stderr.red));
}
public static warn(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
}
@@ -1,3 +1,4 @@
import { EasCommandError } from '../commandUtils/errors';
import { Actor, getCreatableAccountNames } from '../user/User';
/**
@@ -20,8 +21,8 @@ export function getUnconfiguredProjectError({
accountName?: string;
reason?: string;
additionalFix?: string;
}): Error {
return new Error(
}): EasCommandError {
return new EasCommandError(
`EAS project not configured. ${reason} ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
@@ -29,6 +30,7 @@ export function getUnconfiguredProjectError({
`To create a new project:\n\n` +
` eas init --account ${accountName ?? '<account-name>'} --non-interactive\n\n` +
`Accounts you can create projects in: ${getCreatableAccountNames(actor).join(', ')}` +
(additionalFix ? `\n\n${additionalFix}` : '')
(additionalFix ? `\n\n${additionalFix}` : ''),
{ code: 'EAS_PROJECT_NOT_CONFIGURED' }
);
}
+2 -1
View File
@@ -185,7 +185,8 @@ export default class SessionManager {
)} environment variable if you're using EAS CLI on CI (${learnMore(
'https://docs.expo.dev/accounts/programmatic-access/',
{ dim: false }
)})`
)})`,
{ code: 'EAS_LOGIN_REQUIRED' }
);
}
if (printNewLine) {
@@ -313,6 +313,15 @@ describe(SessionManager, () => {
});
describe('showLoginPromptAsync', () => {
it('fails with the EAS_LOGIN_REQUIRED code in non-interactive mode', async () => {
const sessionManager = new SessionManager(analytics);
await expect(
sessionManager.showLoginPromptAsync({ nonInteractive: true })
).rejects.toMatchObject({ code: 'EAS_LOGIN_REQUIRED' });
expect(promptAsync).not.toHaveBeenCalled();
});
it('prompts for OTP when 2FA is enabled', async () => {
jest
.mocked(promptAsync)
@@ -0,0 +1,97 @@
import chalk from 'chalk';
import { Permission, Role } from '../graphql/generated';
import Log from '../log';
import { Actor } from './User';
import { getRoleForPermissions } from './roles';
/**
* Logs the actor's role on each account, and whether the credentials used for this command limit
* it. The list is skipped when it only contains the personal account and the credentials do not
* limit it.
*
* @returns whether the credentials limit the permissions on any account
*/
export function logAccountPermissions(actor: Actor): { hasLimitedAccount: boolean } {
// personal account is included, only show if more accounts that personal account
// but do show personal account in list if there are more, or if the credentials used for
// this command limit the permissions on it
const accountExcludingPersonalAccount = actor.accounts.filter(
account => !('username' in actor) || account.name !== actor.username
);
const hasLimitedAccount = actor.accounts.some(isLimitedByCredentials);
if (accountExcludingPersonalAccount.length === 0 && !hasLimitedAccount) {
return { hasLimitedAccount };
}
Log.newLine();
Log.log('Accounts:');
actor.accounts.forEach(account => {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
const roleLabel = getLabelForPermissions(permissions);
if (isLimitedByCredentials(account)) {
const effectiveRoleLabel = getLabelForPermissions(effectivePermissions);
Log.log(
`• ${account.name} (Role: ${roleLabel}, ${chalk.yellow(
`limited to ${effectiveRoleLabel} by the current credentials`
)})`
);
} else {
Log.log(`• ${account.name} (Role: ${roleLabel})`);
}
});
return { hasLimitedAccount };
}
/**
* Whether the scopes of the credential used for this command remove any of the permissions that
* the viewer's role grants on the account.
*/
function isLimitedByCredentials(account: Actor['accounts'][0]): boolean {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
return permissions.some(permission => !effectivePermissions.includes(permission));
}
function getLabelForPermissions(permissions: readonly Permission[]): string {
const role = getRoleForPermissions(permissions);
if (role !== Role.Custom) {
return getLabelForRole(role);
}
if (permissions.length === 0) {
return 'No permissions';
}
return `Custom: ${permissions
.map(permission => permission.toLowerCase().replaceAll('_', ' '))
.join(', ')}`;
}
function getLabelForRole(role: Role): string {
switch (role) {
case Role.Owner:
return 'Owner';
case Role.Admin:
return 'Admin';
case Role.Developer:
return 'Developer';
case Role.ReleaseManager:
return 'Release Manager';
case Role.Runner:
return 'Runner';
case Role.ViewOnly:
return 'Viewer';
case Role.Custom:
case Role.HasAdmin:
case Role.NotAdmin:
return 'Custom';
// A role added to the API after this version of the CLI was published. TypeScript narrows
// `role` to `never` here, but the server can still send one, so render its name rather than
// returning undefined.
default: {
const unknownRole: string = role;
return unknownRole
.split('_')
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ');
}
}
}
@@ -0,0 +1,36 @@
describe('printJsonErrorOutput', () => {
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
afterEach(() => {
jest.restoreAllMocks();
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
});
it('prints one error object on stdout, without ANSI codes, once per process', () => {
const stdout = jest.fn(() => true);
const stderr = jest.fn(() => true);
process.stdout.write = stdout as any;
process.stderr.write = stderr as any;
// Jest's console does not write through process.stdout, so record which stream was live.
const printed: { text: string; toStdout: boolean }[] = [];
jest.spyOn(console, 'log').mockImplementation((text: string) => {
printed.push({ text, toStdout: process.stdout.write === stdout });
});
jest.isolateModules(() => {
const { printJsonErrorOutput } = require('../json');
printJsonErrorOutput({
message: '\u001b[31mEAS project not configured.\u001b[39m',
});
printJsonErrorOutput({ message: 'update:republish command failed.' });
});
expect(printed).toHaveLength(1);
expect(printed[0].toStdout).toBe(true);
expect(JSON.parse(printed[0].text)).toEqual({
error: { message: 'EAS project not configured.' },
});
});
});
+25
View File
@@ -1,4 +1,5 @@
import assert from 'assert';
import { stripVTControlCharacters } from 'util';
import Log from '../log';
@@ -22,6 +23,30 @@ export function printJsonOnlyOutput(value: object): void {
}
}
export interface JsonErrorOutput {
message: string;
code?: string;
requestId?: string;
}
let jsonErrorPrinted = false;
/**
* Print `{ "error": { message, code?, requestId? } }` on stdout for a command that failed with --json.
*
* Once per process: `update:rollback` runs other commands in-process, and the innermost error is the one
* that says what went wrong.
*/
export function printJsonErrorOutput(error: JsonErrorOutput): void {
if (jsonErrorPrinted) {
return;
}
jsonErrorPrinted = true;
// The command may have failed before it could enable JSON output.
enableJsonOutput();
printJsonOnlyOutput({ error: { ...error, message: stripVTControlCharacters(error.message) } });
}
function sanitizeValue(value: any): unknown {
if (Array.isArray(value)) {
return value.map(val => sanitizeValue(val));
+6
View File
@@ -1,5 +1,11 @@
# @expo/eas-json
## 26.0.0
### Minor Changes
- Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
## 25.3.0
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "25.3.0",
"version": "26.0.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "25.3.0",
"@expo/eas-build-job": "26.0.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
@@ -17,6 +17,7 @@ module Pod
%r{https?://(api\.)?github\.com/},
%r{https?://sourceforge\.net/},
%r{https?://repo1\.maven\.org/},
%r{https?://central\.sonatype\.com/},
%r{https?://boostorg\.jfrog\.io/},
%r{https?://www\.sqlite\.org/},
%r{https?://download\.videolan\.org/},
@@ -142,6 +142,17 @@ RSpec.describe ExpoCocoaPodsProxy do
downloader.download_file(path.to_s)
end
it 'fetches Sonatype snapshot artifacts via proxy' do
path = Pathname.new('some/fake/path')
url = 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/react/react-native-artifacts/0.82.0-SNAPSHOT/maven-metadata.xml'
opts = {}
downloader = Pod::Downloader::Http.new(path, url, opts)
expect(downloader).to receive(:download_file_via_proxy).once {}
downloader.download_file(path.to_s)
end
it 'does not proxy when url doesn\'t match an allowlist regex' do
path = Pathname.new('some/fake/path')
url = 'https://not-github.com/expo/test-repo/archive/main.tar.gz'
+4
View File
@@ -1,5 +1,9 @@
# eas-cli-local-build-plugin
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "25.3.0",
"version": "26.0.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,12 +26,12 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "25.3.0",
"@expo/build-tools": "26.0.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "25.3.0",
"@expo/logger": "25.3.0",
"@expo/eas-build-job": "26.0.0",
"@expo/logger": "26.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "25.3.0",
"@expo/turtle-spawn": "26.0.0",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more