Compare commits

...
10 Commits
Author SHA1 Message Date
Expo CI 8fdf47aa7d v18.9.0 2026-04-30 00:08:37 +00:00
Douglas Lowder 2279b650d5 [eas-cli] Allow eas build:download to accept a build ID (#3655)
# Why

For certain workflows, it is useful to be able to download build artifacts for a known build ID.

# How

Added a new flag `--build-id` to the `eas build:download` command. When this flag is used,`--fingerprint`, `--dev-client`, and `--platform` are excluded.

Also added the `--all-artifacts`flag. When set, all available artifacts are downloaded. Otherwise, the previous behavior applies (only application archive URL is downloaded).

# Test Plan

- Tested in a real project
- New unit tests added
2026-04-29 16:43:28 -07:00
Gabe Debes c6a36e2e47 [steps] Fix job context interpolation when one string has multiple ${{ }} blocks (#3644) 2026-04-29 15:53:34 -07:00
Szymon Świerk 27cb8c12d6 [eas-cli] Add support for configuring app store connect integration (#3558)
* ASC app link GQL queries

* ASC app link GQL mutations

* ASC utils

* status command

* disconnect command

* select or create ASC API key util function

* connect command

* add oclif connections topic

* gql codegen

* changelog

* regenerate readme

* dedicated app store api key purpose

* prompt user to choose the api key role

* remove unncecessary defensiveness in getting links remoteAppStoreConnectApp

* handle GQL error when invalid connection

* format

* rename connections to integations
2026-04-29 15:14:40 +02:00
Szymon Dziedzic 1122a7f374 [eas-cli] prompt users to stop device run session when they are done by running eas simulator:stop command (#3633) 2026-04-28 20:24:20 +02:00
Stanisław Chmiela c06d18138c [worker] Set precompiled modules base URL (#3642)
## Summary

Sets `EXPO_PRECOMPILED_MODULES_BASE_URL` for iOS worker builds that opt into precompiled modules with `EAS_USE_PRECOMPILED_MODULES=1`.

When the CocoaPods cache is configured, the URL is routed through that cache using the same host-prefix style as other cached downloads. When the cache is not configured, it points directly to `https://storage.googleapis.com/eas-build-precompiled-modules/`.

Local builds are unchanged and do not receive this env var.

## Test Plan

- `yarn oxfmt packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/worker typecheck`
2026-04-28 19:33:42 +02:00
Szymon Dziedzic 3f4ff1dc3e [eas-cli] add eas simulator:stop command (#3626) 2026-04-27 14:11:13 +02:00
Mustafa Abban e852ab1cb7 [worker] - wrap error log fetch in try-catch to prevent unhandled rejections (#3631)
* [worker] - wrap error log fetch in try-catch to prevent unhandled rejections

* prevent real HTTP requests in integration tests
2026-04-24 12:28:11 -04:00
Mustafa Abban d941398ce6 [build-errors] - log all build errors with enriched metadata (#3612)
* enrich logs

* more metadata

* all error codes

* top level tags
2026-04-24 11:28:15 -04:00
Expo CI bae7684996 update CHANGELOG.md 2026-04-23 16:02:14 +00:00
43 changed files with 3931 additions and 158 deletions

No files matched your search

+7
View File
@@ -8,10 +8,17 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
### 🎉 New features
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.8.1",
"version": "18.9.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.8.1",
"version": "18.9.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -45,7 +45,7 @@
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.8.0",
"@expo/steps": "18.9.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.8.0",
"version": "18.9.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.8.0"
"@expo/steps": "18.9.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+162 -94
View File
@@ -117,6 +117,9 @@ eas --help COMMAND
* [`eas help [COMMAND]`](#eas-help-command)
* [`eas init`](#eas-init)
* [`eas init:onboarding [TARGET_PROJECT_DIRECTORY]`](#eas-initonboarding-target_project_directory)
* [`eas integrations:asc:connect`](#eas-integrationsascconnect)
* [`eas integrations:asc:disconnect`](#eas-integrationsascdisconnect)
* [`eas integrations:asc:status`](#eas-integrationsascstatus)
* [`eas login`](#eas-login)
* [`eas logout`](#eas-logout)
* [`eas metadata:lint`](#eas-metadatalint)
@@ -180,7 +183,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -197,7 +200,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -219,7 +222,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -236,7 +239,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -250,7 +253,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -302,7 +305,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -323,7 +326,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -343,7 +346,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -363,7 +366,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -386,7 +389,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -424,7 +427,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -444,7 +447,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -462,7 +465,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -482,7 +485,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -504,28 +507,34 @@ DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
download simulator/emulator builds for a given fingerprint hash
download a simulator/emulator build by build ID or fingerprint hash
```
USAGE
$ eas build:download --fingerprint <value> [-p ios|android] [--dev-client] [--json] [--non-interactive]
$ eas build:download [--build-id <value> | --fingerprint <value> | -p ios|android | --dev-client]
[--all-artifacts] [--json] [--non-interactive]
FLAGS
-p, --platform=<option> <options: ios|android>
--all-artifacts Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in
addition to the application archive. Without this flag, only the application archive is
downloaded and the command errors if it is missing.
--build-id=<value> ID of the build to download. Mutually exclusive with --fingerprint, --platform, and
--dev-client; the platform is derived from the build itself.
--[no-]dev-client Filter only dev-client builds.
--fingerprint=<value> (required) Fingerprint hash of the build to download
--fingerprint=<value> Fingerprint hash of the build to download
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
download a simulator/emulator build by build ID or fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -563,7 +572,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -603,7 +612,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -633,7 +642,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -659,7 +668,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -711,7 +720,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -729,7 +738,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -747,7 +756,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -764,7 +773,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -785,7 +794,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -806,7 +815,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -828,7 +837,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -852,7 +861,7 @@ DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -872,7 +881,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -894,7 +903,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -916,7 +925,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -951,7 +960,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -974,7 +983,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -995,7 +1004,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1012,7 +1021,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1030,7 +1039,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1059,7 +1068,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1084,7 +1093,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1105,7 +1114,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1126,7 +1135,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1163,7 +1172,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1183,7 +1192,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1204,7 +1213,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1225,7 +1234,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1239,7 +1248,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1253,7 +1262,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1285,7 +1294,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1311,7 +1320,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1333,7 +1342,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1361,7 +1370,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1389,7 +1398,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1412,7 +1421,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1434,7 +1443,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1468,7 +1477,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1512,7 +1521,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1543,7 +1552,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1602,6 +1611,65 @@ ALIASES
$ eas onboarding
```
## `eas integrations:asc:connect`
connect a project to an App Store Connect app
```
USAGE
$ eas integrations:asc:connect [--api-key-id <value>] [--asc-app-id <value>] [--bundle-id <value>] [--json]
[--non-interactive]
FLAGS
--api-key-id=<value> Apple App Store Connect API Key ID
--asc-app-id=<value> App Store Connect app identifier
--bundle-id=<value> Filter discovered apps by bundle identifier
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
connect a project to an App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
disconnect the current project from its App Store Connect app
```
USAGE
$ eas integrations:asc:disconnect [--yes] [--json] [--non-interactive]
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
--yes Skip confirmation prompt
DESCRIPTION
disconnect the current project from its App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
show the App Store Connect app link status for the current project
```
USAGE
$ eas integrations:asc:status [--json] [--non-interactive]
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
show the App Store Connect app link status for the current project
```
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas login`
log in with your Expo account
@@ -1652,7 +1720,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1670,7 +1738,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1688,7 +1756,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1740,7 +1808,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1754,7 +1822,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1777,7 +1845,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1801,7 +1869,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1819,7 +1887,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1852,7 +1920,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1895,7 +1963,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1916,7 +1984,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1937,7 +2005,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1962,7 +2030,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:insights GROUPID`
@@ -1989,7 +2057,7 @@ DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2016,7 +2084,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2054,7 +2122,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2082,7 +2150,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2112,7 +2180,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2133,7 +2201,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2157,7 +2225,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2178,7 +2246,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2200,7 +2268,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2220,7 +2288,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2239,7 +2307,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2262,7 +2330,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2279,7 +2347,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2400,7 +2468,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2420,7 +2488,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2444,7 +2512,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2484,7 +2552,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2506,7 +2574,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2534,7 +2602,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2554,7 +2622,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2576,5 +2644,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.8.1",
"version": "18.9.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.8.0",
"@expo/steps": "18.9.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -201,6 +201,9 @@
"env": {
"description": "manage project and account environment variables"
},
"integrations": {
"description": "manage service connections"
},
"metadata": {
"description": "manage store configuration"
},
@@ -0,0 +1,197 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform, BuildFragment } from '../../../graphql/generated';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import Download from '../download';
jest.mock('../../../graphql/queries/BuildQuery');
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockByIdAsync = jest.mocked(BuildQuery.byIdAsync);
const mockViewBuildsOnAppAsync = jest.mocked(BuildQuery.viewBuildsOnAppAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeBuild(overrides: Partial<BuildFragment> = {}): BuildFragment {
return {
id: 'build-123',
platform: AppPlatform.Ios,
project: { id: 'project-1' },
artifacts: {
applicationArchiveUrl: 'https://example.com/app.tar.gz',
buildArtifactsUrl: null,
xcodeBuildLogsUrl: null,
buildUrl: null,
},
...overrides,
} as unknown as BuildFragment;
}
describe(Download, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
});
function createCommand(argv: string[]): Download {
const command = new Download(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
});
// Stub the file-system-touching helpers so we don't hit disk.
jest
.spyOn(command as any, 'getPathToBuildArtifactAsync')
.mockResolvedValue('/cache/path/to/build.app');
jest.spyOn(command as any, 'downloadExtraArtifactsAsync').mockResolvedValue({});
return command;
}
it('errors when neither --build-id nor --fingerprint is provided', async () => {
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(
'Either --build-id or --fingerprint is required.'
);
});
it('fetches build by ID when --build-id is provided', async () => {
mockByIdAsync.mockResolvedValue(makeBuild());
const command = createCommand(['--build-id', 'build-123']);
await command.runAsync();
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'build-123');
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
});
it('fetches builds by fingerprint when --fingerprint is provided', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
const command = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
await command.runAsync();
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledTimes(1);
expect(mockViewBuildsOnAppAsync.mock.calls[0][1].filter?.fingerprintHash).toBe('fp-abc');
expect(mockByIdAsync).not.toHaveBeenCalled();
});
it('uses platform from the build itself when --build-id is provided', async () => {
mockByIdAsync.mockResolvedValue(makeBuild({ platform: AppPlatform.Android }));
const command = createCommand(['--build-id', 'build-123']);
await command.runAsync();
const getPathSpy = jest.spyOn(command as any, 'getPathToBuildArtifactAsync');
// The spy was set up after instantiation; just ensure runAsync used the build's platform
// by checking that no platform prompt was needed.
expect(getPathSpy).toBeDefined();
});
it('errors with a helpful message when applicationArchiveUrl is missing and other artifacts exist', async () => {
mockByIdAsync.mockResolvedValue(
makeBuild({
artifacts: {
applicationArchiveUrl: null,
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
xcodeBuildLogsUrl: 'https://example.com/xcode-logs.tar.gz',
buildUrl: null,
} as any,
})
);
const command = createCommand(['--build-id', 'build-123']);
await expect(command.runAsync()).rejects.toThrow(
/Other artifacts are available \(buildArtifacts, xcodeBuildLogs\); re-run with --all-artifacts/
);
});
it('errors with the basic message when applicationArchiveUrl is missing and no other artifacts exist', async () => {
mockByIdAsync.mockResolvedValue(
makeBuild({
artifacts: {
applicationArchiveUrl: null,
buildArtifactsUrl: null,
xcodeBuildLogsUrl: null,
buildUrl: null,
} as any,
})
);
const command = createCommand(['--build-id', 'build-123']);
await expect(command.runAsync()).rejects.toThrow('Build does not have an application archive url');
});
it('does not throw when applicationArchiveUrl is missing but --all-artifacts is set', async () => {
mockByIdAsync.mockResolvedValue(
makeBuild({
artifacts: {
applicationArchiveUrl: null,
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
xcodeBuildLogsUrl: null,
buildUrl: null,
} as any,
})
);
const command = createCommand(['--build-id', 'build-123', '--all-artifacts']);
const downloadExtrasSpy = jest
.spyOn(command as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({ buildArtifacts: '/cache/build-123-artifacts/build-artifacts.tar.gz' });
await expect(command.runAsync()).resolves.not.toThrow();
expect(downloadExtrasSpy).toHaveBeenCalled();
});
it('downloads extras only when --all-artifacts is set, regardless of --build-id vs --fingerprint', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
const commandWithoutFlag = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
const downloadExtrasSpyWithout = jest
.spyOn(commandWithoutFlag as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({});
await commandWithoutFlag.runAsync();
expect(downloadExtrasSpyWithout).not.toHaveBeenCalled();
const commandWithFlag = createCommand([
'--fingerprint',
'fp-abc',
'--platform',
'ios',
'--all-artifacts',
]);
const downloadExtrasSpyWith = jest
.spyOn(commandWithFlag as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({ buildArtifacts: '/path/to/extras' });
await commandWithFlag.runAsync();
expect(downloadExtrasSpyWith).toHaveBeenCalled();
});
it('emits JSON with path and extras when --json --all-artifacts are set', async () => {
mockByIdAsync.mockResolvedValue(makeBuild());
const command = createCommand([
'--build-id',
'build-123',
'--all-artifacts',
'--json',
'--non-interactive',
]);
jest
.spyOn(command as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({ buildArtifacts: '/cache/extras/build-artifacts.tar.gz' });
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
path: '/cache/path/to/build.app',
buildArtifacts: '/cache/extras/build-artifacts.tar.gz',
});
});
});
+164 -19
View File
@@ -1,7 +1,8 @@
import { Platform } from '@expo/eas-build-job';
import { Errors, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { pathExists } from 'fs-extra';
import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -15,24 +16,43 @@ import { toAppPlatform } from '../../graphql/types/AppPlatform';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getEasBuildRunCachedAppPath } from '../../run/run';
import { downloadAndMaybeExtractAppAsync } from '../../utils/download';
import {
downloadAndMaybeExtractAppAsync,
downloadFileWithProgressTrackerAsync,
} from '../../utils/download';
import { formatBytes } from '../../utils/files';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getEasBuildRunCacheDirectoryPath } from '../../utils/paths';
export default class Download extends EasCommand {
static override description = 'download simulator/emulator builds for a given fingerprint hash';
static override description =
'download a simulator/emulator build by build ID or fingerprint hash';
static override flags = {
'build-id': Flags.string({
description:
'ID of the build to download. Mutually exclusive with --fingerprint, --platform, and --dev-client; the platform is derived from the build itself.',
exclusive: ['fingerprint', 'platform', 'dev-client'],
}),
fingerprint: Flags.string({
description: 'Fingerprint hash of the build to download',
required: true,
exclusive: ['build-id'],
}),
platform: Flags.option({
char: 'p',
options: [Platform.IOS, Platform.ANDROID] as const,
exclusive: ['build-id'],
})(),
'dev-client': Flags.boolean({
description: 'Filter only dev-client builds.',
allowNo: true,
exclusive: ['build-id'],
}),
'all-artifacts': Flags.boolean({
description:
'Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in addition to the application archive. Without this flag, only the application archive is downloaded and the command errors if it is missing.',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -45,14 +65,20 @@ export default class Download extends EasCommand {
async runAsync(): Promise<void> {
const {
flags: {
'build-id': buildId,
platform,
fingerprint,
'dev-client': developmentClient,
'all-artifacts': allArtifacts,
...rawFlags
},
} = await this.parse(Download);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
if (!buildId && !fingerprint) {
throw new Error('Either --build-id or --fingerprint is required.');
}
const {
loggedIn: { graphqlClient },
projectId,
@@ -64,24 +90,113 @@ export default class Download extends EasCommand {
enableJsonOutput();
}
const selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
const build = await this.getBuildAsync({
graphqlClient,
projectId,
platform: selectedPlatform,
fingerprintHash: fingerprint,
developmentClient,
});
const buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
let build: BuildFragment;
let selectedPlatform: AppPlatform;
if (buildId) {
build = await this.getBuildByIdAsync({ graphqlClient, buildId });
selectedPlatform = build.platform;
Log.succeed(`🎯 Found build ${chalk.bold(buildId)} on EAS servers.`);
} else {
selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
build = await this.getBuildByFingerprintAsync({
graphqlClient,
projectId,
platform: selectedPlatform,
fingerprintHash: fingerprint!,
developmentClient,
});
}
let buildArtifactPath: string | null = null;
if (build.artifacts?.applicationArchiveUrl) {
buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
} else if (!allArtifacts) {
const availableArtifacts = listAvailableExtraArtifactNames(build);
if (availableArtifacts.length > 0) {
throw new Error(
`Build does not have an application archive url. Other artifacts are available (${availableArtifacts.join(', ')}); re-run with --all-artifacts to download them.`
);
}
throw new Error('Build does not have an application archive url');
}
let extraArtifactPaths: Record<string, string> = {};
if (allArtifacts) {
extraArtifactPaths = await this.downloadExtraArtifactsAsync(build);
}
if (jsonFlag) {
const jsonResults = { path: buildArtifactPath };
const jsonResults = {
...(buildArtifactPath != null && { path: buildArtifactPath }),
...extraArtifactPaths,
};
printJsonOnlyOutput(jsonResults);
} else {
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
if (buildArtifactPath != null) {
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
}
for (const [name, filePath] of Object.entries(extraArtifactPaths)) {
Log.log(`${name} downloaded to ${chalk.bold(filePath)}`);
}
}
}
private async getBuildAsync({
private async downloadExtraArtifactsAsync(build: BuildFragment): Promise<Record<string, string>> {
const artifacts = build.artifacts;
if (!artifacts) {
return {};
}
const extraArtifacts: Array<{ name: string; url: string }> = [];
if (artifacts.buildArtifactsUrl) {
extraArtifacts.push({ name: 'buildArtifacts', url: artifacts.buildArtifactsUrl });
}
if (artifacts.xcodeBuildLogsUrl) {
extraArtifacts.push({ name: 'xcodeBuildLogs', url: artifacts.xcodeBuildLogsUrl });
}
if (artifacts.buildUrl && artifacts.buildUrl !== artifacts.applicationArchiveUrl) {
extraArtifacts.push({ name: 'build', url: artifacts.buildUrl });
}
if (extraArtifacts.length === 0) {
return {};
}
const outputDir = path.join(getEasBuildRunCacheDirectoryPath(), `${build.id}-artifacts`);
await fs.ensureDir(outputDir);
const downloaded: Record<string, string> = {};
for (const { name, url } of extraArtifacts) {
const fileName = getFileNameFromUrl(url, name);
const outputPath = path.join(outputDir, fileName);
await downloadFileWithProgressTrackerAsync(
url,
outputPath,
(ratio, total) =>
`Downloading ${name} (${formatBytes(total * ratio)} / ${formatBytes(total)})`,
`Successfully downloaded ${name}`
);
downloaded[name] = outputPath;
}
return downloaded;
}
private async getBuildByIdAsync({
graphqlClient,
buildId,
}: {
graphqlClient: ExpoGraphqlClient;
buildId: string;
}): Promise<BuildFragment> {
try {
return await BuildQuery.byIdAsync(graphqlClient, buildId);
} catch (error: any) {
throw new Error(`Could not find build with ID ${buildId}: ${error.message}`);
}
}
private async getBuildByFingerprintAsync({
graphqlClient,
projectId,
platform,
@@ -108,7 +223,7 @@ export default class Download extends EasCommand {
limit: 1,
});
if (builds.length === 0) {
throw new Errors.CLIError(
throw new Error(
`No builds available for ${platform} with fingerprint hash ${fingerprintHash}`
);
}
@@ -123,7 +238,7 @@ export default class Download extends EasCommand {
build.id,
platform
);
if (await pathExists(cachedBuildArtifactPath)) {
if (await fs.pathExists(cachedBuildArtifactPath)) {
Log.newLine();
Log.log(`Using cached build...`);
return cachedBuildArtifactPath;
@@ -167,3 +282,33 @@ async function resolvePlatformAsync({
});
return selectedPlatform;
}
function listAvailableExtraArtifactNames(build: BuildFragment): string[] {
const names: string[] = [];
if (build.artifacts?.buildArtifactsUrl) {
names.push('buildArtifacts');
}
if (build.artifacts?.xcodeBuildLogsUrl) {
names.push('xcodeBuildLogs');
}
if (
build.artifacts?.buildUrl &&
build.artifacts.buildUrl !== build.artifacts.applicationArchiveUrl
) {
names.push('build');
}
return names;
}
function getFileNameFromUrl(url: string, fallbackName: string): string {
try {
const pathname = new URL(url).pathname;
const basename = path.basename(pathname);
if (basename) {
return basename;
}
} catch {
// fall through to default
}
return fallbackName;
}
@@ -0,0 +1,371 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../../../../commandUtils/errors';
import { selectOrCreateAscApiKeyIdAsync } from '../../../../integrations/asc/ascApiKey';
import { AppStoreConnectApiKeyQuery } from '../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import IntegrationsAscConnect from '../connect';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
jest.mock('../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery');
jest.mock('../../../../integrations/asc/ascApiKey');
jest.mock('../../../../log');
jest.mock('../../../../ora');
const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
const mockRemoteApps = [
{
ascAppIdentifier: '9876543210',
bundleIdentifier: 'com.test.newapp',
name: 'New App',
appStoreIconUrl: null,
},
];
describe(IntegrationsAscConnect, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const actor = { id: 'actor-id' } as any;
const analytics = {} as any;
const vcsClient = {} as any;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
it('fails when already connected', async () => {
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
const command = new IntegrationsAscConnect(
['--api-key-id', 'key-id', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(EasCommandError);
});
it('connects successfully in non-interactive mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected)
.mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await command.runAsync();
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
ascAppIdentifier: '9876543210',
appStoreConnectApiKeyId: 'key-id',
});
});
it('accepts Apple key identifier in non-interactive mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected)
.mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'eas-key-uuid',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await command.runAsync();
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
ascAppIdentifier: '9876543210',
appStoreConnectApiKeyId: 'eas-key-uuid',
});
});
it('requires --api-key-id in non-interactive mode', async () => {
const command = new IntegrationsAscConnect(
['--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('--api-key-id is required');
});
it('requires --asc-app-id in non-interactive mode', async () => {
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('--asc-app-id is required');
});
it('fails when asc-app-id is not found among discovered apps', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', 'nonexistent', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('was not found among accessible apps');
});
it('fails when passing EAS key id in non-interactive mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'eas-key-uuid',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'eas-key-uuid', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(
'No App Store Connect API key found with Apple key identifier'
);
});
it('fails when multiple keys match Apple key identifier', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'eas-key-uuid-1',
keyIdentifier: 'FAKEKEY000',
},
{
id: 'eas-key-uuid-2',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(
'Multiple App Store Connect API keys match Apple key identifier'
);
});
it('fails when no accessible apps are discovered', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce([]);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(
'No accessible apps found on App Store Connect'
);
});
it('fails when app discovery throws', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest
.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync)
.mockRejectedValueOnce(new Error('discovery failed'));
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('discovery failed');
});
it('creates or selects key when no api-key-id is provided', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected)
.mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([]);
jest.mocked(selectOrCreateAscApiKeyIdAsync).mockResolvedValueOnce('generated-key-id');
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
const command = new IntegrationsAscConnect(['--asc-app-id', '9876543210'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await command.runAsync();
expect(selectOrCreateAscApiKeyIdAsync).toHaveBeenCalled();
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
ascAppIdentifier: '9876543210',
appStoreConnectApiKeyId: 'generated-key-id',
});
});
});
@@ -0,0 +1,160 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../../log';
import { toggleConfirmAsync } from '../../../../prompts';
import IntegrationsAscDisconnect from '../disconnect';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
jest.mock('../../../../log');
jest.mock('../../../../ora');
jest.mock('../../../../prompts');
const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
describe(IntegrationsAscDisconnect, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
it('disconnects successfully with --yes', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataConnected)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AscAppLinkMutation.deleteAppStoreConnectAppAsync).mockResolvedValueOnce(undefined);
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).toHaveBeenCalledWith(
graphqlClient,
'asc-app-link-id'
);
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenNthCalledWith(
2,
graphqlClient,
testProjectId,
{
useCache: false,
}
);
});
it('no-op when already disconnected', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
expect(jest.mocked(Log.log)).toHaveBeenCalled();
});
it('prints json output when already disconnected in json mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscDisconnect(['--yes', '--json'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
expect.stringContaining('"action": "disconnect"')
);
});
it('handles invalid ASC API key gracefully', async () => {
const ascError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
expect.stringContaining('revoked or is no longer valid')
);
});
it('cancels disconnection when confirmation is rejected', async () => {
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(toggleConfirmAsync).mockResolvedValueOnce(false);
const command = new IntegrationsAscDisconnect([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await expect(command.runAsync()).rejects.toThrow('process.exit called');
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
processExitSpy.mockRestore();
});
});
@@ -0,0 +1,155 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../../log';
import IntegrationsAscStatus from '../status';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../log');
jest.mock('../../../../ora');
const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
describe(IntegrationsAscStatus, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
it('displays connected status', async () => {
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
expect(jest.mocked(Log.log)).toHaveBeenCalled();
});
it('displays disconnected status', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalled();
});
it('prints json output in json mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscStatus(['--json'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
expect.stringContaining('"action": "status"')
);
});
it('handles invalid ASC API key gracefully', async () => {
const ascError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
expect.stringContaining('revoked or is no longer valid')
);
});
it('handles invalid ASC API key with json output', async () => {
const ascError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
const command = new IntegrationsAscStatus(['--json'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
expect.stringContaining('"status": "invalid"')
);
});
it('throws when fetching status fails', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockRejectedValueOnce(new Error('status failed'));
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await expect(command.runAsync()).rejects.toThrow('status failed');
});
});
@@ -0,0 +1,197 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { EasCommandError } from '../../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { CredentialsContext } from '../../../credentials/context';
import { buildJsonOutput, formatAscAppLinkStatus } from '../../../integrations/asc/utils';
import { selectOrCreateAscApiKeyIdAsync } from '../../../integrations/asc/ascApiKey';
import { AppStoreConnectApiKeyQuery } from '../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { selectAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
type DiscoveredAscApps = Awaited<ReturnType<typeof AscAppLinkQuery.discoverAccessibleAppsAsync>>;
export default class IntegrationsAscConnect extends EasCommand {
static override description = 'connect a project to an App Store Connect app';
static override flags = {
'api-key-id': Flags.string({
description: 'Apple App Store Connect API Key ID',
}),
'asc-app-id': Flags.string({
description: 'App Store Connect app identifier',
}),
'bundle-id': Flags.string({
description: 'Filter discovered apps by bundle identifier',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsAscConnect);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
if (nonInteractive) {
if (!flags['api-key-id']) {
throw new EasCommandError('--api-key-id is required in non-interactive mode.');
}
if (!flags['asc-app-id']) {
throw new EasCommandError('--asc-app-id is required in non-interactive mode.');
}
}
const {
projectId,
projectDir,
loggedIn: { actor, graphqlClient },
analytics,
vcsClient,
} = await this.getContextAsync(IntegrationsAscConnect, {
nonInteractive,
});
// Step 1: Check current status
const statusSpinner = ora('Checking current App Store Connect app link status').start();
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
statusSpinner.succeed('Checked current status');
if (metadata.appStoreConnectApp) {
throw new EasCommandError(
`Project ${chalk.bold(metadata.fullName)} is already connected to App Store Connect app ${chalk.bold(metadata.appStoreConnectApp.ascAppIdentifier)}. Disconnect first with ${chalk.bold('eas integrations:asc:disconnect')}.`
);
}
// Step 2: Get ASC API key
const keysSpinner = ora('Fetching App Store Connect API keys').start();
const keys = await AppStoreConnectApiKeyQuery.getAllForAccountAsync(
graphqlClient,
metadata.ownerAccount.name
);
keysSpinner.succeed(`Found ${keys.length} API key(s)`);
let apiKeyId = flags['api-key-id'];
if (!apiKeyId) {
const credentialsContext = new CredentialsContext({
projectInfo: null,
nonInteractive,
projectDir,
user: actor,
graphqlClient,
analytics,
vcsClient,
});
apiKeyId = await selectOrCreateAscApiKeyIdAsync({
credentialsContext,
existingKeys: keys,
ownerAccount: metadata.ownerAccount,
});
} else {
const keysByAppleId = keys.filter(key => key.keyIdentifier === apiKeyId);
if (keysByAppleId.length > 1) {
throw new EasCommandError(
`Multiple App Store Connect API keys match Apple key identifier "${apiKeyId}".`
);
} else if (keysByAppleId.length === 1) {
apiKeyId = keysByAppleId[0].id;
} else {
throw new EasCommandError(
`No App Store Connect API key found with Apple key identifier "${apiKeyId}".`
);
}
}
if (!apiKeyId) {
throw new EasCommandError('No App Store Connect API key selected.');
}
// Step 3: Discover remote apps
const discoverSpinner = ora('Discovering App Store Connect apps').start();
let remoteApps: DiscoveredAscApps;
try {
remoteApps = await AscAppLinkQuery.discoverAccessibleAppsAsync(
graphqlClient,
apiKeyId,
flags['bundle-id']
);
discoverSpinner.succeed(`Found ${remoteApps.length} app(s) on App Store Connect`);
} catch (err) {
discoverSpinner.fail('Failed to discover apps');
throw err;
}
if (remoteApps.length === 0) {
throw new EasCommandError(
'No accessible apps found on App Store Connect for the selected API key.' +
(flags['bundle-id']
? ` Try removing the --bundle-id filter or verify the bundle ID "${flags['bundle-id']}".`
: '')
);
}
// Step 4: Select remote app
let selectedApp: DiscoveredAscApps[number];
if (flags['asc-app-id']) {
const match = remoteApps.find(app => app.ascAppIdentifier === flags['asc-app-id']);
if (!match) {
throw new EasCommandError(
`App with identifier "${flags['asc-app-id']}" was not found among accessible apps. Run ${chalk.bold('eas integrations:asc:connect')} interactively to discover available apps.`
);
}
selectedApp = match;
} else {
selectedApp = await selectAsync<DiscoveredAscApps[number]>(
'Select an App Store Connect app:',
remoteApps.map(app => ({
title: `${app.name} (${app.bundleIdentifier}) [${app.ascAppIdentifier}]`,
value: app,
}))
);
}
// Step 5: Create link
const createSpinner = ora('Connecting project to App Store Connect app').start();
try {
await AscAppLinkMutation.createAppStoreConnectAppAsync(graphqlClient, {
appId: metadata.id,
ascAppIdentifier: selectedApp.ascAppIdentifier,
appStoreConnectApiKeyId: apiKeyId,
});
createSpinner.succeed('Connected project to App Store Connect app');
} catch (err) {
createSpinner.fail('Failed to connect project');
throw err;
}
// Step 6: Refetch and display
const refetchSpinner = ora('Verifying connection').start();
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
refetchSpinner.succeed('Verified connection');
if (json) {
printJsonOnlyOutput(buildJsonOutput('connect', updatedMetadata));
} else {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(updatedMetadata));
}
}
}
@@ -0,0 +1,143 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
buildInvalidJsonOutput,
buildJsonOutput,
formatAscAppLinkStatus,
isAscAuthenticationError,
} from '../../../integrations/asc/utils';
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsAscDisconnect extends EasCommand {
static override description = 'disconnect the current project from its App Store Connect app';
static override flags = {
yes: Flags.boolean({
description: 'Skip confirmation prompt',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsAscDisconnect);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsAscDisconnect, {
nonInteractive: nonInteractive || flags.yes,
});
// Step 1: Check current status
const metadata = await this.fetchCurrentStatusAsync(graphqlClient, projectId);
if (!metadata) {
if (json) {
printJsonOnlyOutput(buildInvalidJsonOutput('disconnect', projectId));
} else {
Log.addNewLineIfNone();
Log.warn(
'The App Store Connect API key linked to this project has been revoked or is no longer valid.\nThe connection cannot be resolved from the CLI. Please update the API key on the Expo dashboard.'
);
}
return;
}
if (!metadata.appStoreConnectApp) {
if (json) {
printJsonOnlyOutput(buildJsonOutput('disconnect', metadata));
} else {
Log.addNewLineIfNone();
Log.log(
`Project ${chalk.bold(metadata.fullName)} is not connected to any App Store Connect app.`
);
}
return;
}
// Step 2: Confirm
if (!flags.yes && !nonInteractive) {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(metadata));
Log.newLine();
Log.warn(
'You are about to disconnect this project from its App Store Connect app.\nThis action is reversible by reconnecting.'
);
Log.newLine();
const confirmed = await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
if (!confirmed) {
Log.error('Canceled disconnection');
process.exit(1);
}
}
// Step 3: Delete
const deleteSpinner = ora('Disconnecting App Store Connect app').start();
try {
await AscAppLinkMutation.deleteAppStoreConnectAppAsync(
graphqlClient,
metadata.appStoreConnectApp.id
);
deleteSpinner.succeed('Disconnected App Store Connect app');
} catch (err) {
deleteSpinner.fail('Failed to disconnect App Store Connect app');
throw err;
}
// Step 4: Refetch and display
const refetchSpinner = ora('Verifying disconnection').start();
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId, {
useCache: false,
});
refetchSpinner.succeed('Verified disconnection');
if (json) {
printJsonOnlyOutput(buildJsonOutput('disconnect', updatedMetadata));
} else {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(updatedMetadata));
}
}
private async fetchCurrentStatusAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
const spinner = ora('Checking current App Store Connect app link status').start();
try {
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
spinner.succeed('Checked current status');
return metadata;
} catch (err) {
if (isAscAuthenticationError(err)) {
spinner.fail('App Store Connect connection is invalid');
return null;
}
spinner.fail('Failed to check current status');
throw err;
}
}
}
@@ -0,0 +1,84 @@
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
buildInvalidJsonOutput,
buildJsonOutput,
formatAscAppLinkStatus,
isAscAuthenticationError,
} from '../../../integrations/asc/utils';
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsAscStatus extends EasCommand {
static override description =
'show the App Store Connect app link status for the current project';
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsAscStatus);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsAscStatus, {
nonInteractive,
});
const metadata = await this.fetchStatusAsync(graphqlClient, projectId);
if (!metadata) {
if (json) {
printJsonOnlyOutput(buildInvalidJsonOutput('status', projectId));
} else {
Log.addNewLineIfNone();
Log.warn(
'The App Store Connect API key linked to this project has been revoked or is no longer valid.'
);
}
return;
}
if (json) {
printJsonOnlyOutput(buildJsonOutput('status', metadata));
} else {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(metadata));
}
}
private async fetchStatusAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
const spinner = ora('Fetching App Store Connect app link status').start();
try {
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
spinner.succeed('Fetched App Store Connect app link status');
return metadata;
} catch (err) {
if (isAscAuthenticationError(err)) {
spinner.fail('App Store Connect connection is invalid');
return null;
}
spinner.fail('Failed to fetch App Store Connect app link status');
throw err;
}
}
}
@@ -149,6 +149,10 @@ export default class SimulatorStart extends EasCommand {
Log.log(`🔑 Run the following in your shell to attach to ${flags.type}:`);
Log.newLine();
Log.log(result.message);
Log.newLine();
Log.log(
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
);
}
}
@@ -0,0 +1,46 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
import { ora } from '../../ora';
export default class SimulatorStop extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] stop a remote simulator session on EAS by its device run session ID';
static override flags = {
id: Flags.string({
description: 'Device run session ID',
required: true,
}),
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorStop);
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStop, {
nonInteractive: flags['non-interactive'],
});
const stopSpinner = ora(`🛑 Stopping device run session ${flags.id}`).start();
try {
const session = await DeviceRunSessionMutation.stopDeviceRunSessionAsync(
graphqlClient,
flags.id
);
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
} catch (err) {
stopSpinner.fail(`Failed to stop device run session ${flags.id}`);
throw err;
}
}
}
@@ -2,11 +2,12 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import path from 'path';
import { UserRole } from '@expo/apple-utils';
import { formatAppleTeam } from './AppleTeamFormatting';
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
import { confirmAsync, promptAsync } from '../../../prompts';
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
import { fromNow } from '../../../utils/date';
import { CredentialsContext } from '../../context';
import {
@@ -24,6 +25,7 @@ import { isAscApiKeyValidAndTrackedAsync } from '../validators/validateAscApiKey
export enum AppStoreApiKeyPurpose {
SUBMISSION_SERVICE = 'EAS Submit',
ASC_APP_CONNECTION = 'EAS Connect',
}
export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise<AscApiKeyPath> {
@@ -99,12 +101,27 @@ async function generateAscApiKeyAsync(
purpose: AppStoreApiKeyPurpose
): Promise<MinimalAscApiKey> {
await ctx.appStore.ensureAuthenticatedAsync();
const role = await selectRoleForGeneratedAscApiKeyAsync();
const ascApiKey = await ctx.appStore.createAscApiKeyAsync(ctx.analytics, {
nickname: getAscApiKeyName(purpose),
roles: [role],
});
return await getMinimalAscApiKeyAsync(ascApiKey);
}
async function selectRoleForGeneratedAscApiKeyAsync(): Promise<UserRole> {
return await selectAsync<UserRole>('Select role for the generated API key:', [
{
title: 'ADMIN (default)',
value: UserRole.ADMIN,
},
{
title: 'APP_MANAGER (least privilege for app management)',
value: UserRole.APP_MANAGER,
},
]);
}
export function getAscApiKeyName(purpose: AppStoreApiKeyPurpose): string {
const nameParts = ['[Expo]', purpose, nanoid(10)];
return nameParts.join(' ');
@@ -1,16 +1,24 @@
import { promptAsync } from '../../../../prompts';
import { UserRole } from '@expo/apple-utils';
import fs from 'fs-extra';
import { promptAsync, selectAsync } from '../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { testAscApiKey } from '../../../__tests__/fixtures-ios';
import { getCredentialsFromUserAsync } from '../../../utils/promptForCredentials';
import {
getCredentialsFromUserAsync,
shouldAutoGenerateCredentialsAsync,
} from '../../../utils/promptForCredentials';
import {
AppStoreApiKeyPurpose,
getAscApiKeyName,
promptForAscApiKeyPathAsync,
provideOrGenerateAscApiKeyAsync,
} from '../AscApiKeyUtils';
jest.mock('../../../../prompts');
jest.mock('../../../utils/promptForCredentials');
jest.mock('fs-extra');
function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
return Object.keys(obj) as K[];
@@ -18,7 +26,10 @@ function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
afterEach(() => {
jest.mocked(promptAsync).mockClear();
jest.mocked(selectAsync).mockClear();
jest.mocked(getCredentialsFromUserAsync).mockClear();
jest.mocked(shouldAutoGenerateCredentialsAsync).mockClear();
jest.mocked(fs.readFile).mockClear();
});
describe(getAscApiKeyName, () => {
@@ -87,3 +98,84 @@ describe(promptForAscApiKeyPathAsync, () => {
expect(jest.mocked(ctx.appStore.getAscApiKeyAsync).mock.calls.length).toBe(1); // issuerId
});
});
describe(provideOrGenerateAscApiKeyAsync, () => {
it('creates a key with ADMIN role by default', async () => {
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(true);
jest.mocked(selectAsync).mockResolvedValue(UserRole.ADMIN);
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
authCtx: testAuthCtx,
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
createAscApiKeyAsync,
},
});
await provideOrGenerateAscApiKeyAsync(ctx, AppStoreApiKeyPurpose.ASC_APP_CONNECTION);
expect(createAscApiKeyAsync).toHaveBeenCalledWith(ctx.analytics, {
nickname: expect.any(String),
roles: [UserRole.ADMIN],
});
});
it('allows creating a key with APP_MANAGER role', async () => {
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(true);
jest.mocked(selectAsync).mockResolvedValue(UserRole.APP_MANAGER);
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
authCtx: testAuthCtx,
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
createAscApiKeyAsync,
},
});
await provideOrGenerateAscApiKeyAsync(ctx, AppStoreApiKeyPurpose.ASC_APP_CONNECTION);
expect(createAscApiKeyAsync).toHaveBeenCalledWith(ctx.analytics, {
nickname: expect.any(String),
roles: [UserRole.APP_MANAGER],
});
});
it('does not apply role override when using a user-provided key', async () => {
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(false);
jest.mocked(promptAsync).mockResolvedValue({ keyP8Path: '/asc-api-key.p8' });
jest.mocked(fs.readFile).mockImplementation(async () => 'test-key-p8' as any);
jest
.mocked(getCredentialsFromUserAsync)
.mockResolvedValueOnce({ keyId: 'test-key-id' })
.mockResolvedValueOnce({ issuerId: 'test-issuer-id' });
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
authCtx: null,
createAscApiKeyAsync,
},
});
const result = await provideOrGenerateAscApiKeyAsync(
ctx,
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
);
expect(result).toEqual({
keyP8: expect.any(String),
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
});
expect(selectAsync).not.toHaveBeenCalled();
expect(createAscApiKeyAsync).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,5 @@
import { ProfileType } from '@expo/app-store';
import { UserRole } from '@expo/apple-utils';
import {
AscApiKey,
@@ -195,10 +196,10 @@ export default class AppStoreApi {
public async createAscApiKeyAsync(
analytics: Analytics,
{ nickname }: { nickname: string }
{ nickname, roles }: { nickname: string; roles?: UserRole[] }
): Promise<AscApiKey> {
const userCtx = await this.ensureUserAuthenticatedAsync();
return await createAscApiKeyAsync(analytics, userCtx, { nickname });
return await createAscApiKeyAsync(analytics, userCtx, { nickname, roles });
}
public async revokeAscApiKeyAsync(keyId: string): Promise<AscApiKeyInfo> {
@@ -90,6 +90,23 @@ test(`createAscApiKeyAsync`, async () => {
expect(result).toEqual({ ...mockAscApiKeyInfo, keyP8: 'super secret' });
});
test(`createAscApiKeyAsync forwards explicit roles`, async () => {
const analytics = instance(mock<Analytics>());
const mockRequestContext = {};
jest.spyOn(ApiKey, 'createAsync').mockImplementation(async () => mockApiKey);
jest.mocked(getRequestContext).mockImplementation(() => mockRequestContext);
await createAscApiKeyAsync(analytics, mockAuthCtx, {
nickname: 'test-name',
roles: [UserRole.APP_MANAGER],
});
expect(ApiKey.createAsync).toHaveBeenLastCalledWith(mockRequestContext, {
nickname: 'test-name',
allAppsVisible: true,
roles: [UserRole.APP_MANAGER],
keyType: ApiKeyType.PUBLIC_API,
});
});
test(`revokeAscApiKeyAsync`, async () => {
const mockRequestContext = {};
jest.spyOn(ApiKey, 'infoAsync').mockImplementation(async () => mockApiKey);
+179
View File
@@ -1399,6 +1399,7 @@ export type App = Project & {
*/
buildsReleaseChannels: Array<Scalars['String']['output']>;
channelsPaginated: AppChannelsConnection;
convexProject?: Maybe<ConvexProject>;
deployment?: Maybe<Deployment>;
/** Deployments associated with this app */
deployments: DeploymentsConnection;
@@ -2086,6 +2087,9 @@ export type AppNotificationPreferenceInput = {
export type AppObserve = {
__typename?: 'AppObserve';
appVersions: Array<AppObserveAppVersion>;
customEventCounts: AppObserveCustomEventCounts;
customEventList: AppObserveCustomEventListConnection;
customEventNames: AppObserveCustomEventNames;
environments: Array<Scalars['String']['output']>;
events: AppObserveEventsConnection;
timeSeries: AppObserveTimeSeries;
@@ -2098,6 +2102,28 @@ export type AppObserveAppVersionsArgs = {
};
export type AppObserveCustomEventCountsArgs = {
input: AppObserveCustomEventCountsInput;
};
export type AppObserveCustomEventListArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<AppObserveCustomEventListFilter>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
export type AppObserveCustomEventNamesArgs = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppObservePlatform>;
startTime: Scalars['DateTime']['input'];
};
export type AppObserveEnvironmentsArgs = {
endTime?: InputMaybe<Scalars['DateTime']['input']>;
platform?: InputMaybe<AppObservePlatform>;
@@ -2163,6 +2189,96 @@ export type AppObserveAppVersionMetric = {
statistics: AppObserveVersionMarkerStatistics;
};
export type AppObserveCustomEvent = {
__typename?: 'AppObserveCustomEvent';
appBuildNumber: Scalars['String']['output'];
appEasBuildId?: Maybe<Scalars['String']['output']>;
appUpdateId?: Maybe<Scalars['String']['output']>;
appVersion: Scalars['String']['output'];
countryCode?: Maybe<Scalars['String']['output']>;
deviceModel: Scalars['String']['output'];
deviceOs: Scalars['String']['output'];
deviceOsVersion: Scalars['String']['output'];
easClientId: Scalars['String']['output'];
environment?: Maybe<Scalars['String']['output']>;
eventName: Scalars['String']['output'];
id: Scalars['ID']['output'];
properties: Array<AppObserveEventProperty>;
sessionId?: Maybe<Scalars['String']['output']>;
severityNumber?: Maybe<Scalars['Int']['output']>;
severityText?: Maybe<Scalars['String']['output']>;
timestamp: Scalars['DateTime']['output'];
};
export type AppObserveCustomEventCountBucket = {
__typename?: 'AppObserveCustomEventCountBucket';
bucket: Scalars['DateTime']['output'];
count: Scalars['Int']['output'];
};
export type AppObserveCustomEventCounts = {
__typename?: 'AppObserveCustomEventCounts';
buckets: Array<AppObserveCustomEventCountBucket>;
totalCount: Scalars['Int']['output'];
};
export type AppObserveCustomEventCountsInput = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
eventName: Scalars['String']['input'];
platform: AppObservePlatform;
startTime: Scalars['DateTime']['input'];
};
export type AppObserveCustomEventEdge = {
__typename?: 'AppObserveCustomEventEdge';
cursor: Scalars['String']['output'];
node: AppObserveCustomEvent;
};
export type AppObserveCustomEventListConnection = {
__typename?: 'AppObserveCustomEventListConnection';
edges: Array<AppObserveCustomEventEdge>;
pageInfo: PageInfo;
};
export type AppObserveCustomEventListFilter = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
easClientId?: InputMaybe<Scalars['String']['input']>;
endTime?: InputMaybe<Scalars['DateTime']['input']>;
environment?: InputMaybe<Scalars['String']['input']>;
eventName?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppObservePlatform>;
propertyFilters?: InputMaybe<Array<AppObserveCustomEventPropertyFilter>>;
sessionId?: InputMaybe<Scalars['String']['input']>;
startTime?: InputMaybe<Scalars['DateTime']['input']>;
};
export type AppObserveCustomEventName = {
__typename?: 'AppObserveCustomEventName';
count: Scalars['Int']['output'];
eventName: Scalars['String']['output'];
};
export type AppObserveCustomEventNames = {
__typename?: 'AppObserveCustomEventNames';
isTruncated: Scalars['Boolean']['output'];
names: Array<AppObserveCustomEventName>;
};
export type AppObserveCustomEventPropertyFilter = {
key: Scalars['String']['input'];
value: Scalars['String']['input'];
};
export type AppObserveEvent = {
__typename?: 'AppObserveEvent';
appBuildNumber: Scalars['String']['output'];
@@ -2202,6 +2318,13 @@ export type AppObserveEventEdge = {
node: AppObserveEvent;
};
export type AppObserveEventProperty = {
__typename?: 'AppObserveEventProperty';
key: Scalars['String']['output'];
type: AppObservePropertyType;
value: Scalars['String']['output'];
};
export type AppObserveEventsConnection = {
__typename?: 'AppObserveEventsConnection';
edges: Array<AppObserveEventEdge>;
@@ -2242,6 +2365,13 @@ export enum AppObservePlatform {
Ios = 'IOS'
}
export enum AppObservePropertyType {
Boolean = 'BOOLEAN',
Json = 'JSON',
Number = 'NUMBER',
String = 'STRING'
}
export type AppObserveReleasesInput = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
@@ -4089,6 +4219,18 @@ export type ConvexIntegrationQuery = {
clientIdentifier: Scalars['String']['output'];
};
export type ConvexProject = {
__typename?: 'ConvexProject';
app: App;
convexProjectIdentifier: Scalars['String']['output'];
convexProjectName: Scalars['String']['output'];
convexProjectSlug: Scalars['String']['output'];
convexTeamConnection: ConvexTeamConnection;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export type ConvexTeamConnection = {
__typename?: 'ConvexTeamConnection';
account: Account;
@@ -5610,6 +5752,7 @@ export enum EntityTypeName {
BillingContractEntity = 'BillingContractEntity',
BranchEntity = 'BranchEntity',
ChannelEntity = 'ChannelEntity',
ConvexProjectEntity = 'ConvexProjectEntity',
ConvexTeamConnectionEntity = 'ConvexTeamConnectionEntity',
CustomerEntity = 'CustomerEntity',
EchoProjectEntity = 'EchoProjectEntity',
@@ -11711,6 +11854,20 @@ export type CreateAppVersionMutationVariables = Exact<{
export type CreateAppVersionMutation = { __typename?: 'RootMutation', appVersion: { __typename?: 'AppVersionMutation', createAppVersion: { __typename?: 'AppVersion', id: string } } };
export type CreateAppStoreConnectAppMutationVariables = Exact<{
appStoreConnectAppInput: AppStoreConnectAppInput;
}>;
export type CreateAppStoreConnectAppMutation = { __typename?: 'RootMutation', appStoreConnectApp: { __typename?: 'AppStoreConnectAppMutation', createAppStoreConnectApp: { __typename?: 'AppStoreConnectApp', id: string, ascAppIdentifier: string } } };
export type DeleteAppStoreConnectAppMutationVariables = Exact<{
appStoreConnectAppId: Scalars['ID']['input'];
}>;
export type DeleteAppStoreConnectAppMutation = { __typename?: 'RootMutation', appStoreConnectApp: { __typename?: 'AppStoreConnectAppMutation', deleteAppStoreConnectApp: { __typename?: 'DeleteAppStoreConnectAppResult', id: string } } };
export type CreateAndroidBuildMutationVariables = Exact<{
appId: Scalars['ID']['input'];
job: AndroidJobInput;
@@ -11754,6 +11911,13 @@ export type CreateDeviceRunSessionMutationVariables = Exact<{
export type CreateDeviceRunSessionMutation = { __typename?: 'RootMutation', deviceRunSession: { __typename?: 'DeviceRunSessionMutation', createDeviceRunSession: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string } | null } } };
export type StopDeviceRunSessionMutationVariables = Exact<{
deviceRunSessionId: Scalars['ID']['input'];
}>;
export type StopDeviceRunSessionMutation = { __typename?: 'RootMutation', deviceRunSession: { __typename?: 'DeviceRunSessionMutation', stopDeviceRunSession: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus } } };
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
input: CreateEnvironmentSecretInput;
accountId: Scalars['String']['input'];
@@ -12063,6 +12227,21 @@ export type LatestAppVersionQueryVariables = Exact<{
export type LatestAppVersionQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, latestAppVersionByPlatformAndApplicationIdentifier?: { __typename?: 'AppVersion', id: string, storeVersion: string, buildVersion: string } | null } } };
export type AscAppLinkAppMetadataQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
export type AscAppLinkAppMetadataQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, appStoreConnectApp?: { __typename?: 'AppStoreConnectApp', id: string, ascAppIdentifier: string, remoteAppStoreConnectApp: { __typename?: 'RemoteAppStoreConnectApp', ascAppIdentifier: string, bundleIdentifier: string, name?: string | null, appStoreIconUrl?: string | null } } | null } } };
export type DiscoverAccessibleAppStoreConnectAppsQueryVariables = Exact<{
appStoreConnectApiKeyId: Scalars['ID']['input'];
bundleIdentifier?: InputMaybe<Scalars['String']['input']>;
}>;
export type DiscoverAccessibleAppStoreConnectAppsQuery = { __typename?: 'RootQuery', appStoreConnectApiKey: { __typename?: 'AppStoreConnectApiKeyQuery', byId: { __typename?: 'AppStoreConnectApiKey', id: string, remoteAppStoreConnectApps: Array<{ __typename?: 'RemoteAppStoreConnectApp', ascAppIdentifier: string, bundleIdentifier: string, name?: string | null, appStoreIconUrl?: string | null }> } } };
export type GetAssetSignedUrlsQueryVariables = Exact<{
updateId: Scalars['ID']['input'];
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
@@ -0,0 +1,70 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
export const AscAppLinkMutation = {
async createAppStoreConnectAppAsync(
graphqlClient: ExpoGraphqlClient,
appStoreConnectAppInput: {
appId: string;
ascAppIdentifier: string;
appStoreConnectApiKeyId: string;
}
): Promise<{ id: string; ascAppIdentifier: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<{
appStoreConnectApp: {
createAppStoreConnectApp: {
id: string;
ascAppIdentifier: string;
};
};
}>(
gql`
mutation CreateAppStoreConnectApp($appStoreConnectAppInput: AppStoreConnectAppInput!) {
appStoreConnectApp {
createAppStoreConnectApp(appStoreConnectAppInput: $appStoreConnectAppInput) {
id
ascAppIdentifier
}
}
}
`,
{ appStoreConnectAppInput }
)
.toPromise()
);
return data.appStoreConnectApp.createAppStoreConnectApp;
},
async deleteAppStoreConnectAppAsync(
graphqlClient: ExpoGraphqlClient,
appStoreConnectAppId: string
): Promise<void> {
await withErrorHandlingAsync(
graphqlClient
.mutation<{
appStoreConnectApp: {
deleteAppStoreConnectApp: {
id: string;
};
};
}>(
gql`
mutation DeleteAppStoreConnectApp($appStoreConnectAppId: ID!) {
appStoreConnectApp {
deleteAppStoreConnectApp(appStoreConnectAppId: $appStoreConnectAppId) {
id
}
}
}
`,
{ appStoreConnectAppId }
)
.toPromise()
);
},
};
@@ -6,6 +6,8 @@ import {
CreateDeviceRunSessionInput,
CreateDeviceRunSessionMutation,
CreateDeviceRunSessionMutationVariables,
StopDeviceRunSessionMutation,
StopDeviceRunSessionMutationVariables,
} from '../generated';
export const DeviceRunSessionMutation = {
@@ -44,4 +46,28 @@ export const DeviceRunSessionMutation = {
);
return data.deviceRunSession.createDeviceRunSession;
},
async stopDeviceRunSessionAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string
): Promise<StopDeviceRunSessionMutation['deviceRunSession']['stopDeviceRunSession']> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<StopDeviceRunSessionMutation, StopDeviceRunSessionMutationVariables>(
gql`
mutation StopDeviceRunSessionMutation($deviceRunSessionId: ID!) {
deviceRunSession {
stopDeviceRunSession(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`,
{ deviceRunSessionId },
{ noRetry: true }
)
.toPromise()
);
return data.deviceRunSession.stopDeviceRunSession;
},
};
@@ -0,0 +1,102 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
AscAppLinkAppMetadataQuery,
AscAppLinkAppMetadataQueryVariables,
DiscoverAccessibleAppStoreConnectAppsQuery,
DiscoverAccessibleAppStoreConnectAppsQueryVariables,
} from '../generated';
import { AccountFragmentNode } from '../types/Account';
export const AscAppLinkQuery = {
async getAppMetadataAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
options?: {
useCache?: boolean;
}
): Promise<AscAppLinkAppMetadataQuery['app']['byId']> {
const useCache = options?.useCache ?? true;
const data = await withErrorHandlingAsync(
graphqlClient
.query<AscAppLinkAppMetadataQuery, AscAppLinkAppMetadataQueryVariables>(
gql`
query AscAppLinkAppMetadata($appId: String!) {
app {
byId(appId: $appId) {
id
fullName
ownerAccount {
id
...AccountFragment
}
appStoreConnectApp {
id
ascAppIdentifier
remoteAppStoreConnectApp {
ascAppIdentifier
bundleIdentifier
name
appStoreIconUrl
}
}
}
}
}
${print(AccountFragmentNode)}
`,
{ appId },
{
requestPolicy: useCache ? 'cache-first' : 'network-only',
additionalTypenames: ['App', 'AppStoreConnectApp'],
}
)
.toPromise()
);
return data.app.byId;
},
async discoverAccessibleAppsAsync(
graphqlClient: ExpoGraphqlClient,
appStoreConnectApiKeyId: string,
bundleIdentifier?: string
): Promise<
DiscoverAccessibleAppStoreConnectAppsQuery['appStoreConnectApiKey']['byId']['remoteAppStoreConnectApps']
> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<
DiscoverAccessibleAppStoreConnectAppsQuery,
DiscoverAccessibleAppStoreConnectAppsQueryVariables
>(
gql`
query DiscoverAccessibleAppStoreConnectApps(
$appStoreConnectApiKeyId: ID!
$bundleIdentifier: String
) {
appStoreConnectApiKey {
byId(id: $appStoreConnectApiKeyId) {
id
remoteAppStoreConnectApps(bundleIdentifier: $bundleIdentifier) {
ascAppIdentifier
bundleIdentifier
name
appStoreIconUrl
}
}
}
}
`,
{ appStoreConnectApiKeyId, bundleIdentifier },
{ additionalTypenames: ['AppStoreConnectApp'] }
)
.toPromise()
);
return data.appStoreConnectApiKey.byId?.remoteAppStoreConnectApps ?? [];
},
};
@@ -0,0 +1,99 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import {
buildInvalidJsonOutput,
buildJsonOutput,
formatAscAppLinkStatus,
isAscAuthenticationError,
} from '../utils';
const mockMetadataConnected = {
id: 'app-id',
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: 'app-id',
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
describe('buildJsonOutput', () => {
it('returns connected output', () => {
const output = buildJsonOutput('status', mockMetadataConnected);
expect(output.action).toBe('status');
expect(output.project).toBe('@testuser/testapp');
expect(output.status).toBe('connected');
expect(output.appStoreConnectApp).not.toBeNull();
expect(output.appStoreConnectApp!.ascAppIdentifier).toBe('1234567890');
expect(output.appStoreConnectApp!.name).toBe('Test App');
expect(output.appStoreConnectApp!.bundleIdentifier).toBe('com.test.app');
});
it('returns disconnected output', () => {
const output = buildJsonOutput('status', mockMetadataDisconnected);
expect(output.status).toBe('not-connected');
expect(output.appStoreConnectApp).toBeNull();
});
});
describe('buildInvalidJsonOutput', () => {
it('returns invalid output', () => {
const output = buildInvalidJsonOutput('status', 'project-id');
expect(output.status).toBe('invalid');
expect(output.project).toBe('project-id');
expect(output.appStoreConnectApp).toBeNull();
});
});
describe('isAscAuthenticationError', () => {
it('returns true for ASC authentication errors', () => {
const error = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
expect(isAscAuthenticationError(error)).toBe(true);
});
it('returns false for other CombinedErrors', () => {
const error = new CombinedError({
graphQLErrors: [new GraphQLError('Some other error')],
});
expect(isAscAuthenticationError(error)).toBe(false);
});
it('returns false for non-CombinedError errors', () => {
expect(isAscAuthenticationError(new Error('random error'))).toBe(false);
});
});
describe('formatAscAppLinkStatus', () => {
it('formats connected status', () => {
const status = formatAscAppLinkStatus(mockMetadataConnected);
expect(status).toContain('Connected');
expect(status).toContain('1234567890');
expect(status).toContain('Test App');
expect(status).toContain('com.test.app');
});
it('formats disconnected status', () => {
const status = formatAscAppLinkStatus(mockMetadataDisconnected);
expect(status).toContain('Not connected');
});
});
@@ -0,0 +1,50 @@
import { selectAsync } from '../../prompts';
import { CredentialsContext } from '../../credentials/context';
import {
AppStoreApiKeyPurpose,
formatAscApiKey,
provideOrGenerateAscApiKeyAsync,
sortAscApiKeysByUpdatedAtDesc,
} from '../../credentials/ios/actions/AscApiKeyUtils';
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../graphql/generated';
export async function selectOrCreateAscApiKeyIdAsync({
credentialsContext,
existingKeys,
ownerAccount,
}: {
credentialsContext: CredentialsContext;
existingKeys: AppStoreConnectApiKeyFragment[];
ownerAccount: AccountFragment;
}): Promise<string> {
const sortedKeys = sortAscApiKeysByUpdatedAtDesc(existingKeys);
const createKeyOption = {
title: '[Create or upload a new API key]',
value: '__create_new_key__',
};
const selectedValue =
sortedKeys.length === 0
? createKeyOption.value
: await selectAsync<string>('Select an App Store Connect API key:', [
...sortedKeys.map(key => ({
title: formatAscApiKey(key),
value: key.id,
})),
createKeyOption,
]);
if (selectedValue !== createKeyOption.value) {
return selectedValue;
}
const newKey = await credentialsContext.ios.createAscApiKeyAsync(
credentialsContext.graphqlClient,
ownerAccount,
await provideOrGenerateAscApiKeyAsync(
credentialsContext,
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
)
);
return newKey.id;
}
@@ -0,0 +1,81 @@
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import { AscAppLinkQuery } from '../../graphql/queries/AscAppLinkQuery';
type AscAppLinkMetadata = Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>>;
export type AscAppLinkStatus = 'not-connected' | 'connected' | 'invalid';
export interface AscAppLinkJsonOutput {
action: string;
project: string;
status: AscAppLinkStatus;
appStoreConnectApp: {
id: string;
ascAppIdentifier: string;
name: string | null;
bundleIdentifier: string | null;
appleUrl: string;
} | null;
}
export function buildJsonOutput(
action: string,
metadata: AscAppLinkMetadata
): AscAppLinkJsonOutput {
const link = metadata.appStoreConnectApp;
return {
action,
project: metadata.fullName,
status: link ? 'connected' : 'not-connected',
appStoreConnectApp: link
? {
id: link.id,
ascAppIdentifier: link.ascAppIdentifier,
name: link.remoteAppStoreConnectApp.name ?? null,
bundleIdentifier: link.remoteAppStoreConnectApp.bundleIdentifier ?? null,
appleUrl: getAppleAppUrl(link.ascAppIdentifier),
}
: null,
};
}
export function buildInvalidJsonOutput(action: string, projectId: string): AscAppLinkJsonOutput {
return {
action,
project: projectId,
status: 'invalid',
appStoreConnectApp: null,
};
}
export function isAscAuthenticationError(error: unknown): error is CombinedError {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(e => e.message.includes('App Store Connect rejected this API key'))
);
}
export function formatAscAppLinkStatus(metadata: AscAppLinkMetadata): string {
const link = metadata.appStoreConnectApp;
if (!link) {
return `Project ${chalk.bold(metadata.fullName)}: ${chalk.yellow('Not connected')} to App Store Connect.`;
}
const lines: string[] = [
`Project ${chalk.bold(metadata.fullName)}: ${chalk.green('Connected')} to App Store Connect.`,
` ASC App ID: ${chalk.bold(link.ascAppIdentifier)}`,
];
const remote = link.remoteAppStoreConnectApp;
lines.push(` Name: ${remote.name}`);
lines.push(` Bundle ID: ${remote.bundleIdentifier}`);
lines.push(` Apple URL: ${getAppleAppUrl(link.ascAppIdentifier)}`);
return lines.join('\n');
}
function getAppleAppUrl(ascAppIdentifier: string): string {
return `https://appstoreconnect.apple.com/apps/${encodeURIComponent(ascAppIdentifier)}/distribution`;
}
+1 -1
View File
@@ -70,7 +70,7 @@ function wrapFetchWithProgress(): (
};
}
async function downloadFileWithProgressTrackerAsync(
export async function downloadFileWithProgressTrackerAsync(
url: string,
outputPath: string,
progressTrackerMessage: string | ((ratio: number, total: number) => string),
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "18.8.1",
"version": "18.9.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,7 +26,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "18.8.1",
"@expo/build-tools": "18.9.0",
"@expo/eas-build-job": "18.8.0",
"@expo/logger": "18.5.0",
"@expo/spawn-async": "^1.7.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "18.8.0",
"version": "18.9.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -0,0 +1,51 @@
import type { JobInterpolationContext } from '@expo/eas-build-job';
import { interpolateJobContext } from '../interpolation';
describe(interpolateJobContext, () => {
it('interpolates multiple ${{ }} segments when the string starts with ${{ and ends with }}', () => {
const context = {
github: {
event: {
pull_request: {
html_url: 'https://github.com/acme/app/pull/42',
title: 'Fix crash',
},
},
},
} as unknown as JobInterpolationContext;
expect(
interpolateJobContext({
target: '${{ github.event.pull_request.html_url }}: ${{ github.event.pull_request.title }}',
context,
})
).toBe('https://github.com/acme/app/pull/42: Fix crash');
});
it('still preserves non-string types for a single whole-string interpolation', () => {
const context = {
build: { profile: 'production', id: '123' },
} as unknown as JobInterpolationContext;
expect(
interpolateJobContext({
target: '${{ build }}',
context,
})
).toEqual({ profile: 'production', id: '123' });
});
it("handles a single interpolation when the expression contains '}}' in a string literal", () => {
const context = {
build: { profile: 'production}}' },
} as unknown as JobInterpolationContext;
expect(
interpolateJobContext({
target: "${{ build.profile == 'production}}' }}",
context,
})
).toBe(true);
});
});
+5 -1
View File
@@ -2,6 +2,10 @@ import { JobInterpolationContext } from '@expo/eas-build-job';
import { jsepEval } from './utils/jsepEval';
function isSingleWholeStringInterpolation(value: string): boolean {
return value.startsWith('${{') && value.endsWith('}}') && !value.includes('${{', 3);
}
export function interpolateJobContext({
target,
context,
@@ -12,7 +16,7 @@ export function interpolateJobContext({
if (typeof target === 'string') {
// If the value is e.g. `build: ${{ inputs.build }}`, we will interpolate the value
// without changing `inputs.build` type, i.e. if it is an object it'll be like `build: {...inputs.build}`.
if (target.startsWith('${{') && target.endsWith('}}')) {
if (isSingleWholeStringInterpolation(target)) {
return jsepEval(target.slice(3, -2), context);
}
@@ -14,3 +14,4 @@ if (!process.env.LISTENING_TO_UNHANDLED_REJECTION) {
// Always mock:
jest.mock('../src/CacheManager');
jest.mock('../src/utils/turtleFetch');
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "18.8.1",
"version": "18.9.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "18.8.1",
"@expo/build-tools": "18.9.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "18.5.0",
@@ -28,7 +28,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.8.0",
"@expo/steps": "18.9.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@hapi/boom": "10.0.1",
@@ -70,6 +70,7 @@ jest.mock('../config', () => {
default: {
...config.default,
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
},
};
});
@@ -40,6 +40,7 @@ jest.mock('../config', () => {
return {
...config,
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
};
});
@@ -40,6 +40,7 @@ jest.mock('../config', () => {
return {
...config,
buildId: 'f38532aa-81a8-4db7-915f-6e7afe46e22f',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
};
});
@@ -77,6 +77,7 @@ jest.mock('../config', () => {
return {
...config,
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
};
});
jest.mock('../upload', () => {
+32
View File
@@ -5,9 +5,11 @@ import { getBuildEnv } from '../env';
describe(getBuildEnv.name, () => {
const originalSentryDsn = config.sentry.dsn;
const originalCocoapodsCacheUrl = config.cocoapodsCacheUrl;
afterEach(() => {
config.sentry.dsn = originalSentryDsn;
config.cocoapodsCacheUrl = originalCocoapodsCacheUrl;
});
it('passes the CLI sentry DSN to worker child processes', () => {
@@ -55,6 +57,36 @@ describe(getBuildEnv.name, () => {
});
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBe(
'https://storage.googleapis.com/eas-build-precompiled-modules/'
);
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
});
it('passes precompiled modules through the CocoaPods cache URL when available', () => {
config.cocoapodsCacheUrl = 'https://cache.example.com';
const env = getBuildEnv({
job: {
platform: Platform.IOS,
type: Workflow.GENERIC,
builderEnvironment: {
env: {
EAS_USE_PRECOMPILED_MODULES: '1',
},
},
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBe(
'https://cache.example.com/storage.googleapis.com/eas-build-precompiled-modules/'
);
});
});
+16
View File
@@ -12,6 +12,9 @@ import {
} from './external/turtle';
import { getAccessedEnvs } from './utils/env';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
// keep in sync with local-build-plugin env vars
// see packages/local-build-plugin/src/build.ts
export function getBuildEnv({
@@ -55,6 +58,7 @@ export function getBuildEnv({
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
if (job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES === '1') {
setEnv(env, 'EXPO_USE_PRECOMPILED_MODULES', '1');
setEnv(env, 'EXPO_PRECOMPILED_MODULES_BASE_URL', getPrecompiledModulesBaseUrl());
}
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
@@ -166,6 +170,18 @@ function setEnv(env: Env, key: string, value: string | null | undefined): void {
}
}
function getPrecompiledModulesBaseUrl(): string {
if (!config.cocoapodsCacheUrl) {
return PRECOMPILED_MODULES_BASE_URL;
}
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
return PRECOMPILED_MODULES_BASE_URL.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${config.cocoapodsCacheUrl.replace(/\/$/, '')}/${parsedUrl.host}`
);
}
const ResourceClassToMaxHeapSize: Record<ResourceClass, string | null> = {
[ResourceClass.LINUX_C3D_STANDARD_4]: '4g', // 4 out of 16 GB, 12 GB left
[ResourceClass.LINUX_C3D_STANDARD_8]: '8g', // 8 out of 32 GB, 24 GB left
+36 -17
View File
@@ -332,7 +332,7 @@ export default class BuildService {
});
const robotAccessToken = job.secrets?.robotAccessToken;
if (robotAccessToken && err.errorCode === errors.ErrorCode.UNKNOWN_ERROR) {
if (robotAccessToken) {
let rawErrorMessage: string = '';
if (maybeRawError?.stderr) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
@@ -340,23 +340,42 @@ export default class BuildService {
if (maybeRawError?.stdout) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stdout);
}
if (!rawErrorMessage) {
rawErrorMessage = maybeRawError?.message ?? err.message;
}
await turtleFetch(
new URL('turtle-builds/error-logs', config.wwwApiV2BaseUrl).toString(),
'POST',
{
json: {
buildId: this.buildId,
message: rawErrorMessage,
buildPhase: err.buildPhase ?? null,
errorCode: err.errorCode,
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}
);
try {
await turtleFetch(
new URL('turtle-builds/error-logs', config.wwwApiV2BaseUrl).toString(),
'POST',
{
json: {
buildId: this.buildId,
message: rawErrorMessage,
buildPhase: err.buildPhase ?? null,
errorCode: err.errorCode,
tags: {
platform: job.platform,
workflow: job.type,
sdk_version: metadata?.sdkVersion ?? null,
react_native_version: metadata?.reactNativeVersion ?? null,
app_id: job.appId ?? null,
build_profile: metadata?.buildProfile ?? null,
app_name: metadata?.appName ?? null,
app_identifier: metadata?.appIdentifier ?? null,
distribution: metadata?.distribution ?? null,
cli_version: metadata?.cliVersion ?? null,
},
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}
);
} catch (fetchError: any) {
logger.warn({ err: fetchError }, 'Failed to send build error log');
}
}
await this.finishError(err, maybeArtifacts);
Generated
+8 -8
View File
@@ -2856,7 +2856,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:18.8.1, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:18.9.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -2871,7 +2871,7 @@ __metadata:
"@expo/repack-app": "npm:~0.4.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.8.0"
"@expo/steps": "npm:18.9.0"
"@expo/template-file": "npm:18.5.0"
"@expo/turtle-spawn": "npm:18.5.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -3481,7 +3481,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:18.8.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:18.9.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
@@ -3554,7 +3554,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:18.8.1"
"@expo/build-tools": "npm:18.9.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:18.5.0"
@@ -3563,7 +3563,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.8.0"
"@expo/steps": "npm:18.9.0"
"@expo/template-file": "npm:18.5.0"
"@expo/turtle-spawn": "npm:18.5.0"
"@hapi/boom": "npm:10.0.1"
@@ -10331,7 +10331,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:18.8.0"
"@expo/steps": "npm:18.9.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10944,7 +10944,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:18.8.1"
"@expo/build-tools": "npm:18.9.0"
"@expo/eas-build-job": "npm:18.8.0"
"@expo/logger": "npm:18.5.0"
"@expo/spawn-async": "npm:^1.7.2"
@@ -11018,7 +11018,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.8.0"
"@expo/steps": "npm:18.9.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"