Compare commits

..
Author SHA1 Message Date
Quinlan Jung 0b995e0bed update CHANGELOG.md 2023-06-30 23:28:27 +00:00
Quinlan Jung 8f39b303c9 [eas-cli] bump config dependencies 2023-06-30 16:16:00 -07:00
111 changed files with 1540 additions and 8810 deletions

No files matched your search

+4 -4
View File
@@ -29,7 +29,7 @@ jobs:
with:
node-version: 18
- run: yarn install --frozen-lockfile --check-files
- run: yarn build-ci
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
@@ -70,7 +70,7 @@ jobs:
with:
node-version: 18
- run: yarn install --frozen-lockfile --check-files
- run: yarn build-ci
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
@@ -99,7 +99,7 @@ jobs:
node-version: 18
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build-ci
- run: yarn build
- run: |
yarn pretarball-ci
yarn oclif pack:win
@@ -142,7 +142,7 @@ jobs:
- name: Install dependencies
run: yarn install --frozen-lockfile --check-files
- name: Build
run: yarn build-ci
run: yarn build
- name: Publish packages to npm
run: yarn lerna publish from-package --yes --no-verify-access
env:
+3 -144
View File
@@ -8,157 +8,16 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
### 🧹 Chores
- Logger to say website support is coming soon for rollouts. ([#1997](https://github.com/expo/eas-cli/pull/1997) by [@quinlanj](https://github.com/quinlanj))
## [4.1.0](https://github.com/expo/eas-cli/releases/tag/v4.1.0) - 2023-08-08
### 🎉 New features
- Pass credentials to custom iOS builds. ([#1989](https://github.com/expo/eas-cli/pull/1989) by [@szdziedzic](https://github.com/szdziedzic))
- Add the `withoutCredentials` option as a common build profile field in `eas.json`. ([#1994](https://github.com/expo/eas-cli/pull/1994) by [@szdziedzic](https://github.com/szdziedzic))
## [4.0.1](https://github.com/expo/eas-cli/releases/tag/v4.0.1) - 2023-08-08
### 🐛 Bug fixes
- Fix `eas channel:*` to work with empty branch mappings. ([#1992](https://github.com/expo/eas-cli/pull/1992) by [@quinlanj](https://github.com/quinlanj))
## [4.0.0](https://github.com/expo/eas-cli/releases/tag/v4.0.0) - 2023-08-07
### 🛠 Breaking changes
- Release redesigned `eas channel:rollout` into developer preview. The set of flag arguments are different, in addition to the workflow. ([#1986](https://github.com/expo/eas-cli/pull/1986) by [@quinlanj](https://github.com/quinlanj))
### 🎉 New features
- Option to add current Apple Silicon device without the need to manually provide the provisioning UDID. ([#1943](https://github.com/expo/eas-cli/pull/1943) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
### 🐛 Bug fixes
- Fix rollout-preview ending with republish with code signing. ([#1978](https://github.com/expo/eas-cli/pull/1978) by [@wschurman](https://github.com/wschurman))
- Rollouts: fix fp precision. ([#1985](https://github.com/expo/eas-cli/pull/1985) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- Fixing more grammar errors. ([#1980](https://github.com/expo/eas-cli/pull/1980) by [@quinlanj](https://github.com/quinlanj))
- Handle rollout edit edge case. ([#1981](https://github.com/expo/eas-cli/pull/1981) by [@quinlanj](https://github.com/quinlanj))
- Linked expo.fyi rollout article. ([#1991](https://github.com/expo/eas-cli/pull/1991) by [@quinlanj](https://github.com/quinlanj))
- Rollout ux improvements. ([#1984](https://github.com/expo/eas-cli/pull/1984) by [@quinlanj](https://github.com/quinlanj))
## [3.18.3](https://github.com/expo/eas-cli/releases/tag/v3.18.3) - 2023-08-03
### 🐛 Bug fixes
- Fix republishing with code signing. ([#1973](https://github.com/expo/eas-cli/pull/1973) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- Add relationships flag to rollouts-preview. ([#1972](https://github.com/expo/eas-cli/pull/1972) by [@quinlanj](https://github.com/quinlanj))
- Get channel:{view,list,edit} to play nice with rollouts. ([#1974](https://github.com/expo/eas-cli/pull/1974) by [@quinlanj](https://github.com/quinlanj))
- Use 'roll out' instead of rollout for verbs. ([#1979](https://github.com/expo/eas-cli/pull/1979) by [@quinlanj](https://github.com/quinlanj))
## [3.18.2](https://github.com/expo/eas-cli/releases/tag/v3.18.2) - 2023-08-03
### 🐛 Bug fixes
- Revert adding `.nvmrc` support for setting node version. ([#1976](https://github.com/expo/eas-cli/pull/1976) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Use just a comma instead of `, ` when concatenating `keywords` in `eas metadata`. ([#1967](https://github.com/expo/eas-cli/pull/1967) by [@szdziedzic](https://github.com/szdziedzic))
## [3.18.1](https://github.com/expo/eas-cli/releases/tag/v3.18.1) - 2023-08-03
### 🐛 Bug fixes
- Pass correct group into `update:republish`. ([#1971](https://github.com/expo/eas-cli/pull/1971) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- Make new rollouts version available for internal dogfooding. ([#1966](https://github.com/expo/eas-cli/pull/1966) by [@quinlanj](https://github.com/quinlanj))
- Change default runtime version policy for EAS Update to appVersion. ([#1968](https://github.com/expo/eas-cli/pull/1968) by [@quinlanj](https://github.com/quinlanj))
## [3.18.0](https://github.com/expo/eas-cli/releases/tag/v3.18.0) - 2023-08-02
### 🎉 New features
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
- Provide credentials for custom Android builds. ([#1969](https://github.com/expo/eas-cli/pull/1969) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- More branch mapping utility. ([#1957](https://github.com/expo/eas-cli/pull/1957) by [@quinlanj](https://github.com/quinlanj))
- Utility classes to select existing rollouts and channels. ([#1958](https://github.com/expo/eas-cli/pull/1958) by [@quinlanj](https://github.com/quinlanj))
## [3.17.1](https://github.com/expo/eas-cli/releases/tag/v3.17.1) - 2023-07-27
### 🧹 Chores
- Unify channel graphql query types. ([#1949](https://github.com/expo/eas-cli/pull/1949) by [@quinlanj](https://github.com/quinlanj))
- Revert UpdateBranchWithCurrentGroupFragment. ([#1952](https://github.com/expo/eas-cli/pull/1952) by [@quinlanj](https://github.com/quinlanj))
- Fetch entire relay compliant dataset. ([#1953](https://github.com/expo/eas-cli/pull/1953) by [@quinlanj](https://github.com/quinlanj))
## [3.17.0](https://github.com/expo/eas-cli/releases/tag/v3.17.0) - 2023-07-24
### 🎉 New features
- Add `rollout` option for configuring Android submissions. ([#1938](https://github.com/expo/eas-cli/pull/1938) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Added branch mapping utility functions. ([#1944](https://github.com/expo/eas-cli/pull/1944) by [@quinlanj](https://github.com/quinlanj))
- Amend branch mapping utility functions. ([#1945](https://github.com/expo/eas-cli/pull/1945) by [@quinlanj](https://github.com/quinlanj))
- Handle error thrown when `intel-medium` resource class is not available as server-side defined error. ([#1947](https://github.com/expo/eas-cli/pull/1947) by [@szdziedzic](https://github.com/szdziedzic))
- Remove `intel-medium` from `eas.schema.json`, so it's not suggested as a valid value by our VSCode plugin. ([#1947](https://github.com/expo/eas-cli/pull/1947) by [@szdziedzic](https://github.com/szdziedzic))
## [3.16.0](https://github.com/expo/eas-cli/releases/tag/v3.16.0) - 2023-07-18
### 🎉 New features
- Add styling to SSO auth redirect completion page. ([#1929](https://github.com/expo/eas-cli/pull/1929) by [@wschurman](https://github.com/wschurman))
- Ignore entitlements from native template when `/ios` is gitignored. ([#1906](https://github.com/expo/eas-cli/pull/1906) by [@byCedric](https://github.com/byCedric))
- Use node server default port selection for SSO login server. ([#1930](https://github.com/expo/eas-cli/pull/1930) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- Fix incorrect handling of valid `inProgress` Android submission release status. ([#1934](https://github.com/expo/eas-cli/pull/1934) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Print friendly error msg in case account doesn't have required permission. ([#1867](https://github.com/expo/eas-cli/pull/1867) by [@firasrg](https://github.com/firasrg))
- Limit project file upload size to 2GB. ([#1928](https://github.com/expo/eas-cli/pull/1928) by [@khamilowicz](https://github.com/khamilowicz))
- Bump urql graphql client major version. ([#1936](https://github.com/expo/eas-cli/pull/1936) by [@quinlanj](https://github.com/quinlanj))
## [3.15.1](https://github.com/expo/eas-cli/releases/tag/v3.15.1) - 2023-07-11
### 🐛 Bug fixes
- Ensure useClassicUpdates is not set when using EAS Update commands. ([#1915](https://github.com/expo/eas-cli/pull/1915) by [@ide](https://github.com/ide))
- Handle multiple GraphQLErrors when receiving a CombinedError. ([#1924](https://github.com/expo/eas-cli/pull/1924) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
### 🧹 Chores
- Bump Expo package dependencies. ([#1911](https://github.com/expo/eas-cli/pull/1911) by [@brentvatne](https://github.com/brentvatne))
- Better bare workflow runtimeVersion error. ([#1910](https://github.com/expo/eas-cli/pull/1910) by [@quinlanj](https://github.com/quinlanj))
- Fix runtime version print logs. ([#1925](https://github.com/expo/eas-cli/pull/1925) by [@quinlanj](https://github.com/quinlanj))
- Add info about new `macos-ventura-13.4-xcode-14.3.1` image to `eas.schema.json`. ([#1920](https://github.com/expo/eas-cli/pull/1920) by [@szdziedzic](https://github.com/szdziedzic))
## [3.15.0](https://github.com/expo/eas-cli/releases/tag/v3.15.0) - 2023-06-30
### 🎉 New features
- Show build profile when selecting a build for eas build:run. ([#1901](https://github.com/expo/eas-cli/pull/1901) by [@keith-kurak](https://github.com/keith-kurak))
- Adds -m alias to --message in update/republish and removed README comment. ([#1905](https://github.com/expo/eas-cli/pull/1905) by [@pusongqi](https://github.com/pusongqi))
### 🐛 Bug fixes
### 🧹 Chores
- Better runtimeVersion output. ([#1894](https://github.com/expo/eas-cli/pull/1894) by [@quinlanj](https://github.com/quinlanj))
- Print better error message when uploading project archive tarball fails. ([#1897](https://github.com/expo/eas-cli/pull/1897) by [@szdziedzic](https://github.com/szdziedzic))
- Adds support for 2 new server-side errors related to build limits. ([#1921](https://github.com/expo/eas-cli/pull/1921) by [@sundeeppeswani](https://github.com/sundeeppeswani))
- Bump config dependencies. ([#1913](https://github.com/expo/eas-cli/pull/1913) by [@quinlanj](https://github.com/quinlanj))
## [3.14.0](https://github.com/expo/eas-cli/releases/tag/v3.14.0) - 2023-06-20
+1 -1
View File
@@ -1,6 +1,6 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "4.1.1",
"version": "3.14.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
-1
View File
@@ -6,7 +6,6 @@
],
"scripts": {
"build": "lerna run build",
"build-ci": "lerna run build-ci",
"build:tarballs:linux": "yarn build-for-size-check",
"build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64",
"typecheck": "lerna run typecheck",
+133 -142
View File
@@ -55,68 +55,69 @@ eas --help COMMAND
# Commands
<!-- commands -->
* [`eas account:login`](#eas-accountlogin)
* [`eas account:logout`](#eas-accountlogout)
* [`eas account:view`](#eas-accountview)
* [`eas analytics [STATUS]`](#eas-analytics-status)
* [`eas autocomplete [SHELL]`](#eas-autocomplete-shell)
* [`eas branch:create [NAME]`](#eas-branchcreate-name)
* [`eas branch:delete [NAME]`](#eas-branchdelete-name)
* [`eas branch:list`](#eas-branchlist)
* [`eas branch:rename`](#eas-branchrename)
* [`eas branch:view [NAME]`](#eas-branchview-name)
* [`eas build`](#eas-build)
* [`eas build:cancel [BUILD_ID]`](#eas-buildcancel-build_id)
* [`eas build:configure`](#eas-buildconfigure)
* [`eas build:inspect`](#eas-buildinspect)
* [`eas build:list`](#eas-buildlist)
* [`eas build:resign`](#eas-buildresign)
* [`eas build:run`](#eas-buildrun)
* [`eas build:submit`](#eas-buildsubmit)
* [`eas build:version:get`](#eas-buildversionget)
* [`eas build:version:set`](#eas-buildversionset)
* [`eas build:version:sync`](#eas-buildversionsync)
* [`eas build:view [BUILD_ID]`](#eas-buildview-build_id)
* [`eas channel:create [NAME]`](#eas-channelcreate-name)
* [`eas channel:edit [NAME]`](#eas-channeledit-name)
* [`eas channel:list`](#eas-channellist)
* [`eas channel:rollout [CHANNEL]`](#eas-channelrollout-channel)
* [`eas channel:view [NAME]`](#eas-channelview-name)
* [`eas config`](#eas-config)
* [`eas credentials`](#eas-credentials)
* [`eas device:create`](#eas-devicecreate)
* [`eas device:delete`](#eas-devicedelete)
* [`eas device:list`](#eas-devicelist)
* [`eas device:rename`](#eas-devicerename)
* [`eas device:view [UDID]`](#eas-deviceview-udid)
* [`eas diagnostics`](#eas-diagnostics)
* [`eas help [COMMAND]`](#eas-help-command)
* [`eas init`](#eas-init)
* [`eas login`](#eas-login)
* [`eas logout`](#eas-logout)
* [`eas metadata:lint`](#eas-metadatalint)
* [`eas metadata:pull`](#eas-metadatapull)
* [`eas metadata:push`](#eas-metadatapush)
* [`eas open`](#eas-open)
* [`eas project:info`](#eas-projectinfo)
* [`eas project:init`](#eas-projectinit)
* [`eas secret:create`](#eas-secretcreate)
* [`eas secret:delete`](#eas-secretdelete)
* [`eas secret:list`](#eas-secretlist)
* [`eas secret:push`](#eas-secretpush)
* [`eas submit`](#eas-submit)
* [`eas update`](#eas-update)
* [`eas update:configure`](#eas-updateconfigure)
* [`eas update:delete GROUPID`](#eas-updatedelete-groupid)
* [`eas update:list`](#eas-updatelist)
* [`eas update:republish`](#eas-updaterepublish)
* [`eas update:view GROUPID`](#eas-updateview-groupid)
* [`eas webhook:create`](#eas-webhookcreate)
* [`eas webhook:delete [ID]`](#eas-webhookdelete-id)
* [`eas webhook:list`](#eas-webhooklist)
* [`eas webhook:update`](#eas-webhookupdate)
* [`eas webhook:view ID`](#eas-webhookview-id)
* [`eas whoami`](#eas-whoami)
- [`eas account:login`](#eas-accountlogin)
- [`eas account:logout`](#eas-accountlogout)
- [`eas account:view`](#eas-accountview)
- [`eas analytics [STATUS]`](#eas-analytics-status)
- [`eas autocomplete [SHELL]`](#eas-autocomplete-shell)
- [`eas branch:create [NAME]`](#eas-branchcreate-name)
- [`eas branch:delete [NAME]`](#eas-branchdelete-name)
- [`eas branch:list`](#eas-branchlist)
- [`eas branch:rename`](#eas-branchrename)
- [`eas branch:view [NAME]`](#eas-branchview-name)
- [`eas build`](#eas-build)
- [`eas build:cancel [BUILD_ID]`](#eas-buildcancel-build_id)
- [`eas build:configure`](#eas-buildconfigure)
- [`eas build:inspect`](#eas-buildinspect)
- [`eas build:list`](#eas-buildlist)
- [`eas build:resign`](#eas-buildresign)
- [`eas build:run`](#eas-buildrun)
- [`eas build:submit`](#eas-buildsubmit)
- [`eas build:version:get`](#eas-buildversionget)
- [`eas build:version:set`](#eas-buildversionset)
- [`eas build:version:sync`](#eas-buildversionsync)
- [`eas build:view [BUILD_ID]`](#eas-buildview-build_id)
- [`eas channel:create [NAME]`](#eas-channelcreate-name)
- [`eas channel:edit [NAME]`](#eas-channeledit-name)
- [`eas channel:list`](#eas-channellist)
- [`eas channel:rollout [CHANNEL]`](#eas-channelrollout-channel)
- [`eas channel:view [NAME]`](#eas-channelview-name)
- [`eas config`](#eas-config)
- [`eas credentials`](#eas-credentials)
- [`eas device:create`](#eas-devicecreate)
- [`eas device:delete`](#eas-devicedelete)
- [`eas device:list`](#eas-devicelist)
- [`eas device:rename`](#eas-devicerename)
- [`eas device:view [UDID]`](#eas-deviceview-udid)
- [`eas diagnostics`](#eas-diagnostics)
- [`eas help [COMMAND]`](#eas-help-command)
- [`eas init`](#eas-init)
- [`eas login`](#eas-login)
- [`eas logout`](#eas-logout)
- [`eas metadata:lint`](#eas-metadatalint)
- [`eas metadata:pull`](#eas-metadatapull)
- [`eas metadata:push`](#eas-metadatapush)
- [`eas open`](#eas-open)
- [`eas project:info`](#eas-projectinfo)
- [`eas project:init`](#eas-projectinit)
- [`eas secret:create`](#eas-secretcreate)
- [`eas secret:delete`](#eas-secretdelete)
- [`eas secret:list`](#eas-secretlist)
- [`eas secret:push`](#eas-secretpush)
- [`eas submit`](#eas-submit)
- [`eas update`](#eas-update)
- [`eas update:configure`](#eas-updateconfigure)
- [`eas update:delete GROUPID`](#eas-updatedelete-groupid)
- [`eas update:list`](#eas-updatelist)
- [`eas update:republish`](#eas-updaterepublish)
- [`eas update:view GROUPID`](#eas-updateview-groupid)
- [`eas webhook:create`](#eas-webhookcreate)
- [`eas webhook:delete [ID]`](#eas-webhookdelete-id)
- [`eas webhook:list`](#eas-webhooklist)
- [`eas webhook:update`](#eas-webhookupdate)
- [`eas webhook:view ID`](#eas-webhookview-id)
- [`eas whoami`](#eas-whoami)
## `eas account:login`
@@ -133,7 +134,7 @@ ALIASES
$ eas login
```
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/account/login.ts)_
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -150,7 +151,7 @@ ALIASES
$ eas logout
```
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:view`
@@ -167,7 +168,7 @@ ALIASES
$ eas whoami
```
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/account/view.ts)_
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -181,7 +182,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/analytics.ts)_
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -231,7 +232,7 @@ DESCRIPTION
create a branch
```
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -252,7 +253,7 @@ DESCRIPTION
delete a branch
```
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -272,7 +273,7 @@ DESCRIPTION
list all branches
```
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -292,7 +293,7 @@ DESCRIPTION
rename a branch
```
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -315,7 +316,7 @@ DESCRIPTION
view a branch
```
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -345,7 +346,7 @@ DESCRIPTION
start a build
```
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/index.ts)_
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -362,7 +363,7 @@ DESCRIPTION
cancel a build
```
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -379,7 +380,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:inspect`
@@ -414,7 +415,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -452,7 +453,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/list.ts)_
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -478,7 +479,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -502,7 +503,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/run.ts)_
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -551,7 +552,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -570,7 +571,7 @@ DESCRIPTION
update version of an app
```
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -589,7 +590,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -606,7 +607,7 @@ DESCRIPTION
view a build for your project
```
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/build/view.ts)_
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -627,7 +628,7 @@ DESCRIPTION
create a channel
```
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:edit [NAME]`
@@ -649,7 +650,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -669,7 +670,7 @@ DESCRIPTION
list all channels
```
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -677,31 +678,23 @@ Roll a new branch out on a channel incrementally.
```
USAGE
$ eas channel:rollout [CHANNEL] [--action create|edit|end] [--percent <value>] [--outcome
republish-and-revert|revert] [--branch <value>] [--runtime-version <value>] [--private-key-path <value>] [--json
--non-interactive]
$ eas channel:rollout [CHANNEL] [--branch <value>] [--percent <value>] [--end] [--json --non-interactive]
ARGUMENTS
CHANNEL channel on which the rollout should be done
FLAGS
--action=(create|edit|end) Rollout action to perform
--branch=<value> Branch to roll out. Use with --action=create
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
--outcome=(republish-and-revert|revert) End outcome of rollout. Use with --action=end
--percent=<value> Percent of users to send to the new branch. Use with --action=edit or
--action=create
--private-key-path=<value> File containing the PEM-encoded private key corresponding to the certificate
in expo-updates' configuration. Defaults to a file named "private-key.pem" in
the certificate's directory.
--runtime-version=<value> Runtime version to target. Use with --action=create
--branch=<value> branch to rollout
--end end the rollout
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
--percent=<value> percent of users to send to the new branch
DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -724,7 +717,7 @@ DESCRIPTION
view a channel
```
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -745,7 +738,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/config.ts)_
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -762,7 +755,7 @@ DESCRIPTION
manage credentials
```
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/credentials.ts)_
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/credentials.ts)_
## `eas device:create`
@@ -776,7 +769,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/device/create.ts)_
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -796,7 +789,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -817,7 +810,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/device/list.ts)_
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -838,7 +831,7 @@ DESCRIPTION
rename a registered device
```
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -852,7 +845,7 @@ DESCRIPTION
view a device for your project
```
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/device/view.ts)_
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -866,7 +859,7 @@ DESCRIPTION
display environment info
```
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas help [COMMAND]`
@@ -954,7 +947,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -971,7 +964,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -988,7 +981,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas open`
@@ -1002,7 +995,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/open.ts)_
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1016,7 +1009,7 @@ DESCRIPTION
information about the current project
```
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/project/info.ts)_
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1038,7 +1031,7 @@ ALIASES
$ eas init
```
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/project/init.ts)_
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas secret:create`
@@ -1061,7 +1054,7 @@ DESCRIPTION
create an environment secret on the current project or owner account
```
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/secret/create.ts)_
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/secret/create.ts)_
## `eas secret:delete`
@@ -1079,7 +1072,7 @@ DESCRIPTION
delete an environment secret by ID
```
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/secret/delete.ts)_
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/secret/delete.ts)_
## `eas secret:list`
@@ -1093,7 +1086,7 @@ DESCRIPTION
list environment secrets available for your current app
```
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/secret/list.ts)_
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/secret/list.ts)_
## `eas secret:push`
@@ -1113,7 +1106,7 @@ DESCRIPTION
read environment secrets from env file and store on the server
```
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/secret/push.ts)_
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/secret/push.ts)_
## `eas submit`
@@ -1143,7 +1136,7 @@ ALIASES
$ eas build:submit
```
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/submit.ts)_
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1151,12 +1144,11 @@ publish an update group
```
USAGE
$ eas update [--branch <value>] [--channel <value>] [-m <value>] [--republish | --input-dir <value> |
--skip-bundler] [--group <value> | | ] [--clear-cache] [-p android|ios|all] [--auto] [--private-key-path <value>]
$ eas update [--branch <value>] [--channel <value>] [--message <value>] [--republish | --input-dir <value>
| --skip-bundler] [--group <value> | | ] [--clear-cache] [-p android|ios|all] [--auto] [--private-key-path <value>]
[--json --non-interactive]
FLAGS
-m, --message=<value> A short message describing the update
-p, --platform=(android|ios|all) [default: all]
--auto Use the current git branch and commit message for the EAS branch and update message
--branch=<value> Branch to publish the update group on
@@ -1165,6 +1157,7 @@ FLAGS
--group=<value> Update group to republish (deprecated, see republish command)
--input-dir=<value> [default: dist] Location of the bundle
--json Enable JSON output, non-JSON messages will be printed to stderr.
--message=<value> A short message describing the update
--non-interactive Run the command in non-interactive mode.
--private-key-path=<value> File containing the PEM-encoded private key corresponding to the certificate in
expo-updates' configuration. Defaults to a file named "private-key.pem" in the
@@ -1176,7 +1169,7 @@ DESCRIPTION
publish an update group
```
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/update/index.ts)_
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1194,7 +1187,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1215,7 +1208,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:list`
@@ -1237,7 +1230,7 @@ DESCRIPTION
view the recent updates
```
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/update/list.ts)_
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1245,26 +1238,23 @@ roll back to an existing update
```
USAGE
$ eas update:republish [--channel <value> | --branch <value> | --group <value>] [-m <value>] [-p android|ios|all]
[--private-key-path <value>] [--json --non-interactive]
$ eas update:republish [--channel <value> | --branch <value> | --group <value>] [--message <value>] [-p
android|ios|all] [--json --non-interactive]
FLAGS
-m, --message=<value> Short message describing the republished update group
-p, --platform=(android|ios|all) [default: all]
--branch=<value> Branch name to select an update group to republish from
--channel=<value> Channel name to select an update group to republish from
--branch=<value> Branch name to select an update to republish from
--channel=<value> Channel name to select an update to republish from
--group=<value> Update group ID to republish
--json Enable JSON output, non-JSON messages will be printed to stderr.
--message=<value> Short message describing the republished update
--non-interactive Run the command in non-interactive mode.
--private-key-path=<value> File containing the PEM-encoded private key corresponding to the certificate in
expo-updates' configuration. Defaults to a file named "private-key.pem" in the
certificate's directory.
DESCRIPTION
roll back to an existing update
```
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:view GROUPID`
@@ -1284,7 +1274,7 @@ DESCRIPTION
update group details
```
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/update/view.ts)_
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas webhook:create`
@@ -1305,7 +1295,7 @@ DESCRIPTION
create a webhook
```
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -1325,7 +1315,7 @@ DESCRIPTION
delete a webhook
```
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -1343,7 +1333,7 @@ DESCRIPTION
list webhooks
```
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -1365,7 +1355,7 @@ DESCRIPTION
update a webhook
```
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -1382,7 +1372,7 @@ DESCRIPTION
view a webhook
```
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v4.1.1/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -1398,4 +1388,5 @@ DESCRIPTION
ALIASES
$ eas whoami
```
<!-- commandsstop -->
+262 -1778
View File
File diff suppressed because it is too large. Load diff
+11 -11
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "4.1.1",
"version": "3.14.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -11,10 +11,10 @@
"@expo/apple-utils": "1.3.1",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "8.1.2",
"@expo/config-plugins": "7.2.4",
"@expo/config-plugins": "7.2.3",
"@expo/config-types": "49.0.0",
"@expo/eas-build-job": "1.0.30",
"@expo/eas-json": "4.1.0",
"@expo/eas-build-job": "1.0.13",
"@expo/eas-json": "3.13.0",
"@expo/json-file": "8.2.37",
"@expo/multipart-body-parser": "1.1.0",
"@expo/osascript": "2.0.33",
@@ -23,17 +23,17 @@
"@expo/plist": "0.0.20",
"@expo/plugin-help": "5.1.22",
"@expo/plugin-warn-if-update-available": "2.5.0",
"@expo/prebuild-config": "6.2.5",
"@expo/prebuild-config": "5.0.7",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.0",
"@expo/steps": "1.0.34",
"@expo/steps": "1.0.23",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "1.23.2",
"@oclif/plugin-autocomplete": "1.3.10",
"@segment/ajv-human-errors": "^2.1.2",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
"@urql/core": "3.1.1",
"@urql/exchange-retry": "1.0.0",
"ajv": "8.11.0",
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
@@ -47,6 +47,7 @@
"fast-glob": "3.2.12",
"figures": "3.2.0",
"form-data": "4.0.0",
"freeport-async": "2.0.0",
"fs-extra": "10.1.0",
"getenv": "1.0.0",
"gradle-to-js": "2.0.1",
@@ -204,12 +205,11 @@
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build && yarn build-ci",
"rebuild": "rimraf build && yarn build",
"pretarball-ci": "./scripts/pretarball-ci.sh",
"build": "tsc --project tsconfig.build.json",
"build-ci": "tsc --project tsconfig.build-ci.json",
"watch": "yarn build --watch --preserveWatchOutput",
"typecheck": "tsc --project tsconfig.build-ci.json",
"typecheck": "tsc",
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
+12
View File
@@ -2,6 +2,7 @@ import { JSONValue } from '@expo/json-file';
import { ApiV2Error } from './ApiV2Error';
import fetch, { RequestError, RequestInit } from './fetch';
import { getFreePortAsync } from './utils/port';
interface RequestOptions {
body: JSONValue;
@@ -104,3 +105,14 @@ export function getEASUpdateURL(projectId: string): string {
return new URL(projectId, `https://u.expo.dev`).href;
}
}
export async function getSsoLocalServerPortAsync(): Promise<number> {
let startPort: number;
if (process.env.SSO_LOCAL_SERVER_PORT) {
startPort = Number(process.env.SSO_LOCAL_SERVER_PORT);
} else {
startPort = 19300;
}
const port = await getFreePortAsync(startPort);
return port;
}
@@ -1,12 +0,0 @@
import { UpdateBranchBasicInfoFragment } from '../../graphql/generated';
export const testBasicBranchInfo: UpdateBranchBasicInfoFragment = {
id: 'c4fec95d-7ab1-4d35-bfe6-fe7ff05beda9',
name: 'development',
__typename: 'UpdateBranch',
};
export const testBasicBranchInfo2: UpdateBranchBasicInfoFragment = {
id: '43ccb8a4-8aaa-47f0-9790-dcc80933441b',
name: 'preview',
__typename: 'UpdateBranch',
};
@@ -1,107 +0,0 @@
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { UpdateBranchBasicInfoFragment } from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import {
Connection,
Edge,
FilterPagination,
QueryParams,
selectPaginatedAsync,
} from '../../utils/relay';
/**
* Select a branch for the project.
*
* @constructor
* @param {function} options.filterPredicate - A predicate to filter the branches that are shown to the user. It takes a branchInfo object as a parameter and returns a boolean.
* @param {string} options.printedType - The type of branch printed to the user. Defaults to 'branch'.
* @param {number} options.pageSize - The number of branches to show per page. Defaults to 100.
* @param {function} options.beforeEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called before each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @param {function} options.afterEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called after each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @args willFetchAgain If the query will fetch again to get a complete page.
*/
export class SelectBranch implements EASUpdateAction<UpdateBranchBasicInfoFragment | null> {
constructor(
private options: {
filterPredicate?: (branchInfo: UpdateBranchBasicInfoFragment) => boolean;
printedType?: string;
pageSize?: number;
beforeEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateBranchBasicInfoFragment>[]
) => void;
afterEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateBranchBasicInfoFragment>[],
willFetchAgain: boolean
) => void;
} = {}
) {}
async queryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync(graphqlClient, {
appId: projectId,
...queryParams,
});
}
async filterQueryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams,
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) => boolean
): Promise<Connection<UpdateBranchBasicInfoFragment>> {
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> =>
await this.queryAsync(ctx, queryParams);
return await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery: this.options.beforeEachFilterQuery,
afterEachQuery: this.options.afterEachFilterQuery,
});
}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateBranchBasicInfoFragment | null> {
const { nonInteractive } = ctx;
const { filterPredicate } = this.options;
const printedType = this.options.printedType ?? 'branch';
const pageSize = this.options.pageSize ?? 100;
if (nonInteractive) {
throw new NonInteractiveError(
`${printedType} selection cannot be run in non-interactive mode.`
);
}
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> =>
filterPredicate
? this.filterQueryAsync(ctx, queryParams, filterPredicate)
: this.queryAsync(ctx, queryParams);
const getTitleAsync = async (branchInfo: UpdateBranchBasicInfoFragment): Promise<string> =>
branchInfo.name;
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
}
}
@@ -1,92 +0,0 @@
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { UpdateBranchBasicInfoFragment } from '../../../graphql/generated';
import { BranchQuery } from '../../../graphql/queries/BranchQuery';
import { promptAsync } from '../../../prompts';
import { testBasicBranchInfo, testBasicBranchInfo2 } from '../../__tests__/fixtures';
import { SelectBranch } from '../SelectBranch';
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/BranchQuery');
describe(SelectBranch, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no branchs', async () => {
const ctx = createCtxMock();
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBeNull();
});
it('still prompts if project has only one branch', async () => {
const ctx = createCtxMock();
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicBranchInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicBranchInfo,
}));
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
expect(promptAsync).toHaveBeenCalledTimes(1);
});
it('returns the selected branch', async () => {
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicBranchInfo, cursor: 'cursor' },
{ node: testBasicBranchInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicBranchInfo,
}));
const ctx = createCtxMock();
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
});
it('returns the only available branch with a filter', async () => {
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicBranchInfo, cursor: 'cursor' },
{ node: testBasicBranchInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const ctx = createCtxMock();
const selectBranch = new SelectBranch({
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) =>
branchInfo === testBasicBranchInfo,
});
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
});
it('throws an error in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const selectBranch = new SelectBranch();
// dont fail if users are running in non-interactive mode
await expect(selectBranch.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
});
});
@@ -13,8 +13,6 @@ import {
EasBuildFreeTierDisabledAndroidError,
EasBuildFreeTierDisabledError,
EasBuildFreeTierDisabledIOSError,
EasBuildFreeTierIosLimitExceededError,
EasBuildFreeTierLimitExceededError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError,
RequestValidationError,
@@ -27,18 +25,12 @@ beforeEach(() => {
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `[
{
"name": "GraphQLError",
"extensions": {},
"message": "Error 1"
},
{
"name": "GraphQLError",
"extensions": {},
"message": "Error 2"
},
{
"name": "GraphQLError",
"extensions": {},
"message": "Error 3"
}
]`;
@@ -288,45 +280,6 @@ describe(Build.name, () => {
);
});
});
describe('for EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierLimitExceededError>(
() => {
handleBuildRequestError(error, platform);
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
'Error 1'
);
});
});
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError =
getError<EasBuildFreeTierIosLimitExceededError>(() => {
handleBuildRequestError(error, platform);
});
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierIosLimitExceededError,
'Error 1'
);
});
});
describe('for VALIDATION_ERROR', () => {
describe('with Android', () => {
@@ -471,29 +424,6 @@ describe(Build.name, () => {
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
});
it('throws base Error class with message including all error messages if combined error has multiple GraphQL errors', async () => {
const platform = Platform.ANDROID;
const graphQLErrors = [
getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR'),
getGraphQLError('Error 2', 'OTHER_GRAPHQL_ERROR'),
getGraphQLError('Error 3', 'YET_ANOTHER_GRAPHQL_ERROR'),
];
const error = new CombinedError({ graphQLErrors });
const expectedMessage =
`${EXPECTED_GENERIC_MESSAGE}\n` +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 1\n' +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 2\n' +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 3';
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
});
describe('without request ID', () => {
it('throws base Error class with custom message without request ID line', async () => {
const platform = Platform.ANDROID;
@@ -525,29 +455,6 @@ describe(Build.name, () => {
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
});
it('throws base Error class with message including all error messages if combined error has multiple GraphQL errors', async () => {
const platform = Platform.IOS;
const graphQLErrors = [
getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR'),
getGraphQLError('Error 2', 'OTHER_GRAPHQL_ERROR'),
getGraphQLError('Error 3', 'YET_ANOTHER_GRAPHQL_ERROR'),
];
const error = new CombinedError({ graphQLErrors });
const expectedMessage =
`${EXPECTED_GENERIC_MESSAGE}\n` +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 1\n' +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 2\n' +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 3';
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
});
describe('without request ID', () => {
it('throws base Error class with custom message without request ID line', async () => {
const platform = Platform.IOS;
+2 -1
View File
@@ -124,7 +124,8 @@ export async function prepareAndroidBuildAsync(
}
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
return !ctx.buildProfile.withoutCredentials;
const isCustomBuild = ctx.buildProfile.config !== undefined;
return !ctx.buildProfile.withoutCredentials && !isCustomBuild;
}
async function ensureAndroidCredentialsAsync(
+8 -23
View File
@@ -4,7 +4,6 @@ import assert from 'assert';
import chalk from 'chalk';
import cliProgress from 'cli-progress';
import fs from 'fs-extra';
import { GraphQLError } from 'graphql/error';
import nullthrows from 'nullthrows';
import { BuildEvent } from '../analytics/AnalyticsManager';
@@ -43,9 +42,6 @@ import {
EasBuildFreeTierDisabledAndroidError,
EasBuildFreeTierDisabledError,
EasBuildFreeTierDisabledIOSError,
EasBuildFreeTierIosLimitExceededError,
EasBuildFreeTierLimitExceededError,
EasBuildLegacyResourceClassNotAvailableError,
EasBuildProjectArchiveUploadError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError,
@@ -187,11 +183,8 @@ const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
EAS_BUILD_FREE_TIER_DISABLED: EasBuildFreeTierDisabledError,
EAS_BUILD_FREE_TIER_DISABLED_IOS: EasBuildFreeTierDisabledIOSError,
EAS_BUILD_FREE_TIER_DISABLED_ANDROID: EasBuildFreeTierDisabledAndroidError,
EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED: EasBuildFreeTierLimitExceededError,
EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED: EasBuildFreeTierIosLimitExceededError,
EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER:
EasBuildResourceClassNotAvailableInFreeTierError,
EAS_BUILD_LEGACY_RESOURCE_CLASS_NOT_AVAILABLE: EasBuildLegacyResourceClassNotAvailableError,
VALIDATION_ERROR: RequestValidationError,
};
@@ -213,19 +206,15 @@ export function handleBuildRequestError(error: any, platform: Platform): never {
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
);
} else if (error?.graphQLErrors) {
const errorMessage = error.graphQLErrors
.map((graphQLError: GraphQLError) => {
const requestIdLine = graphQLError?.extensions?.requestId
? `\nRequest ID: ${graphQLError.extensions.requestId}`
: '';
const errorMessageLine = graphQLError?.message
? `\nError message: ${graphQLError.message}`
: '';
return `${requestIdLine}${errorMessageLine}`;
})
.join('');
const graphQLError = error.graphQLErrors[0];
const requestIdLine = graphQLError?.extensions?.requestId
? `\nRequest ID: ${graphQLError.extensions.requestId}`
: '';
const originalErrorMessage = graphQLError?.message
? `\nError message: ${graphQLError.message}`
: '';
throw new Error(
`Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.${errorMessage}`
`Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.${requestIdLine}${originalErrorMessage}`
);
}
throw error;
@@ -257,10 +246,6 @@ async function uploadProjectAsync<TPlatform extends Platform>(
);
}
if (projectTarball.size > 2 * 1024 * 1024 * 1024) {
throw new Error('Project archive is too big. Maximum allowed size is 2GB.');
}
projectTarballPath = projectTarball.path;
const bucketKey = await uploadFileAtPathToGCSAsync(
-6
View File
@@ -8,14 +8,8 @@ export class EasBuildFreeTierDisabledIOSError extends EasCommandError {}
export class EasBuildFreeTierDisabledAndroidError extends EasCommandError {}
export class EasBuildFreeTierLimitExceededError extends EasCommandError {}
export class EasBuildFreeTierIosLimitExceededError extends EasCommandError {}
export class EasBuildResourceClassNotAvailableInFreeTierError extends EasCommandError {}
export class EasBuildLegacyResourceClassNotAvailableError extends EasCommandError {}
export class RequestValidationError extends EasCommandError {}
export class EasBuildDownForMaintenanceError extends EasCommandError {}
@@ -55,5 +55,6 @@ export async function ensureIosCredentialsForBuildResignAsync(
}
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
return !buildCtx.buildProfile.simulator && !buildCtx.buildProfile.withoutCredentials;
const isCustomBuild = buildCtx.buildProfile.config !== undefined;
return !buildCtx.buildProfile.simulator && !isCustomBuild;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import semver from 'semver';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '1.0.35';
const PLUGIN_PACKAGE_VERSION = '1.0.24';
export enum LocalBuildMode {
/**
@@ -24,7 +24,7 @@ import {
} from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import { toAppPlatform, toPlatform } from '../graphql/types/AppPlatform';
import Log, { learnMore } from '../log';
import Log from '../log';
import {
RequestedPlatform,
appPlatformDisplayNames,
@@ -118,13 +118,6 @@ export async function runBuildAndSubmitAsync(
platforms,
profileName: flags.profile ?? undefined,
});
Log.log(
`Loaded "env" configuration for the "${buildProfiles[0].profileName}" profile: ${
buildProfiles[0].profile.env
? Object.keys(buildProfiles[0].profile.env).join(', ')
: 'no environment variables specified'
}. ${learnMore('https://docs.expo.dev/build-reference/variables/')}`
);
await ensureExpoDevClientInstalledForDevClientBuildsAsync({
projectDir,
@@ -1,61 +0,0 @@
import {
emptyBranchMapping,
rolloutBranchMapping,
standardBranchMapping,
} from '../../rollout/__tests__/fixtures';
import {
BranchMappingValidationError,
assertVersion,
getAlwaysTrueBranchMapping,
getBranchMapping,
getStandardBranchId,
isAlwaysTrueBranchMapping,
isEmptyBranchMapping,
} from '../branch-mapping';
import { testChannelObject } from './fixtures';
describe(assertVersion, () => {
it('throws if the branch mapping is not the correct version', () => {
expect(() => assertVersion(testChannelObject, 5)).toThrowError(BranchMappingValidationError);
});
it('asserts the correct version', () => {
assertVersion(testChannelObject, 0);
expect(getBranchMapping(testChannelObject.branchMapping).version).toBe(0);
});
});
describe(isEmptyBranchMapping, () => {
it('detects empty branch mappings', () => {
expect(isEmptyBranchMapping(emptyBranchMapping)).toBe(true);
expect(isEmptyBranchMapping(standardBranchMapping)).toBe(false);
expect(isEmptyBranchMapping(rolloutBranchMapping)).toBe(false);
});
});
describe(isAlwaysTrueBranchMapping, () => {
it('detects always true branch mappings', () => {
expect(isAlwaysTrueBranchMapping(standardBranchMapping)).toBe(true);
expect(isAlwaysTrueBranchMapping(rolloutBranchMapping)).toBe(false);
expect(isAlwaysTrueBranchMapping(emptyBranchMapping)).toBe(false);
});
});
describe(getAlwaysTrueBranchMapping, () => {
it('gets an always true branch mapping', () => {
const alwaysTrueBranchMapping = getAlwaysTrueBranchMapping('test-id');
expect(isAlwaysTrueBranchMapping(alwaysTrueBranchMapping)).toBe(true);
});
});
describe(getStandardBranchId, () => {
it('throws if the branch mapping is not a standard mapping', () => {
expect(() => getStandardBranchId(testChannelObject)).toThrowError(BranchMappingValidationError);
});
it('gets a standard branch id', () => {
const channelObjectWithStandardMapping = { ...testChannelObject };
channelObjectWithStandardMapping.branchMapping = JSON.stringify(standardBranchMapping);
expect(getStandardBranchId(channelObjectWithStandardMapping)).toBe(
standardBranchMapping.data[0].branchId
);
});
});
@@ -1,147 +0,0 @@
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { UpdateBranchObject, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
export const testUpdateBranch1: UpdateBranchObject = {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
updateGroups: [
[
{
id: 'bbe3e63a-f620-4603-8c4d-258f3e51590d',
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"3d0ac172fe41ab941a8c68cd7e3a17ee","fileSHA256":"jjnar-ZPrb_g0Bx5tcFfjJrP3v8HdeAlXMk__xN7I1U","storageKey":"bimf-UXq5IFH5nNHCIHdcQ2MyJwTnwDVBb-P8hr2CZw","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/bbe3e63a-f620-4603-8c4d-258f3e51590d',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
{
id: 'db22401b-d321-4629-a068-21d6d1f19c02',
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"4cc2ee49988e438ac066827b809721de","fileSHA256":"ctfK72f8EdS1IQmDvJLya38RMSma16beJcMoQToZIe0","storageKey":"QdEoKuCQPvVPA71gY52wqJ9vmtV4ObnCcPE2FP4SIdo","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/db22401b-d321-4629-a068-21d6d1f19c02',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
],
],
__typename: 'UpdateBranch',
};
export const testUpdateBranch2: UpdateBranchObject = {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
updateGroups: [
[
{
id: 'f04aafc1-0bf5-4548-8c35-726b467e7c40',
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"76ec49faddaef30ba78fe50edc5ab3d6","fileSHA256":"OLUdgP-fZI2shwGS5qlIk7nWnybL_13RJl5xR85CDBU","storageKey":"OCvI0-34zApbdo3eSGfIaQUbWdZeLeKH-nJSeedKTFs","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/f04aafc1-0bf5-4548-8c35-726b467e7c40',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
{
id: 'd8d15786-2727-407f-bf3e-f4bda7e397d9',
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"f4c6981626dbabcf51078d07a1611f4e","fileSHA256":"q-k1hSWl49aXpSg4jOSaVVnYFSFL6xf7HVl_TQE-ObM","storageKey":"FwK1PaZqtpL0QZIgaHsxU1AV93JhMdu9K1wZizXROAc","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/d8d15786-2727-407f-bf3e-f4bda7e397d9',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
],
],
__typename: 'UpdateBranch',
};
export const testChannelObject: UpdateChannelObject = {
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
name: 'production',
createdAt: '2023-05-16T21:26:16.946Z',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
updateBranches: [testUpdateBranch1, testUpdateBranch2],
__typename: 'UpdateChannel',
};
export const testBasicChannelInfo: UpdateChannelBasicInfoFragment = {
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
name: 'production',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
__typename: 'UpdateChannel',
};
export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
id: '9d5c7bf0-d52d-474c-9140-c1bad7f0de9d',
name: 'staging',
branchMapping:
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"f9f708c2-0c91-4360-b2a4-0b61834aef4a","branchMappingLogic":"true"}],"version":0}',
__typename: 'UpdateChannel',
};
@@ -1,11 +0,0 @@
import { getUpdateBranch } from '../utils';
import { testChannelObject, testUpdateBranch1 } from './fixtures';
describe(getUpdateBranch, () => {
it('gets the update branch', () => {
expect(getUpdateBranch(testChannelObject, '754bf17f-efc0-46ab-8a59-a03f20e53e9b')).toBe(
testUpdateBranch1
);
expect(() => getUpdateBranch(testChannelObject, 'not-a-branch-ID')).toThrow();
});
});
@@ -1,107 +0,0 @@
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import {
Connection,
Edge,
FilterPagination,
QueryParams,
selectPaginatedAsync,
} from '../../utils/relay';
/**
* Select a channel for the project.
*
* @constructor
* @param {function} options.filterPredicate - A predicate to filter the channels that are shown to the user. It takes a channelInfo object as a parameter and returns a boolean.
* @param {string} options.printedType - The type of channel printed to the user. Defaults to 'channel'.
* @param {number} options.pageSize - The number of channels to show per page. Defaults to 100.
* @param {function} options.beforeEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called before each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @param {function} options.afterEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called after each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @args willFetchAgain If the query will fetch again to get a complete page.
*/
export class SelectChannel implements EASUpdateAction<UpdateChannelBasicInfoFragment | null> {
constructor(
private options: {
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean;
printedType?: string;
pageSize?: number;
beforeEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateChannelBasicInfoFragment>[]
) => void;
afterEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateChannelBasicInfoFragment>[],
willFetchAgain: boolean
) => void;
} = {}
) {}
async queryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync(graphqlClient, {
appId: projectId,
...queryParams,
});
}
async filterQueryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams,
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) => boolean
): Promise<Connection<UpdateChannelBasicInfoFragment>> {
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
await this.queryAsync(ctx, queryParams);
return await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery: this.options.beforeEachFilterQuery,
afterEachQuery: this.options.afterEachFilterQuery,
});
}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
const { nonInteractive } = ctx;
const { filterPredicate } = this.options;
const printedType = this.options.printedType ?? 'channel';
const pageSize = this.options.pageSize ?? 100;
if (nonInteractive) {
throw new NonInteractiveError(
`${printedType} selection cannot be run in non-interactive mode.`
);
}
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
filterPredicate
? this.filterQueryAsync(ctx, queryParams, filterPredicate)
: this.queryAsync(ctx, queryParams);
const getTitleAsync = async (channelInfo: UpdateChannelBasicInfoFragment): Promise<string> =>
channelInfo.name;
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
}
}
@@ -1,92 +0,0 @@
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../../graphql/generated';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import { promptAsync } from '../../../prompts';
import { testBasicChannelInfo, testBasicChannelInfo2 } from '../../__tests__/fixtures';
import { SelectChannel } from '../SelectChannel';
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/ChannelQuery');
describe(SelectChannel, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no channels', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBeNull();
});
it('still prompts if project has only one channel', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicChannelInfo,
}));
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
expect(promptAsync).toHaveBeenCalledTimes(1);
});
it('returns the selected channel', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicChannelInfo,
}));
const ctx = createCtxMock();
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
});
it('returns the only available channel with a filter', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const ctx = createCtxMock();
const selectChannel = new SelectChannel({
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) =>
channelInfo === testBasicChannelInfo,
});
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
});
it('throws an error in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const selectChannel = new SelectChannel();
// dont fail if users are running in non-interactive mode
await expect(selectChannel.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
});
});
@@ -1,207 +0,0 @@
import { UpdateChannelBasicInfoFragment } from '../graphql/generated';
// TODO(quin): move this into a common package with www
export type BranchMappingOperator =
| '=='
| '!='
| '<'
| '>'
| '<='
| '>='
| 'in'
| 'regex'
| 'hash_lt'
| 'hash_lte'
| 'hash_gt'
| 'hash_gte';
export type BranchMappingObject = {
clientKey: string;
branchMappingOperator: BranchMappingOperator;
operand: number | string | string[];
};
export type BranchMappingAlwaysTrue = 'true';
export type BranchMappingStatement =
| ['and' | 'or', ...BranchMappingNode[]]
| ['not', BranchMappingNode];
export type BranchMappingNode =
| BranchMappingAlwaysTrue
| BranchMappingObject
| BranchMappingStatement;
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: BranchMappingNode;
}[];
};
export type AlwaysTrueBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
}
];
};
export type EmptyBranchMapping = {
version: number;
data: [];
};
export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMapping {
return {
version: 0,
data: [
{
branchId,
branchMappingLogic: 'true',
},
],
};
}
export function hasEmptyBranchMap(channelInfo: UpdateChannelBasicInfoFragment): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isEmptyBranchMapping(branchMapping);
}
export function hasStandardBranchMap(channelInfo: UpdateChannelBasicInfoFragment): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isAlwaysTrueBranchMapping(branchMapping);
}
export function getStandardBranchId(channelInfo: UpdateChannelBasicInfoFragment): string {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
assertAlwaysTrueBranchMapping(branchMapping);
return getBranchIdFromStandardMapping(branchMapping);
}
export function isEmptyBranchMapping(
branchMapping: BranchMapping
): branchMapping is EmptyBranchMapping {
return branchMapping.data.length === 0;
}
export function isAlwaysTrueBranchMapping(
branchMapping: BranchMapping
): branchMapping is AlwaysTrueBranchMapping {
const numBranches = branchMapping.data.length;
if (numBranches !== 1) {
return false;
}
const branchMappingLogic = branchMapping.data[0].branchMappingLogic;
return isAlwaysTrue(branchMappingLogic);
}
function getBranchIdFromStandardMapping(branchMapping: AlwaysTrueBranchMapping): string {
return branchMapping.data[0].branchId;
}
export function getBranchIds(branchMapping: BranchMapping): string[] {
return branchMapping.data.map(data => data.branchId);
}
export function getBranchMapping(branchMappingString: string): BranchMapping {
try {
return JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
}
export function getNodesFromStatement(statement: BranchMappingStatement): BranchMappingNode[] {
return statement.slice(1) as BranchMappingNode[];
}
export function isAndStatement(
statement: BranchMappingStatement
): statement is BranchMappingStatement {
return statement[0] === 'and';
}
export function isStatement(node: BranchMappingNode): node is BranchMappingStatement {
return Array.isArray(node);
}
export function isNodeObject(node: BranchMappingNode): node is BranchMappingObject {
return typeof node === 'object' && !isStatement(node);
}
export function andStatement(nodes: BranchMappingNode[]): ['and', ...BranchMappingNode[]] {
return ['and', ...nodes];
}
export function isAlwaysTrue(node: BranchMappingNode): boolean {
return node === 'true';
}
export function alwaysTrue(): BranchMappingAlwaysTrue {
return 'true';
}
export function equalsOperator(): BranchMappingOperator {
return '==';
}
export function hashLtOperator(): BranchMappingOperator {
return 'hash_lt';
}
function isVersion(branchMapping: BranchMapping, version: number): boolean {
return branchMapping.version === version;
}
export function assertVersion(channelInfo: UpdateChannelBasicInfoFragment, version: number): void {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
if (!isVersion(branchMapping, version)) {
throw new BranchMappingValidationError(
`Expected branch mapping version ${version}. Received: ${JSON.stringify(branchMapping)}`
);
}
}
export function assertStatement(node: BranchMappingNode): asserts node is BranchMappingStatement {
if (!isStatement(node)) {
throw new BranchMappingValidationError(
'Branch mapping node must be a statement. Received: ' + JSON.stringify(node)
);
}
}
export function assertNodeObject(node: BranchMappingNode): asserts node is BranchMappingObject {
if (!isNodeObject(node)) {
throw new BranchMappingValidationError(
'Branch mapping node must be an object. Received: ' + JSON.stringify(node)
);
}
}
export function assertNumber(operand: string | number | string[]): asserts operand is number {
if (typeof operand !== 'number') {
throw new BranchMappingValidationError(
'Expected a number. Received: ' + JSON.stringify(operand)
);
}
}
export function assertString(operand: string | number | string[]): asserts operand is string {
if (typeof operand !== 'string') {
throw new BranchMappingValidationError(
'Expected a string. Received: ' + JSON.stringify(operand)
);
}
}
function assertAlwaysTrueBranchMapping(
branchMapping: BranchMapping
): asserts branchMapping is AlwaysTrueBranchMapping {
if (!isAlwaysTrueBranchMapping(branchMapping)) {
throw new BranchMappingValidationError(
'Expected standard branch mapping. Received: ' + JSON.stringify(branchMapping)
);
}
}
export class BranchMappingValidationError extends Error {}
@@ -1,52 +0,0 @@
import assert from 'assert';
import chalk from 'chalk';
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import { getRollout, isRollout } from '../rollout/branch-mapping';
import {
FormattedBranchDescription,
formatBranch,
getUpdateGroupDescriptionsWithBranch,
} from '../update/utils';
import { assertVersion, hasEmptyBranchMap, hasStandardBranchMap } from './branch-mapping';
export function logChannelDetails(channel: UpdateChannelObject): void {
assertVersion(channel, 0);
assert(
hasEmptyBranchMap(channel) || hasStandardBranchMap(channel) || isRollout(channel),
'Only standard branch mappings and rollouts are supported.'
);
const branchDescription = channel.updateBranches.flatMap(branch => {
const updateGroupWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
branch.updateGroups
);
const maybeRollout = isRollout(channel) ? getRollout(channel) : null;
let maybePercentOnBranch: number | undefined = undefined;
if (maybeRollout) {
maybePercentOnBranch =
maybeRollout.rolledOutBranchId === branch.id
? maybeRollout.percentRolledOut
: 100 - maybeRollout.percentRolledOut;
}
return updateGroupWithBranchDescriptions.map(
({ branch, ...updateGroup }): FormattedBranchDescription => ({
branch,
branchRolloutPercentage: maybePercentOnBranch,
update: updateGroup,
})
);
});
if (branchDescription.length === 0) {
Log.log(chalk.dim('No branches are pointed to this channel.'));
} else {
Log.log(
branchDescription
.map(description => formatBranch(description))
.join(`\n\n${chalk.dim('———')}\n\n`)
);
}
}
+3 -5
View File
@@ -1,5 +1,4 @@
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
@@ -13,7 +12,6 @@ import {
} from '../graphql/generated';
import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
import Log from '../log';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
@@ -21,7 +19,7 @@ import {
paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync,
} from '../utils/queries';
import { logChannelDetails } from './print-utils';
import { logChannelDetails } from './utils';
export const CHANNELS_LIMIT = 25;
@@ -229,11 +227,11 @@ export async function createChannelOnAppAsync(
updateChannel {
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
id
...UpdateChannelBasicInfoFragment
name
branchMapping
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{
appId,
+111 -17
View File
@@ -1,23 +1,117 @@
import { UpdateBranchObject, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import assert from 'assert';
import chalk from 'chalk';
function getUpdateBranchNullable(
channel: UpdateChannelObject,
branchId: string
): UpdateBranchObject | null {
const updateBranches = channel.updateBranches;
const updateBranch = updateBranches.find(branch => branch.id === branchId);
return updateBranch ?? null;
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import {
FormattedBranchDescription,
formatBranch,
getUpdateGroupDescriptionsWithBranch,
} from '../update/utils';
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: {
operand: number;
clientKey: string;
branchMappingOperator: string;
} & string;
}[];
};
/**
* Get the branch mapping and determine whether it is a rollout.
* Ensure that the branch mapping is properly formatted.
*/
export function getBranchMapping(branchMappingString?: string): {
branchMapping: BranchMapping;
isRollout: boolean;
rolloutPercent?: number;
} {
if (!branchMappingString) {
throw new Error('Missing branch mapping.');
}
let branchMapping: BranchMapping;
try {
branchMapping = JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
assert(branchMapping, 'Branch Mapping must be defined.');
if (branchMapping.version !== 0) {
throw new Error('Branch mapping must be version 0.');
}
const isRollout = branchMapping.data.length === 2;
const rolloutPercent = branchMapping.data[0]?.branchMappingLogic.operand;
switch (branchMapping.data.length) {
case 0:
break;
case 1:
if (branchMapping.data[0].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a single branch must be "true"');
}
break;
case 2:
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
}
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
}
if (rolloutPercent == null) {
throw new Error('Branch mapping is missing a "rolloutPercent"');
}
if (branchMapping.data[1].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
}
break;
default:
throw new Error('Branch mapping data must have length less than or equal to 2.');
}
return { branchMapping, isRollout, rolloutPercent };
}
export function getUpdateBranch(
channel: UpdateChannelObject,
branchId: string
): UpdateBranchObject {
const updateBranch = getUpdateBranchNullable(channel, branchId);
if (!updateBranch) {
throw new Error(
`Could not find branch with id "${branchId}" in branch-mapping of channel "${channel.name}"`
export function logChannelDetails(channel: UpdateChannelObject): void {
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
if (branchMapping.data.length > 2) {
throw new Error('Branch Mapping data must have length less than or equal to 2.');
}
const rolloutBranchIds = branchMapping.data.map(data => data.branchId);
const branchDescription = channel.updateBranches.flatMap(branch => {
const updateGroupWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
branch.updateGroups
);
const isRolloutBranch = isRollout && rolloutBranchIds.includes(branch.id);
const isBaseBranch = rolloutBranchIds.length > 0 && rolloutBranchIds[0] === branch.id;
let rolloutPercentNumber: number | undefined = undefined;
if (isRolloutBranch) {
rolloutPercentNumber = isBaseBranch ? rolloutPercent! * 100 : (1 - rolloutPercent!) * 100;
}
return updateGroupWithBranchDescriptions.map(
({ branch, ...updateGroup }): FormattedBranchDescription => ({
branch,
branchRolloutPercentage: rolloutPercentNumber,
update: updateGroup,
})
);
});
if (branchDescription.length === 0) {
Log.log(chalk.dim('No branches are pointed to this channel.'));
} else {
Log.log(
branchDescription
.map(description => formatBranch(description))
.join(`\n\n${chalk.dim('———')}\n\n`)
);
}
return updateBranch;
}
@@ -1,6 +1,5 @@
import { Command } from '@oclif/core';
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import nullthrows from 'nullthrows';
import {
@@ -194,22 +193,10 @@ export default abstract class EasCommand extends Command {
if (err instanceof EasCommandError) {
Log.error(err.message);
} else if (err instanceof CombinedError && err?.graphQLErrors) {
const cleanMessage = err?.graphQLErrors
.map((graphQLError: GraphQLError) => {
const messageLine = graphQLError.message.replace('[GraphQL] ', '');
const requestIdLine = graphQLError.extensions?.requestId
? `\nRequest ID: ${err.graphQLErrors[0].extensions.requestId}`
: '';
const defaultMsg = `${messageLine}${requestIdLine}`;
if (graphQLError.extensions?.errorCode === 'UNAUTHORIZED_ERROR') {
return `You don't have the required permissions to perform this operation.\n\n${defaultMsg}`;
}
return defaultMsg;
})
.join('\n');
let cleanMessage = err.message.replace('[GraphQL] ', '');
cleanMessage += err?.graphQLErrors?.[0]?.extensions?.requestId
? `\nRequest ID: ${err.graphQLErrors[0].extensions.requestId}`
: '';
Log.error(cleanMessage);
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
} else {
@@ -172,44 +172,6 @@ describe(EasCommand.name, () => {
expect(logDebugSpy).toBeCalledWith(error);
});
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
new GraphQLError('Other GraphQL error message', null, null, null, null, null, {
errorCode: 'OTHER_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
new GraphQLError('Yet another GraphQL error message', null, null, null, null, null, {
errorCode: 'YET_ANOTHER_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
];
const error = new CombinedError({ graphQLErrors });
runAsyncMock.mockImplementation(() => {
throw error;
});
try {
await TestEasCommand.run();
} catch {}
expect(logErrorSpy).toBeCalledWith(
'Unexpected GraphQL error message\n' +
`Request ID: ${mockRequestId}\n` +
'Other GraphQL error message\n' +
`Request ID: ${mockRequestId}\n` +
'Yet another GraphQL error message\n' +
`Request ID: ${mockRequestId}`
);
expect(logDebugSpy).toBeCalledWith(error);
});
it('re-throws the error with default base message', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
@@ -224,7 +186,7 @@ describe(EasCommand.name, () => {
}
});
it('re-throws the error with a different default base message in case of CombinedError', async () => {
it('re-throws the error with a different default base message in case of graphQLError', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
runAsyncMock.mockImplementation(() => {
@@ -238,34 +200,6 @@ describe(EasCommand.name, () => {
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
}
});
it('re-throws the error with a different default base message in case of CombinedError with multiple GraphQLErrors', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
errorCode: 'UNEXPECTED_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
new GraphQLError('Other GraphQL error message', null, null, null, null, null, {
errorCode: 'OTHER_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
new GraphQLError('Yet another GraphQL error message', null, null, null, null, null, {
errorCode: 'YET_ANOTHER_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
];
throw new CombinedError({ graphQLErrors });
});
try {
await TestEasCommand.run();
} catch (caughtError) {
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
}
});
});
});
});
@@ -1,4 +1,4 @@
import { getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
import { getConfig, modifyConfigAsync } from '@expo/config';
import { vol } from 'memfs';
import { anything, instance, mock, when } from 'ts-mockito';
@@ -26,10 +26,6 @@ describe(getProjectIdAsync, () => {
let sessionManager: SessionManager;
beforeEach(() => {
jest
.mocked(getConfigFilePaths)
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
@@ -3,10 +3,11 @@ import {
Client,
OperationContext,
OperationResult,
OperationResultSource,
PromisifiedSource,
TypedDocumentNode,
cacheExchange,
createClient as createUrqlClient,
dedupExchange,
fetchExchange,
} from '@urql/core';
import { retryExchange } from '@urql/exchange-retry';
@@ -21,7 +22,7 @@ export interface ExpoGraphqlClient extends Client {
query: DocumentNode | TypedDocumentNode<Data, Variables> | string,
variables: Variables,
context: Partial<OperationContext> & { additionalTypenames: string[] }
): OperationResultSource<OperationResult<Data, Variables>>;
): PromisifiedSource<OperationResult<Data, Variables>>;
}
export function createGraphqlClient(authInfo: {
@@ -31,6 +32,7 @@ export function createGraphqlClient(authInfo: {
return createUrqlClient({
url: getExpoApiBaseUrl() + '/graphql',
exchanges: [
dedupExchange,
cacheExchange,
retryExchange({
maxDelayMs: 4000,
@@ -11,10 +11,7 @@ import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../../project/projectU
import { resolveWorkflowAsync } from '../../project/workflow';
import { promptAsync } from '../../prompts';
import { syncUpdatesConfigurationAsync as syncAndroidUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
import {
ensureEASUpdateIsConfiguredInEasJsonAsync,
ensureUseClassicUpdatesIsRemovedAsync,
} from '../../update/configure';
import { ensureEASUpdateIsConfiguredInEasJsonAsync } from '../../update/configure';
import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
import { getVcsClient } from '../../vcs';
@@ -66,12 +63,6 @@ export default class BuildConfigure extends EasCommand {
nonInteractive: false,
});
if (didCreateEasJson && isUsingEASUpdate(exp, projectId)) {
if (exp.updates?.useClassicUpdates) {
// NOTE: this method modifies the Expo config; be sure to use this function's return value
// if the config object is used later in the future
await ensureUseClassicUpdatesIsRemovedAsync({ exp, projectDir });
}
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
}
+4 -11
View File
@@ -1,31 +1,26 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { hasEmptyBranchMap, hasStandardBranchMap } from '../../channel/branch-mapping';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
UpdateChannelBasicInfoFragment,
UpdateChannelBranchMappingMutation,
UpdateChannelBranchMappingMutationVariables,
} from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../../graphql/types/UpdateChannelBasicInfo';
import Log from '../../log';
import { isRollout } from '../../rollout/branch-mapping';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export async function updateChannelBranchMappingAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId, branchMapping }: UpdateChannelBranchMappingMutationVariables
): Promise<UpdateChannelBasicInfoFragment> {
): Promise<UpdateChannelBranchMappingMutation['updateChannel']['editUpdateChannel']> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
@@ -34,11 +29,11 @@ export async function updateChannelBranchMappingAsync(
updateChannel {
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
id
...UpdateChannelBasicInfoFragment
name
branchMapping
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId, branchMapping }
)
@@ -100,10 +95,8 @@ export default class ChannelEdit extends EasCommand {
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
});
if (isRollout(existingChannel)) {
if (existingChannel.updateBranches.length > 1) {
throw new Error('There is a rollout in progress. Manage it with "channel:rollout" instead.');
} else if (!hasStandardBranchMap(existingChannel) && !hasEmptyBranchMap(existingChannel)) {
throw new Error('Only standard branch mappings can be edited with this command.');
}
const branch = branchFlag
+439 -141
View File
@@ -1,49 +1,297 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { selectChannelOnAppAsync } from '../../channel/queries';
import { BranchMapping, getBranchMapping } from '../../channel/utils';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { UpdateBranch } from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelByNameObject } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
import {
EndOutcome,
GeneralOptions as EndRolloutGeneralOptions,
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
} from '../../rollout/actions/EndRollout';
import { ManageRolloutActions } from '../../rollout/actions/ManageRollout';
import { NonInteractiveRollout } from '../../rollout/actions/NonInteractiveRollout';
import {
MainMenuActions,
RolloutActions,
RolloutMainMenu,
} from '../../rollout/actions/RolloutMainMenu';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync, selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { updateChannelBranchMappingAsync } from './edit';
enum ActionRawFlagValue {
CREATE = 'create',
EDIT = 'edit',
END = 'end',
async function promptForRolloutPercentAsync({
promptMessage,
}: {
promptMessage: string;
}): Promise<number> {
const { name: rolloutPercent } = await promptAsync({
type: 'text',
name: 'name',
format: value => {
return parseInt(value, 10);
},
message: promptMessage,
initial: 0,
validate: (rolloutPercent: string): true | string => {
const floatValue = parseFloat(rolloutPercent);
return Number.isInteger(floatValue) && floatValue >= 0 && floatValue <= 100
? true
: 'The rollout percentage must be an integer between 0 and 100 inclusive.';
},
});
return rolloutPercent;
}
type ChannelRolloutRawArgsAndFlags = {
channel?: string;
action?: ActionRawFlagValue;
percent?: number;
outcome?: EndOutcome;
'non-interactive': boolean;
branch?: string;
'runtime-version'?: string;
'private-key-path'?: string;
json?: boolean;
};
type ChannelRolloutArgsAndFlags = {
channelName?: string;
action?: RolloutActions;
nonInteractive: boolean;
json?: boolean;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
EndRolloutGeneralOptions &
Partial<CreateRolloutNonInteractiveOptions>;
function getRolloutInfo(channel: UpdateChannelByNameObject): {
newBranch: Pick<UpdateBranch, 'name' | 'id'>;
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
currentPercent: number;
} {
const { branchMapping } = getBranchMapping(channel.branchMapping);
const [newBranchId, oldBranchId] = branchMapping.data.map(d => d.branchId);
const newBranch = channel.updateBranches.filter(branch => branch.id === newBranchId)[0];
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
if (!newBranch || !oldBranch) {
throw new Error(`Branch mapping rollout is missing a branch for channel "${channel.name}".`);
}
const currentPercent = 100 * branchMapping.data[0].branchMappingLogic.operand;
return { newBranch, oldBranch, currentPercent };
}
async function startRolloutAsync(
graphqlClient: ExpoGraphqlClient,
{
channelName,
branchName,
percent,
projectId,
displayName,
currentBranchMapping,
channel,
nonInteractive,
}: {
channelName?: string;
branchName: string;
percent?: number;
projectId: string;
displayName: string;
currentBranchMapping: BranchMapping;
channel: UpdateChannelByNameObject;
nonInteractive: boolean;
}
): Promise<{
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
}> {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId: projectId,
name: branchName,
});
const oldBranchId = currentBranchMapping.data[0].branchId;
if (branch.id === oldBranchId) {
throw new Error(
`channel "${channelName}" is already pointing at branch "${branchName}". Rollouts must be done with distinct branches.`
);
}
if (percent == null) {
if (nonInteractive) {
throw new Error(
'You must specify a percent with the --percent flag when initiating a rollout with the --non-interactive flag.'
);
}
const promptMessage = `What percent of users should be directed to the branch "${branchName}"?`;
percent = await promptForRolloutPercentAsync({ promptMessage });
}
const newBranchMapping = {
version: 0,
data: [
{
branchId: branch.id,
branchMappingLogic: {
operand: percent / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
},
currentBranchMapping.data[0],
],
};
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: channel.id,
branchMapping: JSON.stringify(newBranchMapping),
});
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
if (!oldBranch) {
throw new Error(
`Branch mapping is missing its only branch for channel "${channelName}" on app "${displayName}"`
);
}
const logMessage = `Started a rollout of branch ${chalk.bold(branchName)} on channel ${chalk.bold(
channelName!
)}! ${chalk.bold(percent)}% of users will be directed to branch ${chalk.bold(
branchName
)}, ${chalk.bold(100 - percent)}% to branch ${chalk.bold(oldBranch.name)}.`;
return { newChannelInfo, logMessage };
}
async function editRolloutAsync(
graphqlClient: ExpoGraphqlClient,
{
channelName,
percent,
nonInteractive,
currentBranchMapping,
channel,
}: {
channelName?: string;
percent?: number;
nonInteractive: boolean;
currentBranchMapping: BranchMapping;
channel: UpdateChannelByNameObject;
}
): Promise<{
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
}> {
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
if (percent == null) {
if (nonInteractive) {
throw new Error(
'A rollout is already in progress. If you wish to modify it you must use specify the new rollout percentage with the --percent flag.'
);
}
const promptMessage = `Currently ${currentPercent}% of all users are routed to branch ${
newBranch.name
} and ${100 - currentPercent}% of all users are routed to branch ${
oldBranch.name
}. What percent of users should be directed to the branch ${newBranch.name}?`;
percent = await promptForRolloutPercentAsync({ promptMessage });
}
const newBranchMapping = { ...currentBranchMapping };
newBranchMapping.data[0].branchMappingLogic.operand = percent / 100;
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: channel.id,
branchMapping: JSON.stringify(newBranchMapping),
});
const logMessage = `Rollout of branch ${chalk.bold(newBranch.name)} on channel ${chalk.bold(
channelName!
)} updated from ${chalk.bold(currentPercent)}% to ${chalk.bold(percent)}%. ${chalk.bold(
percent
)}% of users will be directed to branch ${chalk.bold(newBranch.name)}, ${chalk.bold(
100 - percent
)}% to branch ${chalk.bold(oldBranch.name)}.`;
return { newChannelInfo, logMessage };
}
async function endRolloutAsync(
graphqlClient: ExpoGraphqlClient,
{
channelName,
branchName,
nonInteractive,
projectId,
channel,
}: {
channelName?: string;
branchName?: string;
nonInteractive: boolean;
projectId: string;
channel: UpdateChannelByNameObject;
}
): Promise<{
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
}> {
// end rollout
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
let endOnNewBranch;
if (branchName) {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId: projectId,
name: branchName,
});
switch (branch.id) {
case newBranch.id:
endOnNewBranch = true;
break;
case oldBranch.id:
endOnNewBranch = false;
break;
default:
throw new Error(
`The branch "${branchName}" specified by --branch must be one of the branches involved in the rollout: "${newBranch.name}" or "${oldBranch.name}".`
);
}
} else {
if (nonInteractive) {
throw new Error(
'Branch name must be specified with the --branch flag when both the --end and --non-interactive flag are true.'
);
}
endOnNewBranch = await selectAsync<boolean>(
'Ending the rollout will send all traffic to a single branch. Which one should that be?',
[
{
title: `${newBranch.name} ${chalk.grey(`- current percent: ${currentPercent}%`)}`,
value: true,
},
{
title: `${oldBranch.name} ${chalk.grey(`- current percent: ${100 - currentPercent}%`)}`,
value: false,
},
]
);
}
if (endOnNewBranch == null) {
throw new Error('Branch to end on is undefined.');
}
const newBranchMapping = {
version: 0,
data: [
{
branchId: endOnNewBranch ? newBranch.id : oldBranch.id,
branchMappingLogic: 'true',
},
],
};
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: channel.id,
branchMapping: JSON.stringify(newBranchMapping),
});
const logMessage = `Rollout on channel ${chalk.bold(
channelName
)} ended. All traffic is now sent to branch ${chalk.bold(
endOnNewBranch ? newBranch.name : oldBranch.name
)}`;
return { newChannelInfo, logMessage };
}
export default class ChannelRollout extends EasCommand {
static override description = 'Roll a new branch out on a channel incrementally.';
@@ -56,68 +304,17 @@ export default class ChannelRollout extends EasCommand {
];
static override flags = {
action: Flags.enum({
description: 'Rollout action to perform',
options: Object.values(ActionRawFlagValue),
branch: Flags.string({
description: 'branch to rollout',
required: false,
relationships: [
{
type: 'all',
flags: [
{
name: 'percent',
// eslint-disable-next-line async-protect/async-suffix
when: async flags => {
return (
!!flags['non-interactive'] &&
(flags['action'] === ActionRawFlagValue.CREATE ||
flags['action'] === ActionRawFlagValue.EDIT)
);
},
},
{
name: 'outcome',
// eslint-disable-next-line async-protect/async-suffix
when: async flags =>
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.END,
},
{
name: 'branch',
// eslint-disable-next-line async-protect/async-suffix
when: async flags =>
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.CREATE,
},
{
name: 'runtime-version',
// eslint-disable-next-line async-protect/async-suffix
when: async flags =>
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.CREATE,
},
],
},
],
}),
percent: Flags.integer({
description:
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
description: 'percent of users to send to the new branch',
required: false,
}),
outcome: Flags.enum({
description: 'End outcome of rollout. Use with --action=end',
options: Object.values(EndOutcome),
required: false,
}),
branch: Flags.string({
description: 'Branch to roll out. Use with --action=create',
required: false,
}),
'runtime-version': Flags.string({
description: 'Runtime version to target. Use with --action=create',
required: false,
}),
'private-key-path': Flags.string({
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory.`,
required: false,
end: Flags.boolean({
description: 'end the rollout',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -128,63 +325,164 @@ export default class ChannelRollout extends EasCommand {
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelRollout);
const argsAndFlags = this.sanitizeArgsAndFlags({ ...flags, ...args });
const {
privateProjectConfig: { exp, projectId },
args: { channel: channelNameArg },
flags: {
json: jsonFlag,
end: endFlag,
branch: branchName,
percent,
'non-interactive': nonInteractive,
},
} = await this.parse(ChannelRollout);
const {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelRollout, {
nonInteractive: argsAndFlags.nonInteractive,
nonInteractive,
});
if (argsAndFlags.json) {
// TODO(quin): implement json output
throw new Error('Developer Preview doesnt support JSON output yet');
if (jsonFlag) {
enableJsonOutput();
}
const app = { projectId, exp };
const ctx = {
projectId,
nonInteractive: argsAndFlags.nonInteractive,
graphqlClient,
app,
};
if (argsAndFlags.nonInteractive) {
await new NonInteractiveRollout(argsAndFlags).runAsync(ctx);
} else {
Log.addNewLineIfNone();
Log.warn(
`✨ This command is in Developer Preview and has not been released to production yet. Website support is coming soon.`
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
let channelName: string = channelNameArg;
if (!channelName) {
const { name } = await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel on which to perform a rollout',
paginatedQueryOptions: {
json: jsonFlag,
nonInteractive,
offset: 0,
},
});
channelName = name;
}
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName,
});
if (!channel) {
throw new Error(
`Could not find a channel named "${channelName}". Check which channels exist on this project with ${chalk.bold(
'eas channel:list'
)}.`
);
Log.addNewLineIfNone();
await new RolloutMainMenu(argsAndFlags).runAsync(ctx);
}
}
private getAction(action: ActionRawFlagValue): RolloutActions {
switch (action) {
case ActionRawFlagValue.CREATE:
return MainMenuActions.CREATE_NEW;
case ActionRawFlagValue.EDIT:
return ManageRolloutActions.EDIT;
case ActionRawFlagValue.END:
return ManageRolloutActions.END;
const { branchMapping: currentBranchMapping, isRollout } = getBranchMapping(
channel.branchMapping
);
if (currentBranchMapping.data.length === 0) {
throw new Error('The channel is not pointing at any branches.');
}
if (currentBranchMapping.data.length > 2) {
throw new Error('"channel:rollout" cannot handle branch mappings with more than 2 branches.');
}
// This combination doesn't make sense. Throw an error explaining the options.
if (isRollout && branchName && !endFlag) {
throw new Error(
`There is a rollout in progress. You can only either edit the rollout percent or 'end' it.`
);
}
}
private sanitizeArgsAndFlags(
rawFlags: ChannelRolloutRawArgsAndFlags
): ChannelRolloutArgsAndFlags {
const action = rawFlags.action;
return {
channelName: rawFlags.channel,
percent: rawFlags.percent,
outcome: rawFlags.outcome,
branchNameToRollout: rawFlags.branch,
runtimeVersion: rawFlags['runtime-version'],
privateKeyPath: rawFlags['private-key-path'] ?? null,
action: action ? this.getAction(action) : undefined,
nonInteractive: rawFlags['non-interactive'],
json: rawFlags.json,
/**
* This if/else block has three branches:
* 1. The branch mapping is not a rollout, i.e. it is pointing to a single branch.
* 2. The branch mapping is a rollout.
* a. increase/decrease the rollout percentage.
* b. end the rollout.
*/
let rolloutMutationResult: {
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
};
if (!isRollout) {
rolloutMutationResult = await startRolloutAsync(graphqlClient, {
channelName,
branchName:
branchName ??
(await promptForBranchNameAsync({
graphqlClient,
projectId,
channelName,
nonInteractive,
json: jsonFlag,
})),
percent,
nonInteractive,
projectId,
displayName: projectDisplayName,
currentBranchMapping,
channel,
});
} else if (endFlag) {
rolloutMutationResult = await endRolloutAsync(graphqlClient, {
channelName,
branchName,
nonInteractive,
projectId,
channel,
});
} else {
rolloutMutationResult = await editRolloutAsync(graphqlClient, {
channelName,
percent,
nonInteractive,
currentBranchMapping,
channel,
});
}
if (!rolloutMutationResult) {
throw new Error('rollout result is empty');
}
const { newChannelInfo, logMessage } = rolloutMutationResult;
if (jsonFlag) {
printJsonOnlyOutput(newChannelInfo);
} else {
Log.withTick(logMessage);
}
}
}
async function promptForBranchNameAsync({
graphqlClient,
projectId,
channelName,
nonInteractive,
json,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
channelName: string;
nonInteractive: boolean;
json: boolean;
}): Promise<string> {
if (nonInteractive) {
throw new Error('Must supply branch flag in non-interactive mode');
}
const { name: branchName } = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: `Which branch would you like roll out on ${channelName}?`,
displayTextForListItem: updateBranch => ({
title: updateBranch.name,
}),
// discard limit and offset because this query is not their intended target
paginatedQueryOptions: {
json,
nonInteractive,
offset: 0,
},
});
return branchName;
}
@@ -1,27 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { instance, mock } from 'ts-mockito';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { ensureEASUpdateIsConfiguredAsync } from '../../../update/configure';
describe(ensureEASUpdateIsConfiguredAsync, () => {
it('errors with "useClassicUpdates" set and no app.json', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>({}));
const exp: ExpoConfig = {
name: 'test',
slug: 'test',
updates: { useClassicUpdates: true },
};
await expect(async () => {
await ensureEASUpdateIsConfiguredAsync(graphqlClient, {
exp,
projectId: 'test',
projectDir: '/tmp/test',
platform: null,
});
}).rejects.toThrow(
`Your app config sets "updates.useClassicUpdates" but EAS Update does not support classic updates. Remove "useClassicUpdates" from your app config and run this command again.`
);
});
});
@@ -1,5 +1,5 @@
import { AppJSONConfig, ExpoConfig, PackageJSONConfig, getConfig } from '@expo/config';
import { DirectoryJSON, vol } from 'memfs';
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
import { vol } from 'memfs';
import { instance, mock } from 'ts-mockito';
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
@@ -8,17 +8,12 @@ import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/cr
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { UpdateFragment } from '../../../graphql/generated';
import { CodeSigningInfo, UpdateFragment } from '../../../graphql/generated';
import { PublishMutation } from '../../../graphql/mutations/PublishMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
import { getBranchNameFromChannelNameAsync } from '../../../update/getBranchNameFromChannelNameAsync';
import { selectUpdateGroupOnBranchAsync } from '../../../update/queries';
import {
getCodeSigningInfoAsync,
getManifestBodyAsync,
signBody,
} from '../../../utils/code-signing';
import UpdateRepublish from '../republish';
const projectRoot = '/test-project';
@@ -38,6 +33,12 @@ const updateStub: UpdateFragment = {
createdAt: '2022-01-01T12:00:00Z',
};
const codeSigningStub: CodeSigningInfo = {
keyid: 'keyid',
alg: 'alg',
sig: 'sig',
};
jest.mock('fs');
jest.mock('@expo/config');
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
@@ -51,8 +52,6 @@ jest.mock('../../../ora', () => ({
start: () => ({ succeed: () => {}, fail: () => {} }),
}),
}));
jest.mock('../../../utils/code-signing');
jest.mock('../../../fetch');
describe(UpdateRepublish.name, () => {
afterEach(() => vol.reset());
@@ -157,45 +156,18 @@ describe(UpdateRepublish.name, () => {
});
it('re-creates update using codesigning with --group and --message', async () => {
const flags = [
'--group=1234',
'--message=test-republish',
`--private-key-path=./keys/test-private-key.pem`,
];
const flags = ['--group=1234', '--message=test-republish'];
const codeSigning = {
alg: 'rsa-v1_5-sha256',
alg: 'alg',
keyid: 'keyid',
sig: 'sig',
};
mockTestProject({
extraManifest: {
updates: {
codeSigningCertificate: './keys/test-certificate.pem',
codeSigningMetadata: {
alg: 'rsa-v1_5-sha256',
keyid: 'keyid',
},
},
},
extraVol: {
'./keys/test-private-key.pem': 'testpemprivate',
'./keys/test-certificate.pem': 'testpemcertificate',
},
});
jest.mocked(getCodeSigningInfoAsync).mockResolvedValue({
privateKey: '' as any,
certificate: '' as any,
codeSigningMetadata: { alg: 'rsa-v1_5-sha256', keyid: 'keyid' },
});
jest.mocked(getManifestBodyAsync).mockResolvedValue('test');
jest.mocked(signBody).mockReturnValue('sig');
mockTestProject();
// Mock queries to retrieve the update and code signing info
jest
.mocked(UpdateQuery.viewUpdateGroupAsync)
.mockResolvedValue([{ ...updateStub, codeSigningInfo: codeSigning }]);
.mockResolvedValue([{ ...updateStub, codeSigningInfo: codeSigningStub }]);
// Mock mutations to store the new update
jest.mocked(PublishMutation.publishUpdateGroupAsync).mockResolvedValue([
{
@@ -315,12 +287,8 @@ describe(UpdateRepublish.name, () => {
/** Create a new in-memory project, based on src/commands/project/__tests__/init.test.ts */
function mockTestProject({
configuredProjectId = '1234',
extraManifest,
extraVol,
}: {
configuredProjectId?: string;
extraManifest?: Partial<ExpoConfig>;
extraVol?: DirectoryJSON;
} = {}): void {
const packageJSON: PackageJSONConfig = {
name: 'testing123',
@@ -341,7 +309,6 @@ function mockTestProject({
projectId: configuredProjectId,
},
},
...extraManifest,
},
};
@@ -349,7 +316,6 @@ function mockTestProject({
{
'package.json': JSON.stringify(packageJSON),
'app.json': JSON.stringify(appJSON),
...extraVol,
},
projectRoot
);
@@ -41,7 +41,7 @@ export default class UpdateConfigure extends EasCommand {
});
Log.log(
'💡 The following process will configure your project to use EAS Update. These changes only apply to your local project files and you can safely revert them at any time.'
'💡 The following process will configure your project to run EAS Update. These changes only apply to your local project files and you can safely revert them at any time.'
);
await getVcsClient().ensureRepoExistsAsync();
@@ -56,7 +56,7 @@ export default class UpdateConfigure extends EasCommand {
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
Log.addNewLineIfNone();
Log.log(`🎉 Your app is configured to use EAS Update!`);
Log.log(`🎉 Your app is configured with EAS Update!`);
Log.newLine();
const easJsonExists = await easJsonExistsAsync(projectDir);
if (!easJsonExists) {
+123 -43
View File
@@ -1,19 +1,23 @@
import { Platform } from '@expo/config';
import { Flags } from '@oclif/core';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { Update } from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { getBranchNameFromChannelNameAsync } from '../../update/getBranchNameFromChannelNameAsync';
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
import { UpdateToRepublish, republishAsync } from '../../update/republish';
import { truncateString as truncateUpdateMessage } from '../../update/utils';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const defaultRepublishPlatforms: Platform[] = ['android', 'ios'];
@@ -23,7 +27,6 @@ type UpdateRepublishRawFlags = {
group?: string;
message?: string;
platform: string;
'private-key-path'?: string;
'non-interactive': boolean;
json?: boolean;
};
@@ -34,21 +37,34 @@ type UpdateRepublishFlags = {
groupId?: string;
updateMessage?: string;
platform: Platform[];
privateKeyPath?: string;
nonInteractive: boolean;
json: boolean;
};
type UpdateToRepublish = {
groupId: string;
branchId: string;
branchName: string;
} & Pick<
Update,
| 'message'
| 'runtimeVersion'
| 'manifestFragment'
| 'platform'
| 'gitCommitHash'
| 'codeSigningInfo'
>;
export default class UpdateRepublish extends EasCommand {
static override description = 'roll back to an existing update';
static override flags = {
channel: Flags.string({
description: 'Channel name to select an update group to republish from',
description: 'Channel name to select an update to republish from',
exclusive: ['branch', 'group'],
}),
branch: Flags.string({
description: 'Branch name to select an update group to republish from',
description: 'Branch name to select an update to republish from',
exclusive: ['channel', 'group'],
}),
group: Flags.string({
@@ -57,7 +73,7 @@ export default class UpdateRepublish extends EasCommand {
}),
message: Flags.string({
char: 'm',
description: 'Short message describing the republished update group',
description: 'Short message describing the republished update',
required: false,
}),
platform: Flags.enum({
@@ -66,10 +82,6 @@ export default class UpdateRepublish extends EasCommand {
default: 'all',
required: false,
}),
'private-key-path': Flags.string({
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory.`,
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -93,8 +105,6 @@ export default class UpdateRepublish extends EasCommand {
enableJsonOutput();
}
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
const existingUpdates = await getOrAskUpdatesAsync(graphqlClient, projectId, flags);
const updatesToPublish = existingUpdates.filter(update =>
flags.platform.includes(update.platform as Platform)
@@ -114,39 +124,110 @@ export default class UpdateRepublish extends EasCommand {
}
if (rawFlags.platform === 'all') {
Log.withTick(`The republished update group will appear only on: ${rawFlags.platform}`);
Log.withTick(`The republished update will appear only on: ${rawFlags.platform}`);
} else {
const platformsFromUpdates = updatesToPublish.map(update => update.platform);
if (platformsFromUpdates.length < defaultRepublishPlatforms.length) {
Log.warn(`You are republishing an update group that wasn't published for all platforms.`);
Log.warn(`You are republishing an update that wasn't published for all platforms.`);
}
Log.withTick(
`The republished update group will appear on the same platforms it was originally published on: ${platformsFromUpdates.join(
`The republished update will appear on the same platforms it was originally published on: ${platformsFromUpdates.join(
', '
)}`
);
}
// This command only republishes a single update group
// The update group properties are the same for all updates
const { branchId, branchName, runtimeVersion } = updatesToPublish[0];
const updateMessage = await getOrAskUpdateMessageAsync(updatesToPublish, flags);
const arbitraryUpdate = updatesToPublish[0];
await republishAsync({
graphqlClient,
app: { exp, projectId },
updatesToPublish,
targetBranch: { branchId: arbitraryUpdate.branchId, branchName: arbitraryUpdate.branchName },
updateMessage,
codeSigningInfo,
json: flags.json,
});
// If codesigning was created for the original update, we need to add it to the republish
const shouldRepublishWithCodesigning = updatesToPublish.some(update => update.codeSigningInfo);
if (shouldRepublishWithCodesigning) {
Log.withTick(
`The republished update will be signed with the same codesigning as the original update.`
);
}
const publishIndicator = ora('Republishing...').start();
let updatesRepublished: Awaited<ReturnType<typeof PublishMutation.publishUpdateGroupAsync>>;
try {
updatesRepublished = await PublishMutation.publishUpdateGroupAsync(graphqlClient, [
{
branchId,
runtimeVersion,
message: updateMessage,
updateInfoGroup: Object.fromEntries(
updatesToPublish.map(update => [update.platform, JSON.parse(update.manifestFragment)])
),
gitCommitHash: updatesToPublish[0].gitCommitHash,
awaitingCodeSigningInfo: shouldRepublishWithCodesigning,
},
]);
if (shouldRepublishWithCodesigning) {
const codeSigningByPlatform = Object.fromEntries(
updatesToPublish.map(update => [update.platform, update.codeSigningInfo])
);
await Promise.all(
updatesRepublished.map(async update => {
const codeSigning = codeSigningByPlatform[update.platform];
if (codeSigning) {
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, update.id, codeSigning);
}
})
);
}
publishIndicator.succeed('Republished update');
} catch (error: any) {
publishIndicator.fail('Failed to republish update');
throw error;
}
if (flags.json) {
return printJsonOnlyOutput(updatesRepublished);
}
const updatesRepublishedByPlatform = Object.fromEntries(
updatesRepublished.map(update => [update.platform, update])
);
const updateGroupUrl = getUpdateGroupUrl(
(await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name,
exp.slug,
updatesRepublished[0].group
);
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: updatesRepublished[0].runtimeVersion },
{ label: 'Platform', value: updatesRepublished.map(update => update.platform).join(', ') },
{ label: 'Update Group ID', value: updatesRepublished[0].id },
...(updatesRepublishedByPlatform.android
? [{ label: 'Android update ID', value: updatesRepublishedByPlatform.android.id }]
: []),
...(updatesRepublishedByPlatform.ios
? [{ label: 'iOS update ID', value: updatesRepublishedByPlatform.ios.id }]
: []),
{ label: 'Message', value: updateMessage },
{ label: 'Website link', value: link(updateGroupUrl, { dim: false }) },
])
);
}
private sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
const branchName = rawFlags.branch;
const channelName = rawFlags.channel;
const groupId = rawFlags.group;
const nonInteractive = rawFlags['non-interactive'];
const privateKeyPath = rawFlags['private-key-path'];
if (nonInteractive && !groupId) {
throw new Error('Only --group can be used in non-interactive mode');
@@ -164,7 +245,6 @@ export default class UpdateRepublish extends EasCommand {
groupId,
platform,
updateMessage: rawFlags.message,
privateKeyPath,
json: rawFlags.json ?? false,
nonInteractive,
};
@@ -178,15 +258,15 @@ async function getOrAskUpdatesAsync(
flags: UpdateRepublishFlags
): Promise<UpdateToRepublish[]> {
if (flags.groupId) {
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
const updateGroups = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
groupId: flags.groupId,
});
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
return updateGroups.map(group => ({
...group,
groupId: group.group,
branchId: group.branch.id,
branchName: group.branch.name,
}));
}
@@ -223,17 +303,17 @@ async function askUpdatesFromBranchNameAsync(
throw new Error('Must supply --group when in non-interactive mode');
}
const updateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
const updateGroups = await selectUpdateGroupOnBranchAsync(graphqlClient, {
projectId,
branchName,
paginatedQueryOptions: getPaginatedQueryOptions({ json, 'non-interactive': nonInteractive }),
});
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
return updateGroups.map(group => ({
...group,
groupId: group.id,
branchId: group.branch.id,
branchName: group.branch.name,
}));
}
/** Ask the user which update needs to be republished by channel name, this requires interactive mode */
@@ -5,10 +5,10 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AndroidAppBuildCredentials,
AndroidAppBuildCredentialsFragment,
AndroidAppBuildCredentialsInput,
CreateAndroidAppBuildCredentialsMutation,
SetDefaultAndroidAppBuildCredentialsMutation,
SetKeystoreMutation,
} from '../../../../../graphql/generated';
import { AndroidAppBuildCredentialsFragmentNode } from '../../../../../graphql/types/credentials/AndroidAppBuildCredentials';
@@ -62,9 +62,9 @@ export const AndroidAppBuildCredentialsMutation = {
): Promise<AndroidAppBuildCredentialsFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<SetDefaultAndroidAppBuildCredentialsMutation>(
.mutation<AndroidAppBuildCredentials>(
gql`
mutation SetDefaultAndroidAppBuildCredentialsMutation(
mutation AndroidAppBuildCredentialsMutation(
$androidAppBuildCredentialsId: ID!
$isDefault: Boolean!
) {
@@ -85,7 +85,7 @@ export const AndroidAppBuildCredentialsMutation = {
.toPromise()
);
assert(data, `GraphQL: 'setDefault' not defined in server response ${JSON.stringify(data)}}`);
return data.androidAppBuildCredentials.setDefault;
return data;
},
async setKeystoreAsync(
graphqlClient: ExpoGraphqlClient,
@@ -1 +0,0 @@
export const runCurrentMachineMethodAsync = jest.fn();
@@ -5,7 +5,6 @@ import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils
import AppStoreApi from '../../../../credentials/ios/appstore/AppStoreApi';
import { AccountFragment, Role } from '../../../../graphql/generated';
import DeviceCreateAction, { RegistrationMethod } from '../action';
import { runCurrentMachineMethodAsync } from '../currentMachineMethod';
import { runDeveloperPortalMethodAsync } from '../developerPortalMethod';
import { runInputMethodAsync } from '../inputMethod';
import { runRegistrationUrlMethodAsync } from '../registrationUrlMethod';
@@ -14,7 +13,6 @@ jest.mock('prompts');
jest.mock('../developerPortalMethod');
jest.mock('../inputMethod');
jest.mock('../registrationUrlMethod');
jest.mock('../currentMachineMethod');
beforeEach(() => {
const promptsMock = jest.mocked(prompts);
@@ -24,7 +22,6 @@ beforeEach(() => {
});
jest.mocked(runRegistrationUrlMethodAsync).mockClear();
jest.mocked(runInputMethodAsync).mockClear();
jest.mocked(runCurrentMachineMethodAsync).mockClear();
});
describe(DeviceCreateAction, () => {
@@ -121,36 +118,5 @@ describe(DeviceCreateAction, () => {
expect(runDeveloperPortalMethodAsync).toBeCalled();
});
it('calls runCurrentMachineMethodAsync if user chooses the current machine method', async () => {
jest.mocked(prompts).mockImplementationOnce(async () => ({
method: RegistrationMethod.CURRENT_MACHINE,
}));
const appStoreApiMock = mock<AppStoreApi>();
const appStoreApi = instance(appStoreApiMock);
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const account: AccountFragment = {
id: 'account_id',
name: 'foobar',
users: [
{
role: Role.Owner,
actor: {
id: 'user_id',
},
},
],
};
const appleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123Y',
appleTeamName: 'John Doe (Individual)',
};
const action = new DeviceCreateAction(graphqlClient, appStoreApi, account, appleTeam);
await action.runAsync();
expect(runCurrentMachineMethodAsync).toBeCalled();
});
});
});
@@ -1,183 +0,0 @@
import os from 'os';
import prompts from 'prompts';
import { instance, mock } from 'ts-mockito';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppleDeviceMutation } from '../../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import { AppleTeam } from '../../../../graphql/generated';
import { DeviceCreateError } from '../../../utils/errors';
import { runCurrentMachineMethodAsync } from '../currentMachineMethod';
jest.mock('../../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation');
jest.mock('../../../../ora');
jest.mock('os', () => {
return {
...jest.requireActual('os'),
cpus: jest.fn(),
};
});
jest.mock('@expo/spawn-async', () => {
return {
__esModule: true,
...jest.requireActual('@expo/spawn-async'),
default: jest.fn(async () => {
return {
stdout: JSON.stringify({
SPHardwareDataType: [
{
provisioning_UDID: 'fake_udid',
machine_name: 'fake_machine_name',
},
],
}),
};
}),
};
});
let actualPlatform: string;
beforeAll(() => {
actualPlatform = process.platform;
});
beforeEach(() => {
jest.mocked(prompts).mockReset();
jest.mocked(prompts).mockImplementation(() => {
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
});
jest.mocked(AppleDeviceMutation.createAppleDeviceAsync).mockClear();
});
afterAll(() => {
Object.defineProperty(process, 'platform', { value: actualPlatform });
});
describe(runCurrentMachineMethodAsync, () => {
it('allows registering the default Mac device class', async () => {
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockDeviceData('my Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const accountId = 'account-id';
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
const appleTeam: AppleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123XY',
appleTeamName: 'John Doe (Individual)',
};
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(1);
});
it('proceeds with registration if user approves, only once', async () => {
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockDeviceData('my Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
mockDeviceData('my other Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const accountId = 'account-id';
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
const appleTeam: AppleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123XY',
appleTeamName: 'John Doe (Individual)',
};
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(1);
});
it('exits registration if user cancels', async () => {
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockDeviceData('my Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
mockDeviceData('my other Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const accountId = 'account-id';
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
const appleTeam: AppleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123XY',
appleTeamName: 'John Doe (Individual)',
};
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
});
it('exits registration if current Mac machine is not of Apple Silicon type', async () => {
jest
.mocked(os.cpus)
.mockImplementation(() => [{ model: 'Intel(R) Core(TM) i5' } as os.CpuInfo]);
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockDeviceData('my old Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
mockDeviceData('my other Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const accountId = 'account-id';
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
const appleTeam: AppleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123XY',
appleTeamName: 'John Doe (Individual)',
};
await expect(
runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam)
).rejects.toThrowError(
new DeviceCreateError(
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
)
);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
});
it('exits registration if current machine is not Mac', async () => {
jest
.mocked(os.cpus)
.mockImplementation(() => [{ model: 'Intel(R) Core(TM) i5' } as os.CpuInfo]);
Object.defineProperty(process, 'platform', { value: 'win32' });
mockDeviceData('my Windows PC');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
mockDeviceData('my other Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const accountId = 'account-id';
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
const appleTeam: AppleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123XY',
appleTeamName: 'John Doe (Individual)',
};
await expect(
runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam)
).rejects.toThrowError(
new DeviceCreateError(
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
)
);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
});
});
function mockDeviceData(name: string): void {
jest.mocked(prompts).mockImplementationOnce(async () => ({ name }));
}
@@ -21,8 +21,6 @@ describe(runInputMethodAsync, () => {
it('should allow for multiple device registration', async () => {
mockDeviceData('00001111-001122334455662E', 'my iPhone', AppleDeviceClass.Iphone);
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
mockDeviceData('00001111-001122334455662F', 'my Mac', AppleDeviceClass.Mac);
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
mockDeviceData('b12cba9856d89c932ab7a4b813c4d932534e1679', 'my iPad', AppleDeviceClass.Ipad);
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
@@ -37,7 +35,7 @@ describe(runInputMethodAsync, () => {
await runInputMethodAsync(graphqlClient, accountId, appleTeam);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(3);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(2);
});
});
@@ -5,7 +5,6 @@ import AppStoreApi from '../../../credentials/ios/appstore/AppStoreApi';
import { AccountFragment, AppleTeam } from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { runCurrentMachineMethodAsync } from './currentMachineMethod';
import { runDeveloperPortalMethodAsync } from './developerPortalMethod';
import { runInputMethodAsync } from './inputMethod';
import { runRegistrationUrlMethodAsync } from './registrationUrlMethod';
@@ -14,7 +13,6 @@ export enum RegistrationMethod {
WEBSITE,
INPUT,
DEVELOPER_PORTAL,
CURRENT_MACHINE,
EXIT,
}
@@ -39,8 +37,6 @@ export default class DeviceCreateAction {
);
} else if (method === RegistrationMethod.INPUT) {
await runInputMethodAsync(this.graphqlClient, this.account.id, this.appleTeam);
} else if (method === RegistrationMethod.CURRENT_MACHINE) {
await runCurrentMachineMethodAsync(this.graphqlClient, this.account.id, this.appleTeam);
} else if (method === RegistrationMethod.EXIT) {
Log.log('Bye!');
process.exit(0);
@@ -70,12 +66,6 @@ export default class DeviceCreateAction {
title: `${chalk.bold('Input')} - allows you to type in UDIDs (advanced option)`,
value: RegistrationMethod.INPUT,
},
{
title: `${chalk.bold(
'Current Machine'
)} - automatically sets the provisioning UDID of the current Apple Silicon machine`,
value: RegistrationMethod.CURRENT_MACHINE,
},
{ title: chalk.bold('Exit'), value: RegistrationMethod.EXIT },
],
validate: (val: RegistrationMethod) => Object.values(RegistrationMethod).includes(val),
@@ -1,127 +0,0 @@
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import os from 'os';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppleDeviceMutation } from '../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
import Log from '../../../log';
import { ora } from '../../../ora';
import { confirmAsync } from '../../../prompts';
import { DeviceCreateError } from '../../utils/errors';
import { DeviceData, printDeviceData, promptForNameAsync } from './utils';
export async function runCurrentMachineMethodAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string,
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>
): Promise<void> {
Log.newLine();
Log.log(chalk.white('Checking if current machine is an Apple Silicon one.'));
if (!isMachineAppleSilicon()) {
throw new DeviceCreateError(
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
);
}
Log.log(chalk.green('Check successful.'));
await collectDataAndRegisterDeviceAsync(graphqlClient, { accountId, appleTeam });
}
function isMachineAppleSilicon(): boolean {
return os.cpus()[0].model.includes('Apple M') && process.platform === 'darwin';
}
async function collectDataAndRegisterDeviceAsync(
graphqlClient: ExpoGraphqlClient,
{
accountId,
appleTeam,
}: {
accountId: string;
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>;
}
): Promise<void> {
const deviceData = await collectDeviceDataAsync(appleTeam);
if (!deviceData) {
return;
}
const { udid, deviceClass, name } = deviceData;
const spinner = ora(`Registering Apple device on Expo`).start();
try {
await AppleDeviceMutation.createAppleDeviceAsync(
graphqlClient,
{
appleTeamId: appleTeam.id,
identifier: udid,
name,
deviceClass: deviceClass ?? undefined,
},
accountId
);
} catch (err) {
spinner.fail();
throw err;
}
spinner.succeed();
}
async function collectDeviceDataAsync(
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName'>,
initialValues: Partial<DeviceData> = {}
): Promise<DeviceData | null> {
Log.log(chalk.white('Fetching the provisioning UDID.'));
const [udid, defaultMachineName] = await fetchCurrentMachineUDIDAsync();
Log.log(chalk.green(`Fetched the provisioning UDID - ${udid}`));
const name = await promptForNameAsync(defaultMachineName ?? initialValues.name);
const deviceClass = AppleDeviceClass.Mac;
const deviceData: DeviceData = {
udid,
name,
deviceClass,
};
printDeviceData(deviceData, appleTeam);
const registrationConfirmed = await confirmAsync({
message: 'Is this what you want to register?',
});
if (!registrationConfirmed) {
Log.log('No worries, just try again.');
Log.newLine();
return null;
} else {
return deviceData;
}
}
async function fetchCurrentMachineUDIDAsync(): Promise<[string, string?]> {
try {
const profilerData = (
await spawnAsync('system_profiler', ['-json', 'SPHardwareDataType'])
).stdout.trim();
if (!profilerData) {
const message = 'Failed to fetch the provisioning UDID from system profiler';
Log.error(message);
throw new DeviceCreateError(message);
}
const profilerDataJSON = JSON.parse(profilerData);
const provisioningUDID = profilerDataJSON?.SPHardwareDataType[0]?.provisioning_UDID;
if (!provisioningUDID) {
const message = 'System profiler data did not contain the provisioning UDID';
Log.error(message);
throw new DeviceCreateError(message);
}
const defaultMachineName = profilerDataJSON?.SPHardwareDataType[0]?.machine_name;
return [provisioningUDID, defaultMachineName];
} catch (err: any) {
if (!(err instanceof DeviceCreateError)) {
const message = `Failed to fetch the provisioning UDID of the current machine - ${err.message}`;
Log.error(message);
throw new DeviceCreateError(message);
}
throw err;
}
}
@@ -19,7 +19,6 @@ const DEVICE_IMPORT_CHUNK_SIZE = 10;
const DEVICE_CLASS_TO_GRAPHQL_TYPE: Partial<Record<DeviceClass, AppleDeviceClass>> = {
[DeviceClass.IPAD]: AppleDeviceClass.Ipad,
[DeviceClass.IPHONE]: AppleDeviceClass.Iphone,
[DeviceClass.MAC]: AppleDeviceClass.Mac,
};
export async function runDeveloperPortalMethodAsync(
@@ -106,7 +105,7 @@ async function findUnregisteredPortalDevicesAsync(
return portalDevices.filter(
portalDevice =>
!(portalDevice.attributes.udid in expoRegisteredDevicesByUdid) &&
[DeviceClass.IPAD, DeviceClass.IPHONE, DeviceClass.APPLE_TV, DeviceClass.MAC].includes(
[DeviceClass.IPAD, DeviceClass.IPHONE, DeviceClass.APPLE_TV].includes(
portalDevice.attributes.deviceClass
)
);
@@ -2,17 +2,18 @@ import chalk from 'chalk';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppleDeviceMutation } from '../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import { AppleTeam } from '../../../graphql/generated';
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
import Log from '../../../log';
import { ora } from '../../../ora';
import { confirmAsync } from '../../../prompts';
import {
DeviceData,
printDeviceData,
promptForDeviceClassAsync,
promptForNameAsync,
promptForUDIDAsync,
} from './utils';
import { confirmAsync, promptAsync } from '../../../prompts';
import { isValidUDID, normalizeUDID } from '../../udids';
import { formatNewDevice } from '../../utils/formatDevice';
interface DeviceData {
udid: string;
name?: string;
deviceClass: AppleDeviceClass | null;
}
export async function runInputMethodAsync(
graphqlClient: ExpoGraphqlClient,
@@ -77,7 +78,16 @@ async function collectDeviceDataAsync(
deviceClass,
};
printDeviceData(deviceData, appleTeam);
Log.newLine();
Log.log(
`We are going to register the following device in our database.
This device will ${chalk.bold(
'not'
)} be registered on the Apple Developer Portal until it is chosen for an internal distribution build.`
);
Log.newLine();
Log.log(formatNewDevice({ ...deviceData, identifier: deviceData.udid }, appleTeam));
Log.newLine();
const registrationConfirmed = await confirmAsync({
message: 'Is this what you want to register?',
@@ -90,3 +100,56 @@ async function collectDeviceDataAsync(
return deviceData;
}
}
export async function promptForUDIDAsync(initial?: string): Promise<string> {
const { udid } = await promptAsync({
type: 'text',
name: 'udid',
message: 'UDID:',
initial,
validate: (rawVal: string) => {
const val = normalizeUDID(rawVal);
if (!val || val === '') {
return 'UDID cannot be empty';
} else if (val.length !== 25 && val.length !== 40) {
return 'UDID should be a 25 or 40-character string';
} else if (!isValidUDID(val)) {
return 'UDID is invalid';
} else {
return true;
}
},
format: (val: string) => normalizeUDID(val),
});
return udid;
}
async function promptForNameAsync(initial?: string): Promise<string | undefined> {
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Device name (optional):',
initial,
});
return name;
}
async function promptForDeviceClassAsync(
initial?: AppleDeviceClass | null
): Promise<AppleDeviceClass | null> {
const choices = [
{ title: 'iPhone', value: AppleDeviceClass.Iphone },
{ title: 'iPad', value: AppleDeviceClass.Ipad },
{ title: 'Not sure (leave empty)', value: null },
];
const values = choices.map(({ value }) => value);
const { deviceClass } = await promptAsync({
type: 'select',
name: 'deviceClass',
message: 'Device class (optional):',
choices,
initial: initial !== undefined && values.indexOf(initial),
});
return deviceClass;
}
@@ -1,83 +0,0 @@
import chalk from 'chalk';
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { isValidUDID, normalizeUDID } from '../../udids';
import { formatNewDevice } from '../../utils/formatDevice';
export interface DeviceData {
udid: string;
name?: string;
deviceClass: AppleDeviceClass | null;
}
export async function promptForDeviceClassAsync(
initial?: AppleDeviceClass | null
): Promise<AppleDeviceClass | null> {
const choices = [
{ title: 'iPhone', value: AppleDeviceClass.Iphone },
{ title: 'iPad', value: AppleDeviceClass.Ipad },
{ title: 'Mac', value: AppleDeviceClass.Mac },
{ title: 'Not sure (leave empty)', value: null },
];
const values = choices.map(({ value }) => value);
const { deviceClass } = await promptAsync({
type: 'select',
name: 'deviceClass',
message: 'Device class (optional):',
choices,
initial: initial !== undefined && values.indexOf(initial),
});
return deviceClass;
}
export async function promptForNameAsync(initial?: string): Promise<string | undefined> {
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Device name (optional):',
initial,
});
return name;
}
export async function promptForUDIDAsync(initial?: string): Promise<string> {
const { udid } = await promptAsync({
type: 'text',
name: 'udid',
message: 'UDID:',
initial,
validate: (rawVal: string) => {
const val = normalizeUDID(rawVal);
if (!val || val === '') {
return 'UDID cannot be empty';
} else if (val.length !== 25 && val.length !== 40) {
return 'UDID should be a 25 or 40-character string';
} else if (!isValidUDID(val)) {
return 'UDID is invalid';
} else {
return true;
}
},
format: (val: string) => normalizeUDID(val),
});
return udid;
}
export function printDeviceData(
deviceData: DeviceData,
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName'>
): void {
Log.newLine();
Log.log(
`We are going to register the following device in our database.
This device will ${chalk.bold(
'not'
)} be registered on the Apple Developer Portal until it is chosen for an internal distribution build.`
);
Log.newLine();
Log.log(formatNewDevice({ ...deviceData, identifier: deviceData.udid }, appleTeam));
Log.newLine();
}
+3 -3
View File
@@ -12,10 +12,10 @@ import { Actor } from '../user/User';
import DeviceCreateAction from './actions/create/action';
import { DeviceManagerContext } from './context';
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones, iPads and Macs) for internal distribution of your app.
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones and iPads) for internal distribution of your app.
Internal distribution means that you won't need to upload your app archive to App Store / Testflight.
Your app archive (.ipa) will be installable on your equipment as long as you sign your application with an adhoc provisioning profile.
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones, iPads and Macs.
Your app archive (.ipa) will be installable on your equipment as long as you sign your application with an adhoc provisiong profile.
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones and iPads.
First of all, choose the Expo account under which you want to register your devices.
Later, authenticate with Apple and choose your desired Apple Team (if your Apple ID has access to multiple teams).`;
@@ -3,9 +3,3 @@ export class DeviceNotFoundError extends Error {
super(message ?? 'Device not found.');
}
}
export class DeviceCreateError extends Error {
constructor(message?: string) {
super(message ?? 'Failed to create a device.');
}
}
@@ -9,7 +9,6 @@ export type AppleTeamIdAndName = Pick<AppleTeam, 'appleTeamIdentifier' | 'appleT
const DEVICE_CLASS_DISPLAY_NAMES: Record<AppleDeviceClass, string> = {
[AppleDeviceClass.Iphone]: 'iPhone',
[AppleDeviceClass.Ipad]: 'iPad',
[AppleDeviceClass.Mac]: 'Mac',
};
function formatDeviceClass(device: Device | NewDevice): string {
@@ -1,10 +0,0 @@
import { testAppJson } from '../../credentials/__tests__/fixtures-constants';
import { EASUpdateContext } from '../utils';
export function createCtxMock(options: { nonInteractive?: boolean } = {}): EASUpdateContext {
return {
graphqlClient: jest.fn() as any,
nonInteractive: options.nonInteractive ?? false,
app: { exp: testAppJson, projectId: 'test-project-id' },
};
}
-15
View File
@@ -1,15 +0,0 @@
import { ExpoConfig } from '@expo/config-types';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
export type EASUpdateContext = {
graphqlClient: ExpoGraphqlClient;
nonInteractive: boolean;
app: { exp: ExpoConfig; projectId: string };
};
export interface EASUpdateAction<T = any> {
runAsync(ctx: EASUpdateContext): Promise<T>;
}
export class NonInteractiveError extends Error {}
+1 -10
View File
@@ -6,16 +6,7 @@ export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult
const { data, error } = await promise;
if (error) {
if (
error.graphQLErrors.some(
e =>
e?.extensions?.isTransient &&
![
'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED',
'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED',
].includes(e?.extensions?.errorCode as string)
)
) {
if (error.graphQLErrors.some(e => e?.extensions?.isTransient)) {
Log.error(`We've encountered a transient error. Try again shortly.`);
}
throw error;
+51 -295
View File
@@ -112,8 +112,6 @@ export type Account = {
googleServiceAccountKeys: Array<GoogleServiceAccountKey>;
id: Scalars['ID'];
isCurrent: Scalars['Boolean'];
/** Whether this account has SSO enabled. Can be queried by all members. */
isSSOEnabled: Scalars['Boolean'];
name: Scalars['String'];
/** Offers set on this account */
offers?: Maybe<Array<Offer>>;
@@ -468,14 +466,20 @@ export type AccountQueryByNameArgs = {
/** Auth configuration data for an SSO account. */
export type AccountSsoConfiguration = {
__typename?: 'AccountSSOConfiguration';
authEndpoint?: Maybe<Scalars['String']>;
authProtocol: AuthProtocolType;
authProviderIdentifier: Scalars['String'];
clientIdentifier: Scalars['String'];
clientSecret: Scalars['String'];
createdAt: Scalars['DateTime'];
endSessionEndpoint?: Maybe<Scalars['String']>;
id: Scalars['ID'];
issuer: Scalars['String'];
jwksEndpoint?: Maybe<Scalars['String']>;
revokeEndpoint?: Maybe<Scalars['String']>;
tokenEndpoint?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
userInfoEndpoint?: Maybe<Scalars['String']>;
};
export type AccountSsoConfigurationData = {
@@ -522,11 +526,17 @@ export type AccountSsoConfigurationMutationUpdateAccountSsoConfigurationArgs = {
/** Public auth configuration data for an SSO account. */
export type AccountSsoConfigurationPublicData = {
__typename?: 'AccountSSOConfigurationPublicData';
authEndpoint?: Maybe<Scalars['String']>;
authProtocol: AuthProtocolType;
authProviderIdentifier: Scalars['String'];
authorizationUrl: Scalars['String'];
clientIdentifier: Scalars['String'];
endSessionEndpoint?: Maybe<Scalars['String']>;
id: Scalars['ID'];
issuer: Scalars['String'];
jwksEndpoint?: Maybe<Scalars['String']>;
revokeEndpoint?: Maybe<Scalars['String']>;
tokenEndpoint?: Maybe<Scalars['String']>;
userInfoEndpoint?: Maybe<Scalars['String']>;
};
export type AccountSsoConfigurationPublicDataQuery = {
@@ -962,7 +972,6 @@ export type AndroidSubmissionConfig = {
/** @deprecated archiveType is deprecated and will be null */
archiveType?: Maybe<SubmissionAndroidArchiveType>;
releaseStatus?: Maybe<SubmissionAndroidReleaseStatus>;
rollout?: Maybe<Scalars['Float']>;
track: SubmissionAndroidTrack;
};
@@ -973,7 +982,6 @@ export type AndroidSubmissionConfigInput = {
googleServiceAccountKeyId?: InputMaybe<Scalars['String']>;
googleServiceAccountKeyJson?: InputMaybe<Scalars['String']>;
releaseStatus?: InputMaybe<SubmissionAndroidReleaseStatus>;
rollout?: InputMaybe<Scalars['Float']>;
track: SubmissionAndroidTrack;
};
@@ -1009,7 +1017,6 @@ export type App = Project & {
/** Environment secrets for an app */
environmentSecrets: Array<EnvironmentSecret>;
fullName: Scalars['String'];
githubBuildTriggers: Array<GitHubBuildTrigger>;
githubRepository?: Maybe<GitHubRepository>;
githubRepositorySettings?: Maybe<GitHubRepositorySettings>;
/** githubUrl field from most recent classic update manifest */
@@ -1366,19 +1373,16 @@ export type AppInsights = {
export type AppInsightsTotalUniqueUsersArgs = {
timespan: InsightsTimespan;
useDeprecatedBackend?: InputMaybe<Scalars['Boolean']>;
};
export type AppInsightsUniqueUsersByAppVersionOverTimeArgs = {
timespan: InsightsTimespan;
useDeprecatedBackend?: InputMaybe<Scalars['Boolean']>;
};
export type AppInsightsUniqueUsersByPlatformOverTimeArgs = {
timespan: InsightsTimespan;
useDeprecatedBackend?: InputMaybe<Scalars['Boolean']>;
};
export type AppMutation = {
@@ -1657,8 +1661,7 @@ export type AppleDevice = {
export enum AppleDeviceClass {
Ipad = 'IPAD',
Iphone = 'IPHONE',
Mac = 'MAC'
Iphone = 'IPHONE'
}
export type AppleDeviceInput = {
@@ -2006,8 +2009,7 @@ export type BackgroundJobReceiptQueryByIdArgs = {
};
export enum BackgroundJobResultType {
GithubBuild = 'GITHUB_BUILD',
Void = 'VOID'
GithubBuild = 'GITHUB_BUILD'
}
export enum BackgroundJobState {
@@ -2070,7 +2072,6 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
initiatingUser?: Maybe<User>;
iosEnterpriseProvisioning?: Maybe<BuildIosEnterpriseProvisioning>;
isGitWorkingTreeDirty?: Maybe<Scalars['Boolean']>;
isWaived: Scalars['Boolean'];
logFiles: Array<Scalars['String']>;
maxBuildTimeSeconds: Scalars['Int'];
/** Retry time starts after completedAt */
@@ -2241,17 +2242,6 @@ export enum BuildJobStatus {
Started = 'STARTED'
}
export type BuildLimitThresholdExceededMetadata = {
__typename?: 'BuildLimitThresholdExceededMetadata';
account: Account;
thresholdsExceeded: Array<NotificationThresholdExceeded>;
};
export enum BuildLimitThresholdExceededMetadataType {
Ios = 'IOS',
Total = 'TOTAL'
}
export type BuildLogs = {
__typename?: 'BuildLogs';
format?: Maybe<BuildJobLogsFormat>;
@@ -2397,14 +2387,6 @@ export type BuildParamsInput = {
sdkVersion?: InputMaybe<Scalars['String']>;
};
export type BuildPlanCreditThresholdExceededMetadata = {
__typename?: 'BuildPlanCreditThresholdExceededMetadata';
account: Account;
buildCreditUsage: Scalars['Int'];
planLimit: Scalars['Int'];
threshold: Scalars['Int'];
};
export enum BuildPriority {
High = 'HIGH',
Normal = 'NORMAL',
@@ -2610,19 +2592,6 @@ export type CreateGitHubAppInstallationInput = {
installationIdentifier: Scalars['Int'];
};
export type CreateGitHubBuildTriggerInput = {
appId: Scalars['ID'];
autoSubmit: Scalars['Boolean'];
buildProfile: Scalars['String'];
isActive: Scalars['Boolean'];
platform: AppPlatform;
/** A branch or tag name, or a wildcard pattern where the code change originates from. For example, `main` or `release/*`. */
sourcePattern: Scalars['String'];
/** A branch name or a wildcard pattern that the pull request targets. For example, `main` or `release/*`. */
targetPattern?: InputMaybe<Scalars['String']>;
type: GitHubBuildTriggerType;
};
export type CreateGitHubRepositoryInput = {
appId: Scalars['ID'];
githubAppInstallationId: Scalars['ID'];
@@ -2735,11 +2704,6 @@ export type DeleteRobotResult = {
id: Scalars['ID'];
};
export type DeleteSsoUserResult = {
__typename?: 'DeleteSSOUserResult';
id: Scalars['ID'];
};
export type DeleteUpdateBranchResult = {
__typename?: 'DeleteUpdateBranchResult';
id: Scalars['ID'];
@@ -3128,54 +3092,6 @@ export type GitHubBuildInput = {
platform: AppPlatform;
};
export type GitHubBuildTrigger = {
__typename?: 'GitHubBuildTrigger';
app: App;
autoSubmit: Scalars['Boolean'];
buildProfile: Scalars['String'];
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
isActive: Scalars['Boolean'];
lastRunAt?: Maybe<Scalars['DateTime']>;
platform: AppPlatform;
sourcePattern: Scalars['String'];
targetPattern?: Maybe<Scalars['String']>;
type: GitHubBuildTriggerType;
updatedAt: Scalars['DateTime'];
};
export type GitHubBuildTriggerMutation = {
__typename?: 'GitHubBuildTriggerMutation';
/** Create GitHub build trigger for an App */
createGitHubBuildTrigger: GitHubBuildTrigger;
/** Delete GitHub build trigger by ID */
deleteGitHubBuildTrigger: GitHubBuildTrigger;
/** Update a GitHub build trigger by ID */
updateGitHubBuildTrigger: GitHubBuildTrigger;
};
export type GitHubBuildTriggerMutationCreateGitHubBuildTriggerArgs = {
githubBuildTriggerData: CreateGitHubBuildTriggerInput;
};
export type GitHubBuildTriggerMutationDeleteGitHubBuildTriggerArgs = {
githubBuildTriggerId: Scalars['ID'];
};
export type GitHubBuildTriggerMutationUpdateGitHubBuildTriggerArgs = {
githubBuildTriggerData: UpdateGitHubBuildTriggerInput;
githubBuildTriggerId: Scalars['ID'];
};
export enum GitHubBuildTriggerType {
PullRequestUpdated = 'PULL_REQUEST_UPDATED',
PushToBranch = 'PUSH_TO_BRANCH',
TagUpdated = 'TAG_UPDATED'
}
export type GitHubRepository = {
__typename?: 'GitHubRepository';
app: App;
@@ -3183,7 +3099,7 @@ export type GitHubRepository = {
githubRepositoryIdentifier: Scalars['Int'];
githubRepositoryUrl?: Maybe<Scalars['String']>;
id: Scalars['ID'];
metadata: GitHubRepositoryMetadata;
metadata?: Maybe<GitHubRepositoryMetadata>;
nodeIdentifier: Scalars['String'];
};
@@ -3196,6 +3112,7 @@ export type GitHubRepositoryMetadata = {
githubRepoUrl: Scalars['String'];
lastPushed: Scalars['DateTime'];
lastUpdated: Scalars['DateTime'];
openGraphImageUrl?: Maybe<Scalars['String']>;
private: Scalars['Boolean'];
};
@@ -3711,8 +3628,6 @@ export type MeMutation = {
createAccount: Account;
/** Delete an Account created via createAccount */
deleteAccount: DeleteAccountResult;
/** Delete a SSO user. Actor must be an owner on the SSO user's SSO account. */
deleteSSOUser: DeleteSsoUserResult;
/** Delete a second factor device */
deleteSecondFactorDevice: SecondFactorBooleanResult;
/** Delete a Snack that the current user owns */
@@ -3765,11 +3680,6 @@ export type MeMutationDeleteAccountArgs = {
};
export type MeMutationDeleteSsoUserArgs = {
ssoUserId: Scalars['ID'];
};
export type MeMutationDeleteSecondFactorDeviceArgs = {
otp: Scalars['String'];
userSecondFactorDeviceId: Scalars['ID'];
@@ -3843,27 +3753,12 @@ export type MeteredBillingStatus = {
EAS_UPDATE: Scalars['Boolean'];
};
export type Notification = {
__typename?: 'Notification';
createdAt: Scalars['DateTime'];
event: NotificationEvent;
id: Scalars['ID'];
isRead: Scalars['Boolean'];
metadata?: Maybe<NotificationMetadata>;
type: NotificationType;
updatedAt: Scalars['DateTime'];
};
export enum NotificationEvent {
BuildComplete = 'BUILD_COMPLETE',
BuildLimitThresholdExceeded = 'BUILD_LIMIT_THRESHOLD_EXCEEDED',
BuildPlanCreditThresholdExceeded = 'BUILD_PLAN_CREDIT_THRESHOLD_EXCEEDED',
SubmissionComplete = 'SUBMISSION_COMPLETE',
Test = 'TEST'
SubmissionComplete = 'SUBMISSION_COMPLETE'
}
export type NotificationMetadata = BuildLimitThresholdExceededMetadata | BuildPlanCreditThresholdExceededMetadata | TestNotificationMetadata;
export type NotificationSubscription = {
__typename?: 'NotificationSubscription';
account?: Maybe<Account>;
@@ -3904,17 +3799,8 @@ export type NotificationSubscriptionMutationUnsubscribeArgs = {
id: Scalars['ID'];
};
export type NotificationThresholdExceeded = {
__typename?: 'NotificationThresholdExceeded';
count: Scalars['Int'];
limit: Scalars['Int'];
threshold: Scalars['Int'];
type: BuildLimitThresholdExceededMetadataType;
};
export enum NotificationType {
Email = 'EMAIL',
Web = 'WEB'
Email = 'EMAIL'
}
export type Offer = {
@@ -4198,8 +4084,6 @@ export type RootMutation = {
githubApp: GitHubAppMutation;
/** Mutations for GitHub App installations */
githubAppInstallation: GitHubAppInstallationMutation;
/** Mutations for GitHub build triggers */
githubBuildTrigger: GitHubBuildTriggerMutation;
/** Mutations for GitHub repositories */
githubRepository: GitHubRepositoryMutation;
/** Mutations for GitHub repository settings */
@@ -4310,20 +4194,17 @@ export type RootQuery = {
meUserActor?: Maybe<UserActor>;
project: ProjectQuery;
snack: SnackQuery;
/** Top-level query object for querying SSO Users. */
ssoUser: SsoUserQuery;
/** Top-level query object for querying Expo status page services. */
statuspageService: StatuspageServiceQuery;
submissions: SubmissionQuery;
/** fetch all updates in a group */
updatesByGroup: Array<Update>;
/**
* Top-level query object for querying Users.
* @deprecated Public user queries are no longer supported
*/
/** Top-level query object for querying Users. */
user: UserQuery;
/** Top-level query object for querying UserActors. */
userActor: UserActorQuery;
/** Top-level query object for querying UserActorPublicData publicly. */
userActorPublicData: UserActorPublicDataQuery;
/** @deprecated Use 'byId' field under 'user'. */
userByUserId?: Maybe<User>;
/** @deprecated Use 'byUsername' field under 'user'. */
@@ -4375,23 +4256,6 @@ export type Runtime = {
version: Scalars['String'];
};
export type RuntimeEdge = {
__typename?: 'RuntimeEdge';
cursor: Scalars['String'];
node: Runtime;
};
export type RuntimeFilterInput = {
/** Only return runtimes shared with this branch */
branchId?: InputMaybe<Scalars['String']>;
};
export type RuntimesConnection = {
__typename?: 'RuntimesConnection';
edges: Array<RuntimeEdge>;
pageInfo: PageInfo;
};
/** Represents a human SSO (not robot) actor. */
export type SsoUser = Actor & UserActor & {
__typename?: 'SSOUser';
@@ -4437,8 +4301,6 @@ export type SsoUser = Actor & UserActor & {
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']>;
username: Scalars['String'];
/** Web notifications linked to a user */
websiteNotifications: Array<Notification>;
};
@@ -4481,6 +4343,24 @@ export type SsoUserDataInput = {
lastName?: InputMaybe<Scalars['String']>;
};
export type SsoUserQuery = {
__typename?: 'SSOUserQuery';
/** Query an SSOUser by ID */
byId: SsoUser;
/** Query an SSOUser by username */
byUsername: SsoUser;
};
export type SsoUserQueryByIdArgs = {
userId: Scalars['ID'];
};
export type SsoUserQueryByUsernameArgs = {
username: Scalars['String'];
};
export type SecondFactorBooleanResult = {
__typename?: 'SecondFactorBooleanResult';
success: Scalars['Boolean'];
@@ -4857,11 +4737,6 @@ export type SubscriptionDetailsPlanEnablementArgs = {
serviceMetric: EasServiceMetric;
};
export type TestNotificationMetadata = {
__typename?: 'TestNotificationMetadata';
message: Scalars['String'];
};
export type TimelineActivityConnection = {
__typename?: 'TimelineActivityConnection';
edges: Array<TimelineActivityEdge>;
@@ -4923,22 +4798,12 @@ export type UpdateBranch = {
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
name: Scalars['String'];
runtimes: RuntimesConnection;
updateGroups: Array<Array<Update>>;
updatedAt: Scalars['DateTime'];
updates: Array<Update>;
};
export type UpdateBranchRuntimesArgs = {
after?: InputMaybe<Scalars['String']>;
before?: InputMaybe<Scalars['String']>;
filter?: InputMaybe<RuntimeFilterInput>;
first?: InputMaybe<Scalars['Int']>;
last?: InputMaybe<Scalars['Int']>;
};
export type UpdateBranchUpdateGroupsArgs = {
filter?: InputMaybe<UpdatesFilter>;
limit: Scalars['Int'];
@@ -5044,16 +4909,6 @@ export type UpdateChannelMutationEditUpdateChannelArgs = {
channelId: Scalars['ID'];
};
export type UpdateGitHubBuildTriggerInput = {
autoSubmit: Scalars['Boolean'];
buildProfile: Scalars['String'];
isActive: Scalars['Boolean'];
platform: AppPlatform;
sourcePattern: Scalars['String'];
targetPattern?: InputMaybe<Scalars['String']>;
type: GitHubBuildTriggerType;
};
export type UpdateGitHubRepositorySettingsInput = {
baseDirectory: Scalars['String'];
};
@@ -5161,7 +5016,7 @@ export type User = Actor & UserActor & {
/** Discord account linked to a user */
discordUser?: Maybe<DiscordUser>;
displayName: Scalars['String'];
email: Scalars['String'];
email?: Maybe<Scalars['String']>;
emailVerified: Scalars['Boolean'];
/**
* Server feature gate values for this actor, optionally filtering by desired gates.
@@ -5199,7 +5054,6 @@ export type User = Actor & UserActor & {
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']>;
username: Scalars['String'];
websiteNotifications: Array<Notification>;
};
@@ -5288,8 +5142,6 @@ export type UserActor = {
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']>;
username: Scalars['String'];
/** Web notifications linked to a user */
websiteNotifications: Array<Notification>;
};
@@ -5327,46 +5179,6 @@ export type UserActorSnacksArgs = {
offset: Scalars['Int'];
};
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
export type UserActorPublicData = {
__typename?: 'UserActorPublicData';
/** Apps this user has published */
apps: Array<App>;
firstName?: Maybe<Scalars['String']>;
id: Scalars['ID'];
lastName?: Maybe<Scalars['String']>;
profilePhoto: Scalars['String'];
/** Snacks associated with this user's personal account */
snacks: Array<Snack>;
username: Scalars['String'];
};
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
export type UserActorPublicDataAppsArgs = {
includeUnpublished?: InputMaybe<Scalars['Boolean']>;
limit: Scalars['Int'];
offset: Scalars['Int'];
};
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
export type UserActorPublicDataSnacksArgs = {
limit: Scalars['Int'];
offset: Scalars['Int'];
};
export type UserActorPublicDataQuery = {
__typename?: 'UserActorPublicDataQuery';
/** Get UserActorPublicData by username */
byUsername: UserActorPublicData;
};
export type UserActorPublicDataQueryByUsernameArgs = {
username: Scalars['String'];
};
export type UserActorQuery = {
__typename?: 'UserActorQuery';
/** Query a UserActor by ID */
@@ -5508,15 +5320,9 @@ export type UserPermission = {
export type UserQuery = {
__typename?: 'UserQuery';
/**
* Query a User by ID
* @deprecated Public user queries are no longer supported
*/
/** Query a User by ID */
byId: User;
/**
* Query a User by username
* @deprecated Public user queries are no longer supported
*/
/** Query a User by username */
byUsername: User;
};
@@ -5709,14 +5515,6 @@ export type CreateAndroidAppBuildCredentialsMutationVariables = Exact<{
export type CreateAndroidAppBuildCredentialsMutation = { __typename?: 'RootMutation', androidAppBuildCredentials: { __typename?: 'AndroidAppBuildCredentialsMutation', createAndroidAppBuildCredentials: { __typename?: 'AndroidAppBuildCredentials', id: string, isDefault: boolean, isLegacy: boolean, name: string, androidKeystore?: { __typename?: 'AndroidKeystore', id: string, type: AndroidKeystoreType, keystore: string, keystorePassword: string, keyAlias: string, keyPassword?: string | null, md5CertificateFingerprint?: string | null, sha1CertificateFingerprint?: string | null, sha256CertificateFingerprint?: string | null, createdAt: any, updatedAt: any } | null } } };
export type SetDefaultAndroidAppBuildCredentialsMutationVariables = Exact<{
androidAppBuildCredentialsId: Scalars['ID'];
isDefault: Scalars['Boolean'];
}>;
export type SetDefaultAndroidAppBuildCredentialsMutation = { __typename?: 'RootMutation', androidAppBuildCredentials: { __typename?: 'AndroidAppBuildCredentialsMutation', setDefault: { __typename?: 'AndroidAppBuildCredentials', id: string, isDefault: boolean, isLegacy: boolean, name: string, androidKeystore?: { __typename?: 'AndroidKeystore', id: string, type: AndroidKeystoreType, keystore: string, keystorePassword: string, keyAlias: string, keyPassword?: string | null, md5CertificateFingerprint?: string | null, sha1CertificateFingerprint?: string | null, sha256CertificateFingerprint?: string | null, createdAt: any, updatedAt: any } | null } } };
export type SetKeystoreMutationVariables = Exact<{
androidAppBuildCredentialsId: Scalars['ID'];
keystoreId: Scalars['ID'];
@@ -6200,7 +5998,7 @@ export type CreateAndroidSubmissionMutationVariables = Exact<{
}>;
export type CreateAndroidSubmissionMutation = { __typename?: 'RootMutation', submission: { __typename?: 'SubmissionMutation', createAndroidSubmission: { __typename?: 'CreateSubmissionResult', submission: { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null } } } };
export type CreateAndroidSubmissionMutation = { __typename?: 'RootMutation', submission: { __typename?: 'SubmissionMutation', createAndroidSubmission: { __typename?: 'CreateSubmissionResult', submission: { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null } } } };
export type CreateIosSubmissionMutationVariables = Exact<{
appId: Scalars['ID'];
@@ -6210,7 +6008,7 @@ export type CreateIosSubmissionMutationVariables = Exact<{
}>;
export type CreateIosSubmissionMutation = { __typename?: 'RootMutation', submission: { __typename?: 'SubmissionMutation', createIosSubmission: { __typename?: 'CreateSubmissionResult', submission: { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null } } } };
export type CreateIosSubmissionMutation = { __typename?: 'RootMutation', submission: { __typename?: 'SubmissionMutation', createIosSubmission: { __typename?: 'CreateSubmissionResult', submission: { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null } } } };
export type CreateUploadSessionMutationVariables = Exact<{
type: UploadSessionType;
@@ -6282,17 +6080,6 @@ export type BranchesByAppQueryVariables = Exact<{
export type BranchesByAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updates: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }> }> } } };
export type BranchesBasicPaginatedOnAppQueryVariables = Exact<{
appId: Scalars['String'];
first?: InputMaybe<Scalars['Int']>;
after?: InputMaybe<Scalars['String']>;
last?: InputMaybe<Scalars['Int']>;
before?: InputMaybe<Scalars['String']>;
}>;
export type BranchesBasicPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, branchesPaginated: { __typename?: 'AppBranchesConnection', edges: Array<{ __typename?: 'AppBranchEdge', cursor: string, node: { __typename?: 'UpdateBranch', id: string, name: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type ViewBranchesOnUpdateChannelQueryVariables = Exact<{
appId: Scalars['String'];
channelName: Scalars['String'];
@@ -6315,7 +6102,7 @@ export type BuildsWithSubmissionsByIdQueryVariables = Exact<{
}>;
export type BuildsWithSubmissionsByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, resourceClass: BuildResourceClass, expirationDate?: any | null, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }>, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } } } };
export type BuildsWithSubmissionsByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, resourceClass: BuildResourceClass, expirationDate?: any | null, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }>, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } } } };
export type ViewBuildsOnAppQueryVariables = Exact<{
appId: Scalars['String'];
@@ -6330,7 +6117,6 @@ export type ViewBuildsOnAppQuery = { __typename?: 'RootQuery', app: { __typename
export type ViewUpdateChannelOnAppQueryVariables = Exact<{
appId: Scalars['String'];
channelName: Scalars['String'];
filter?: InputMaybe<UpdatesFilter>;
}>;
@@ -6343,18 +6129,7 @@ export type ViewUpdateChannelsOnAppQueryVariables = Exact<{
}>;
export type ViewUpdateChannelsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannels: Array<{ __typename?: 'UpdateChannel', id: string, name: string, createdAt: any, branchMapping: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updateGroups: Array<Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }>> }> }> } } };
export type ViewUpdateChannelsPaginatedOnAppQueryVariables = Exact<{
appId: Scalars['String'];
first?: InputMaybe<Scalars['Int']>;
after?: InputMaybe<Scalars['String']>;
last?: InputMaybe<Scalars['Int']>;
before?: InputMaybe<Scalars['String']>;
}>;
export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', cursor: string, node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type ViewUpdateChannelsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannels: Array<{ __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updateGroups: Array<Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }>> }> }> } } };
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
appId: Scalars['String'];
@@ -6377,19 +6152,6 @@ export type GetAssetLimitPerUpdateGroupForAppQueryVariables = Exact<{
export type GetAssetLimitPerUpdateGroupForAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, assetLimitPerUpdateGroup: number } } };
export type ViewRuntimesOnBranchQueryVariables = Exact<{
appId: Scalars['String'];
name: Scalars['String'];
first?: InputMaybe<Scalars['Int']>;
after?: InputMaybe<Scalars['String']>;
last?: InputMaybe<Scalars['Int']>;
before?: InputMaybe<Scalars['String']>;
filter?: InputMaybe<RuntimeFilterInput>;
}>;
export type ViewRuntimesOnBranchQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranchByName?: { __typename?: 'UpdateBranch', id: string, runtimes: { __typename?: 'RuntimesConnection', edges: Array<{ __typename?: 'RuntimeEdge', cursor: string, node: { __typename?: 'Runtime', id: string, version: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } | null } } };
export type StatuspageServiceByServiceNamesQueryVariables = Exact<{
serviceNames: Array<StatuspageServiceName> | StatuspageServiceName;
}>;
@@ -6402,7 +6164,7 @@ export type SubmissionsByIdQueryVariables = Exact<{
}>;
export type SubmissionsByIdQuery = { __typename?: 'RootQuery', submissions: { __typename?: 'SubmissionQuery', byId: { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null } } };
export type SubmissionsByIdQuery = { __typename?: 'RootQuery', submissions: { __typename?: 'SubmissionQuery', byId: { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null } } };
export type GetAllSubmissionsForAppQueryVariables = Exact<{
appId: Scalars['String'];
@@ -6413,7 +6175,7 @@ export type GetAllSubmissionsForAppQueryVariables = Exact<{
}>;
export type GetAllSubmissionsForAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }> } } };
export type GetAllSubmissionsForAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }> } } };
export type ViewUpdatesByGroupQueryVariables = Exact<{
groupId: Scalars['ID'];
@@ -6469,24 +6231,18 @@ export type AppFragment = { __typename?: 'App', id: string, fullName: string, sl
export type BuildFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, resourceClass: BuildResourceClass, expirationDate?: any | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } };
export type BuildWithSubmissionsFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, resourceClass: BuildResourceClass, expirationDate?: any | null, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }>, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } };
export type BuildWithSubmissionsFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, resourceClass: BuildResourceClass, expirationDate?: any | null, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }>, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } };
export type EnvironmentSecretFragment = { __typename?: 'EnvironmentSecret', id: string, name: string, type: EnvironmentSecretType, createdAt: any };
export type RuntimeFragment = { __typename?: 'Runtime', id: string, version: string };
export type StatuspageServiceFragment = { __typename?: 'StatuspageService', id: string, name: StatuspageServiceName, status: StatuspageServiceStatus, incidents: Array<{ __typename?: 'StatuspageIncident', id: string, status: StatuspageIncidentStatus, name: string, impact: StatuspageIncidentImpact, shortlink: string }> };
export type SubmissionFragment = { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null };
export type SubmissionFragment = { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null };
export type UpdateFragment = { __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null };
export type UpdateBranchFragment = { __typename?: 'UpdateBranch', id: string, name: string, updates: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }> };
export type UpdateBranchBasicInfoFragment = { __typename?: 'UpdateBranch', id: string, name: string };
export type UpdateChannelBasicInfoFragment = { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string };
export type WebhookFragment = { __typename?: 'Webhook', id: string, event: WebhookType, url: string, createdAt: any, updatedAt: any };
export type AndroidAppBuildCredentialsFragment = { __typename?: 'AndroidAppBuildCredentials', id: string, isDefault: boolean, isLegacy: boolean, name: string, androidKeystore?: { __typename?: 'AndroidKeystore', id: string, type: AndroidKeystoreType, keystore: string, keystorePassword: string, keyAlias: string, keyPassword?: string | null, md5CertificateFingerprint?: string | null, sha1CertificateFingerprint?: string | null, sha256CertificateFingerprint?: string | null, createdAt: any, updatedAt: any } | null };
@@ -5,11 +5,8 @@ import { BranchNotFoundError } from '../../branch/utils';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
BranchesBasicPaginatedOnAppQuery,
BranchesBasicPaginatedOnAppQueryVariables,
BranchesByAppQuery,
BranchesByAppQueryVariables,
UpdateBranchBasicInfoFragment,
UpdateBranchFragment,
ViewBranchQuery,
ViewBranchQueryVariables,
@@ -18,7 +15,6 @@ import {
} from '../generated';
import { UpdateFragmentNode } from '../types/Update';
import { UpdateBranchFragmentNode } from '../types/UpdateBranch';
import { UpdateBranchBasicInfoFragmentNode } from '../types/UpdateBranchBasicInfo';
export type UpdateBranchOnChannelObject = NonNullable<
ViewBranchesOnUpdateChannelQuery['app']['byId']['updateChannelByName']
@@ -28,7 +24,7 @@ export const BranchQuery = {
async getBranchByNameAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name }: ViewBranchQueryVariables
): Promise<UpdateBranchBasicInfoFragment> {
): Promise<NonNullable<ViewBranchQuery['app']['byId']['updateBranchByName']>> {
const response = await withErrorHandlingAsync<ViewBranchQuery>(
graphqlClient
.query<ViewBranchQuery, ViewBranchQueryVariables>(
@@ -39,12 +35,11 @@ export const BranchQuery = {
id
updateBranchByName(name: $name) {
id
...UpdateBranchBasicInfoFragment
name
}
}
}
}
${print(UpdateBranchBasicInfoFragmentNode)}
`,
{
appId,
@@ -93,56 +88,6 @@ export const BranchQuery = {
return data?.app?.byId.updateBranches ?? [];
},
async listBranchesBasicInfoPaginatedOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, first, after, last, before }: BranchesBasicPaginatedOnAppQueryVariables
): Promise<BranchesBasicPaginatedOnAppQuery['app']['byId']['branchesPaginated']> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<BranchesBasicPaginatedOnAppQuery, BranchesBasicPaginatedOnAppQueryVariables>(
gql`
query BranchesBasicPaginatedOnApp(
$appId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
app {
byId(appId: $appId) {
id
branchesPaginated(first: $first, after: $after, before: $before, last: $last) {
edges {
node {
id
...UpdateBranchBasicInfoFragment
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
${print(UpdateBranchBasicInfoFragmentNode)}
`,
{ appId, first, after, last, before },
{ additionalTypenames: ['UpdateBranch'] }
)
.toPromise()
);
const { branchesPaginated } = response.app.byId;
if (!branchesPaginated) {
throw new Error(`Could not find channels on project with id ${appId}`);
}
return branchesPaginated;
},
async listBranchesOnChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, channelName, offset, limit }: ViewBranchesOnUpdateChannelQueryVariables
@@ -5,55 +5,31 @@ import { ChannelNotFoundError } from '../../channel/errors';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
UpdateBranchBasicInfoFragment,
UpdateFragment,
ViewUpdateChannelOnAppQuery,
ViewUpdateChannelOnAppQueryVariables,
ViewUpdateChannelsOnAppQuery,
ViewUpdateChannelsOnAppQueryVariables,
ViewUpdateChannelsPaginatedOnAppQuery,
ViewUpdateChannelsPaginatedOnAppQueryVariables,
} from '../generated';
import { UpdateFragmentNode } from '../types/Update';
import { UpdateChannelBasicInfoFragmentNode } from '../types/UpdateChannelBasicInfo';
type ViewUpdateChannelsOnAppObject = NonNullable<
export type UpdateChannelObject = NonNullable<
ViewUpdateChannelsOnAppQuery['app']['byId']['updateChannels']
>[number];
type UpdateChannelByNameObject = NonNullable<
export type UpdateChannelByNameObject = NonNullable<
ViewUpdateChannelOnAppQuery['app']['byId']['updateChannelByName']
>;
// these types should have the same fields
export type UpdateChannelObject = ViewUpdateChannelsOnAppObject & UpdateChannelByNameObject;
export type UpdateBranchObject = UpdateChannelObject['updateBranches'][number];
export function composeUpdateBranchObject(
branchInfo: UpdateBranchBasicInfoFragment,
updateGroups: UpdateFragment[][]
): UpdateBranchObject {
return {
...branchInfo,
updateGroups,
};
}
export const ChannelQuery = {
async viewUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, channelName, filter }: ViewUpdateChannelOnAppQueryVariables
{ appId, channelName }: ViewUpdateChannelOnAppQueryVariables
): Promise<UpdateChannelByNameObject> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<ViewUpdateChannelOnAppQuery, ViewUpdateChannelOnAppQueryVariables>(
gql`
query ViewUpdateChannelOnApp(
$appId: String!
$channelName: String!
$filter: UpdatesFilter
) {
query ViewUpdateChannelOnApp($appId: String!, $channelName: String!) {
app {
byId(appId: $appId) {
id
@@ -65,7 +41,7 @@ export const ChannelQuery = {
updateBranches(offset: 0, limit: 5) {
id
name
updateGroups(offset: 0, limit: 1, filter: $filter) {
updateGroups(offset: 0, limit: 1) {
id
...UpdateFragment
}
@@ -76,7 +52,7 @@ export const ChannelQuery = {
}
${print(UpdateFragmentNode)}
`,
{ appId, channelName, filter },
{ appId, channelName },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
@@ -104,7 +80,6 @@ export const ChannelQuery = {
updateChannels(offset: $offset, limit: $limit) {
id
name
createdAt
branchMapping
updateBranches(offset: 0, limit: 5) {
id
@@ -133,57 +108,4 @@ export const ChannelQuery = {
return updateChannels;
},
async viewUpdateChannelsBasicInfoPaginatedOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, first, after, last, before }: ViewUpdateChannelsPaginatedOnAppQueryVariables
): Promise<ViewUpdateChannelsPaginatedOnAppQuery['app']['byId']['channelsPaginated']> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<
ViewUpdateChannelsPaginatedOnAppQuery,
ViewUpdateChannelsPaginatedOnAppQueryVariables
>(
gql`
query ViewUpdateChannelsPaginatedOnApp(
$appId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
app {
byId(appId: $appId) {
id
channelsPaginated(first: $first, after: $after, before: $before, last: $last) {
edges {
node {
id
...UpdateChannelBasicInfoFragment
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ appId, first, after, last, before },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
);
const { channelsPaginated } = response.app.byId;
if (!channelsPaginated) {
throw new Error(`Could not find channels on project with id ${appId}`);
}
return channelsPaginated;
},
};
@@ -1,85 +0,0 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { BranchNotFoundError } from '../../branch/utils';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Connection } from '../../utils/relay';
import { withErrorHandlingAsync } from '../client';
import {
RuntimeFragment,
ViewRuntimesOnBranchQuery,
ViewRuntimesOnBranchQueryVariables,
} from '../generated';
import { RuntimeFragmentNode } from '../types/Runtime';
export const RuntimeQuery = {
async getRuntimesOnBranchAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name, first, after, last, before, filter }: ViewRuntimesOnBranchQueryVariables
): Promise<Connection<RuntimeFragment>> {
const response = await withErrorHandlingAsync<ViewRuntimesOnBranchQuery>(
graphqlClient
.query<ViewRuntimesOnBranchQuery, ViewRuntimesOnBranchQueryVariables>(
gql`
query ViewRuntimesOnBranch(
$appId: String!
$name: String!
$first: Int
$after: String
$last: Int
$before: String
$filter: RuntimeFilterInput
) {
app {
byId(appId: $appId) {
id
updateBranchByName(name: $name) {
id
runtimes(
first: $first
after: $after
before: $before
last: $last
filter: $filter
) {
edges {
node {
id
...RuntimeFragment
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
}
${print(RuntimeFragmentNode)}
`,
{
appId,
name,
first,
after,
last,
before,
filter,
},
{ additionalTypenames: ['UpdateBranch', 'Runtime'] }
)
.toPromise()
);
const { updateBranchByName } = response.app.byId;
if (!updateBranchByName) {
throw new BranchNotFoundError(`Could not find a branch named "${name}".`);
}
return updateBranchByName.runtimes;
},
};
@@ -1,8 +0,0 @@
import gql from 'graphql-tag';
export const RuntimeFragmentNode = gql`
fragment RuntimeFragment on Runtime {
id
version
}
`;
@@ -18,7 +18,6 @@ export const SubmissionFragmentNode = gql`
applicationIdentifier
track
releaseStatus
rollout
}
iosConfig {
ascAppIdentifier
@@ -2,17 +2,15 @@ import { print } from 'graphql';
import gql from 'graphql-tag';
import { UpdateFragmentNode } from './Update';
import { UpdateBranchBasicInfoFragmentNode } from './UpdateBranchBasicInfo';
export const UpdateBranchFragmentNode = gql`
fragment UpdateBranchFragment on UpdateBranch {
id
...UpdateBranchBasicInfoFragment
name
updates(offset: 0, limit: 10) {
id
...UpdateFragment
}
}
${print(UpdateFragmentNode)}
${print(UpdateBranchBasicInfoFragmentNode)}
`;
@@ -1,8 +0,0 @@
import gql from 'graphql-tag';
export const UpdateBranchBasicInfoFragmentNode = gql`
fragment UpdateBranchBasicInfoFragment on UpdateBranch {
id
name
}
`;
@@ -1,9 +0,0 @@
import gql from 'graphql-tag';
export const UpdateChannelBasicInfoFragmentNode = gql`
fragment UpdateChannelBasicInfoFragment on UpdateChannel {
id
name
branchMapping
}
`;
@@ -5,7 +5,6 @@ import { AppleDeviceClass } from '../../generated';
export const APPLE_DEVICE_CLASS_LABELS: Record<AppleDeviceClass, string> = {
[AppleDeviceClass.Ipad]: 'iPad',
[AppleDeviceClass.Iphone]: 'iPhone',
[AppleDeviceClass.Mac]: 'Mac',
};
export const AppleDeviceFragmentNode = gql`
@@ -180,7 +180,7 @@ export class AppleConfigReader {
return {
locale,
description: info.description,
keywords: info.keywords?.join(','),
keywords: info.keywords?.join(', '),
// TODO: maybe move this to task logic, it's more an exception than data handling
whatsNew: context.versionIsFirst ? undefined : info.releaseNotes || DEFAULT_WHATSNEW,
marketingUrl: info.marketingUrl,
+4 -10
View File
@@ -3,7 +3,6 @@ import { Env } from '@expo/eas-build-job';
import JsonFile from '@expo/json-file';
import fs from 'fs-extra';
import Joi from 'joi';
import nullthrows from 'nullthrows';
import path from 'path';
export type PublicExpoConfig = Omit<
@@ -94,24 +93,19 @@ export function ensureExpoConfigExists(projectDir: string): void {
}
async function ensureStaticExpoConfigIsValidAsync(projectDir: string): Promise<void> {
if (isUsingStaticExpoConfig(projectDir)) {
const staticConfigPath = nullthrows(getConfigFilePaths(projectDir).staticConfigPath);
const staticConfig = await JsonFile.readAsync(staticConfigPath);
const paths = getConfigFilePaths(projectDir);
if (paths?.staticConfigPath?.endsWith('app.json') && !paths?.dynamicConfigPath) {
const staticConfig = await JsonFile.readAsync(paths.staticConfigPath);
// Add the "expo" key if it doesn't exist on app.json yet, such as in
// projects initialized with RNC CLI
if (!staticConfig?.expo) {
staticConfig.expo = {};
await JsonFile.writeAsync(staticConfigPath, staticConfig);
await JsonFile.writeAsync(paths.staticConfigPath, staticConfig);
}
}
}
export function isUsingStaticExpoConfig(projectDir: string): boolean {
const paths = getConfigFilePaths(projectDir);
return !!(paths.staticConfigPath?.endsWith('app.json') && !paths.dynamicConfigPath);
}
export function getPublicExpoConfig(
projectDir: string,
opts: ExpoConfigOptions = {}
@@ -3,7 +3,6 @@ import { JSONObject } from '@expo/json-file';
import { getPrebuildConfigAsync } from '@expo/prebuild-config';
import { readPlistAsync } from '../../utils/plist';
import { hasIgnoredIosProjectAsync } from '../workflow';
interface Target {
buildConfiguration?: string;
@@ -14,7 +13,6 @@ export async function getManagedApplicationTargetEntitlementsAsync(
env: Record<string, string>
): Promise<JSONObject> {
const originalProcessEnv: NodeJS.ProcessEnv = process.env;
try {
process.env = {
...process.env,
@@ -26,7 +24,6 @@ export async function getManagedApplicationTargetEntitlementsAsync(
projectRoot: projectDir,
platforms: ['ios'],
introspect: true,
ignoreExistingNativeFiles: await hasIgnoredIosProjectAsync(projectDir),
});
return expWithMods.ios?.entitlements || {};
} finally {
+2 -2
View File
@@ -38,7 +38,7 @@ const AppExtensionsConfigSchema = Joi.array().items(
})
);
export async function resolveManagedProjectTargetsAsync({
export async function resolveMangedProjectTargetsAsync({
exp,
projectDir,
xcodeBuildContext,
@@ -138,7 +138,7 @@ export async function resolveTargetsAsync(opts: ResolveTargetOptions): Promise<T
if (workflow === Workflow.GENERIC) {
return await resolveBareProjectTargetsAsync(opts);
} else if (workflow === Workflow.MANAGED) {
return await resolveManagedProjectTargetsAsync(opts);
return await resolveMangedProjectTargetsAsync(opts);
} else {
throw new Error(`Unknown workflow: ${workflow}`);
}
+1 -3
View File
@@ -644,9 +644,7 @@ export async function getRuntimeVersionObjectAsync(
Workflow.MANAGED;
if (!isManaged) {
throw new Error(
`You're currently using the bare workflow, where runtime version policies are not supported. You must set your runtime version manually. For example, define your runtime version as "1.0.0", not {"policy": "appVersion"} in your app config. ${learnMore(
'https://docs.expo.dev/eas-update/runtime-versions'
)}`
'Runtime version policies are only supported in the managed workflow. In the bare workflow, runtime version needs to be set manually.'
);
}
}
-12
View File
@@ -44,15 +44,3 @@ export async function resolveWorkflowPerPlatformAsync(
]);
return { android, ios };
}
export async function hasIgnoredIosProjectAsync(projectDir: string): Promise<boolean> {
const vcsClient = getVcsClient();
const vcsRootPath = path.normalize(await vcsClient.getRootPathAsync());
try {
const pbxProjectPath = IOSConfig.Paths.getPBXProjectPath(projectDir);
return await vcsClient.isFileIgnoredAsync(path.relative(vcsRootPath, pbxProjectPath));
} finally {
return false;
}
}
@@ -1,339 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import {
testChannelObject,
testUpdateBranch1,
testUpdateBranch2,
} from '../../channel/__tests__/fixtures';
import {
BranchMappingOperator,
BranchMappingValidationError,
alwaysTrue,
andStatement,
equalsOperator,
hashLtOperator,
} from '../../channel/branch-mapping';
import {
assertRolloutBranchMapping,
composeRollout,
createRolloutBranchMapping,
editRolloutBranchMapping,
getRollout,
getRolloutInfo,
getRolloutInfoFromBranchMapping,
isConstrainedRollout,
isConstrainedRolloutInfo,
isLegacyRolloutInfo,
isRolloutBranchMapping,
} from '../branch-mapping';
import {
rolloutBranchMapping,
rolloutBranchMappingLegacy,
standardBranchMapping,
} from './fixtures';
describe(composeRollout, () => {
it('composes a rollout', () => {
const rollout = getRollout(testChannelObject);
const rolloutInfo = getRolloutInfo(testChannelObject);
const composedRollout = composeRollout(rolloutInfo, testUpdateBranch2, testUpdateBranch1);
expect(composedRollout).toEqual(rollout);
});
it('throws if the branches to not match the rollout info', () => {
const rolloutInfo = getRolloutInfo(testChannelObject);
expect(() => composeRollout(rolloutInfo, testUpdateBranch1, testUpdateBranch2)).toThrowError(
BranchMappingValidationError
);
});
});
describe(createRolloutBranchMapping, () => {
it('creates a rollout branch mapping', () => {
const branchMapping = createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: 10,
runtimeVersion: '1.0.0',
});
expect(isRolloutBranchMapping(branchMapping)).toBe(true);
});
it('throws if an invalid number is passed in', () => {
expect(() =>
createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: 0.1,
runtimeVersion: '1.0.0',
})
).toThrowError(BranchMappingValidationError);
expect(() =>
createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: -1,
runtimeVersion: '1.0.0',
})
).toThrowError(BranchMappingValidationError);
expect(() =>
createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: 1000,
runtimeVersion: '1.0.0',
})
).toThrowError(BranchMappingValidationError);
});
});
describe(assertRolloutBranchMapping, () => {
it('asserts a rollout branch mapping', () => {
expect(() => assertRolloutBranchMapping(standardBranchMapping)).toThrowError(
BranchMappingValidationError
);
expect(() => assertRolloutBranchMapping(rolloutBranchMapping)).not.toThrowError(
BranchMappingValidationError
);
expect(() => assertRolloutBranchMapping(rolloutBranchMappingLegacy)).not.toThrowError(
BranchMappingValidationError
);
});
});
describe(isLegacyRolloutInfo, () => {
it('classifies rollouts properly', () => {
const constrainedRollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
expect(isLegacyRolloutInfo(constrainedRollout)).toBe(false);
const legacyRollout = getRolloutInfoFromBranchMapping(rolloutBranchMappingLegacy);
expect(isLegacyRolloutInfo(legacyRollout)).toBe(true);
});
});
describe(isConstrainedRollout, () => {
it('classifies rollouts properly', () => {
const constrainedRollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
expect(isConstrainedRolloutInfo(constrainedRollout)).toBe(true);
const legacyRollout = getRolloutInfoFromBranchMapping(rolloutBranchMappingLegacy);
expect(isConstrainedRolloutInfo(legacyRollout)).toBe(false);
});
});
describe(getRollout, () => {
it('doesnt get the mapping if it isnt a rollout', () => {
const notRollout = { ...testChannelObject };
notRollout.branchMapping = JSON.stringify(standardBranchMapping);
expect(() => getRollout(notRollout)).toThrowError();
});
it('gets a constrained rollout', () => {
const constrainedRollout = { ...testChannelObject };
const rolloutBranchMappingWithCorrectBranch = { ...rolloutBranchMapping };
rolloutBranchMappingWithCorrectBranch.data[0].branchId = testUpdateBranch1.id;
rolloutBranchMappingWithCorrectBranch.data[1].branchId = testUpdateBranch2.id;
constrainedRollout.branchMapping = JSON.stringify(rolloutBranchMapping);
const rollout = getRollout(constrainedRollout);
expect(rollout.percentRolledOut).toEqual(10);
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranch).toEqual(testUpdateBranch2);
expect(rollout.rolledOutBranch).toEqual(testUpdateBranch1);
expect(rollout.defaultBranchId).toEqual(testUpdateBranch2.id);
expect(rollout.rolledOutBranchId).toEqual(testUpdateBranch1.id);
});
it('gets a legacy rollout', () => {
const rollout = getRollout(testChannelObject);
expect(rollout.percentRolledOut).toEqual(15);
expect(isLegacyRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranch).toEqual(testUpdateBranch2);
expect(rollout.rolledOutBranch).toEqual(testUpdateBranch1);
expect(rollout.defaultBranchId).toEqual(testUpdateBranch2.id);
expect(rollout.rolledOutBranchId).toEqual(testUpdateBranch1.id);
});
});
describe(getRolloutInfoFromBranchMapping, () => {
it('gets a constrained rollout', () => {
const rollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
expect(rollout.percentRolledOut).toEqual(10);
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranchId).toEqual(rolloutBranchMapping.data[1].branchId);
expect(rollout.rolledOutBranchId).toEqual(rolloutBranchMapping.data[0].branchId);
});
it('gets a legacy rollout', () => {
const rollout = getRolloutInfoFromBranchMapping(rolloutBranchMappingLegacy);
expect(rollout.percentRolledOut).toEqual(10);
expect(isLegacyRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranchId).toEqual(rolloutBranchMappingLegacy.data[1].branchId);
expect(rollout.rolledOutBranchId).toEqual(rolloutBranchMappingLegacy.data[0].branchId);
});
});
describe(editRolloutBranchMapping, () => {
it('doesnt edit the branch mapping if the input is out of range', () => {
expect(() => editRolloutBranchMapping(rolloutBranchMapping, -1)).toThrowError();
expect(() => editRolloutBranchMapping(rolloutBranchMapping, 101)).toThrowError();
expect(() => editRolloutBranchMapping(rolloutBranchMapping, 0.1)).toThrowError();
});
it('returns a new instance of a branch mapping', () => {
const editedBranchMapping = editRolloutBranchMapping(rolloutBranchMapping, 50);
expect(rolloutBranchMapping).not.toBe(editedBranchMapping);
});
it('edits the branch mapping for a constrained rollout', () => {
const editedBranchMapping = editRolloutBranchMapping(rolloutBranchMapping, 50);
const rollout = getRolloutInfoFromBranchMapping(editedBranchMapping);
expect(rollout.percentRolledOut).toEqual(50);
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
});
it('edits the branch mapping for a legacy rollout', () => {
const editedBranchMapping = editRolloutBranchMapping(rolloutBranchMappingLegacy, 50);
const rollout = getRolloutInfoFromBranchMapping(editedBranchMapping);
expect(rollout.percentRolledOut).toEqual(50);
expect(isLegacyRolloutInfo(rollout)).toBe(true);
});
});
describe(isRolloutBranchMapping, () => {
it('detects a rollout made by the cli', () => {
expect(isRolloutBranchMapping(rolloutBranchMappingLegacy)).toBe(true);
expect(isRolloutBranchMapping(rolloutBranchMapping)).toBe(true);
});
it('detects custom mappings equivalent to rollouts made by the cli', () => {
const customMapping1 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping1)).toBe(true);
});
it('correctly classifies branchMappings that arent rollouts', () => {
expect(isRolloutBranchMapping(standardBranchMapping)).toBe(false);
const customMapping1 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_gt' as BranchMappingOperator,
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping1)).toBe(false);
const customMapping2 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping2)).toBe(false);
const customMapping3 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping3)).toBe(false);
const customMapping4 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
},
],
};
expect(isRolloutBranchMapping(customMapping4)).toBe(false);
const customMapping5 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
{
operand: '2.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping5)).toBe(false);
const customMapping6 = {
version: 0,
data: [
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
},
],
};
expect(isRolloutBranchMapping(customMapping6)).toBe(false);
});
});
@@ -1,75 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { BranchMapping, alwaysTrue } from '../../channel/branch-mapping';
import { ConstrainedRolloutBranchMapping, LegacyRolloutBranchMapping } from '../branch-mapping';
export const rolloutBranchMapping: ConstrainedRolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: [
'and',
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: '==',
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
],
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const rolloutBranchMapping2: ConstrainedRolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: [
'and',
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: '==',
},
],
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const rolloutBranchMappingLegacy: LegacyRolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const standardBranchMapping: BranchMapping = {
version: 0,
data: [{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() }],
};
export const emptyBranchMapping: BranchMapping = {
version: 0,
data: [],
};
@@ -1,332 +0,0 @@
import { Updates } from '@expo/config-plugins';
import assert from 'assert';
import { SelectBranch } from '../../branch/actions/SelectBranch';
import {
getStandardBranchId,
hasEmptyBranchMap,
hasStandardBranchMap,
} from '../../channel/branch-mapping';
import { getUpdateBranch } from '../../channel/utils';
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import {
UpdateBranchBasicInfoFragment,
UpdateChannelBasicInfoFragment,
UpdateFragment,
} from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import {
ChannelQuery,
UpdateChannelObject,
composeUpdateBranchObject,
} from '../../graphql/queries/ChannelQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { truthy } from '../../utils/expodash/filter';
import {
composeRollout,
createRolloutBranchMapping,
getRolloutInfoFromBranchMapping,
isRollout,
} from '../branch-mapping';
import {
displayRolloutDetails,
formatBranchWithUpdateGroup,
promptForRolloutPercentAsync,
} from '../utils';
import { SelectRuntime } from './SelectRuntime';
export type NonInteractiveOptions = {
branchNameToRollout: string;
percent: number;
runtimeVersion: string;
};
function isNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): options is NonInteractiveOptions {
return !!options.branchNameToRollout && !!options.percent && !!options.runtimeVersion;
}
function assertNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): asserts options is NonInteractiveOptions {
assert(
isNonInteractiveOptions(options),
'--branch, --percent and --runtime-version are required for creating a rollout in non-interactive mode.'
);
}
/**
* Create a rollout for the project.
*/
export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: Partial<NonInteractiveOptions> = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
const { branchNameToRollout } = this.options;
const { nonInteractive, graphqlClient } = ctx;
if (nonInteractive) {
assertNonInteractiveOptions(this.options);
}
if (isRollout(this.channelInfo)) {
throw new Error(`A rollout is already in progress for channel ${this.channelInfo.name}`);
}
if (hasEmptyBranchMap(this.channelInfo)) {
throw new Error(
`Your channel needs to be linked to a branch before a rollout can be created. Do this by running 'eas channel:edit'`
);
}
if (!hasStandardBranchMap(this.channelInfo)) {
throw new Error(
`You have a custom branch mapping. Map your channel to a single branch before creating a rollout. Received: ${this.channelInfo.branchMapping}`
);
}
const defaultBranchId = getStandardBranchId(this.channelInfo);
const branchInfoToRollout = branchNameToRollout
? await this.resolveBranchNameAsync(ctx, branchNameToRollout)
: await this.selectBranchAsync(ctx, defaultBranchId);
if (branchInfoToRollout.id === defaultBranchId) {
throw new Error(
`Channel ${this.channelInfo.name} is already mapped to branch ${branchInfoToRollout.name}.`
);
}
const runtimeVersion =
this.options.runtimeVersion ??
(await this.selectRuntimeVersionAsync(ctx, branchInfoToRollout, defaultBranchId));
Log.newLine();
const promptMessage = `What percent of users should be rolled out to branch ${branchInfoToRollout.name}?`;
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
const rolloutBranchMapping = createRolloutBranchMapping({
defaultBranchId,
rolloutBranchId: branchInfoToRollout.id,
percent,
runtimeVersion,
});
const channelObject = await this.getChannelObjectAsync(ctx, runtimeVersion);
const defaultBranch = getUpdateBranch(channelObject, defaultBranchId);
const defaultUpdateGroup = defaultBranch.updateGroups[0];
const rolloutInfo = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
const rolledOutBranchUpdateGroup = await this.getLatestUpdateGroupOnBranchAsync(
ctx,
branchInfoToRollout,
runtimeVersion
);
const rolledOutBranch = composeUpdateBranchObject(
branchInfoToRollout,
rolledOutBranchUpdateGroup ? [rolledOutBranchUpdateGroup] : []
);
const rollout = composeRollout(rolloutInfo, defaultBranch, rolledOutBranch);
displayRolloutDetails(this.channelInfo.name, rollout);
Log.log(
formatBranchWithUpdateGroup(defaultUpdateGroup, defaultBranch, 100 - rollout.percentRolledOut)
);
Log.addNewLineIfNone();
Log.log(
formatBranchWithUpdateGroup(
rolledOutBranchUpdateGroup,
rolledOutBranch,
rollout.percentRolledOut
)
);
Log.addNewLineIfNone();
const didConfirm = await this.confirmCreationAsync(ctx);
if (!didConfirm) {
throw new Error('Aborting...');
}
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: this.channelInfo.id,
branchMapping: JSON.stringify(rolloutBranchMapping),
});
Log.succeed('✅ Successfully created rollout');
return newChannelInfo;
}
async confirmCreationAsync(ctx: EASUpdateContext): Promise<boolean> {
const { nonInteractive } = ctx;
if (nonInteractive) {
return true;
}
return await confirmAsync({
message: `Create rollout?`,
});
}
async getChannelObjectAsync(
ctx: EASUpdateContext,
runtimeVersion: string
): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
filter: { runtimeVersions: [runtimeVersion] },
});
}
async getLatestUpdateGroupOnBranchAsync(
ctx: EASUpdateContext,
branchInfo: UpdateBranchBasicInfoFragment,
runtimeVersion: string
): Promise<UpdateFragment[] | null> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
const updateGroups = await UpdateQuery.viewUpdateGroupsOnBranchAsync(graphqlClient, {
appId: projectId,
branchName: branchInfo.name,
limit: 1,
offset: 0,
filter: {
runtimeVersions: [runtimeVersion],
},
});
assert(
updateGroups.length < 2,
`Expected at most one update group. Received: ${JSON.stringify(updateGroups)}`
);
return updateGroups[0] ?? null;
}
async selectRuntimeVersionAsync(
ctx: EASUpdateContext,
branchToRollout: UpdateBranchBasicInfoFragment,
defaultBranchId: string
): Promise<string> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
// we just want the branch name, not the update group
const channelObjectRtvAgnostic = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
});
const defaultBranchRtvAgnostic = getUpdateBranch(channelObjectRtvAgnostic, defaultBranchId);
const selectSharedRuntimeAction = new SelectRuntime(branchToRollout, {
anotherBranchToIntersectRuntimesBy: defaultBranchRtvAgnostic,
});
const sharedRuntime = await selectSharedRuntimeAction.runAsync(ctx);
if (sharedRuntime) {
return sharedRuntime;
}
return await this.selectRuntimeVersionFromAlternativeSourceAsync(
ctx,
branchToRollout,
defaultBranchRtvAgnostic
);
}
async selectRuntimeVersionFromAlternativeSourceAsync(
ctx: EASUpdateContext,
branchToRollout: UpdateBranchBasicInfoFragment,
defaultBranch: UpdateBranchBasicInfoFragment
): Promise<string> {
const { runtimeSource: selectedRuntimeSource } = await promptAsync({
type: 'select',
name: 'runtimeSource',
message: `What would you like to do?`,
choices: [
{
value: 'DEFAULT_BRANCH_RUNTIME',
title: `Find a runtime supported on the ${defaultBranch.name} branch`,
},
{
value: 'ROLLED_OUT_BRANCH_RUNTIME',
title: `Find a runtime supported on the ${branchToRollout.name} branch`,
},
{
value: 'PROJECT_RUNTIME',
title: 'Use the runtime specified in your project config',
},
],
});
if (selectedRuntimeSource === 'DEFAULT_BRANCH_RUNTIME') {
const selectDefaultBranchRuntimeAction = new SelectRuntime(defaultBranch);
const defaultBranchRuntime = await selectDefaultBranchRuntimeAction.runAsync(ctx);
if (defaultBranchRuntime) {
return defaultBranchRuntime;
}
} else if (selectedRuntimeSource === 'ROLLED_OUT_BRANCH_RUNTIME') {
const selectBranchToRolloutRuntimeAction = new SelectRuntime(branchToRollout);
const branchToRolloutRuntime = await selectBranchToRolloutRuntimeAction.runAsync(ctx);
if (branchToRolloutRuntime) {
return branchToRolloutRuntime;
}
} else if (selectedRuntimeSource === 'PROJECT_RUNTIME') {
return await this.selectRuntimeVersionFromProjectConfigAsync(ctx);
} else {
throw new Error(`Unexpected runtime source: ${selectedRuntimeSource}`);
}
return await this.selectRuntimeVersionFromAlternativeSourceAsync(
ctx,
branchToRollout,
defaultBranch
);
}
async selectRuntimeVersionFromProjectConfigAsync(ctx: EASUpdateContext): Promise<string> {
const platforms: ('ios' | 'android')[] = ['ios', 'android'];
const runtimes = platforms
.map(platform => Updates.getRuntimeVersion(ctx.app.exp, platform))
.filter(truthy);
const dedupedRuntimes = [...new Set(runtimes)];
if (dedupedRuntimes.length === 0) {
throw new Error(
`Your project config doesn't specify a runtime. Ensure your project is configured correctly for EAS Update by running \`eas update:configure\``
);
} else if (dedupedRuntimes.length === 1) {
const runtime = dedupedRuntimes[0];
Log.log(`🔧 Your project config currently supports runtime ${runtime}`);
return runtime;
}
const { runtime: selectedRuntime } = await promptAsync({
type: 'select',
name: 'runtime',
message: `Select a runtime supported by your project config`,
choices: runtimes.map((runtime, index) => ({
title: `${runtime} ${index === 0 ? '[iOS runtime]' : '[Android runtime]'}`,
value: runtime,
})),
});
return selectedRuntime;
}
async selectBranchAsync(
ctx: EASUpdateContext,
defaultBranchId: string
): Promise<UpdateBranchBasicInfoFragment> {
const selectBranchAction = new SelectBranch({
printedType: 'branch to roll out',
// we don't want to show the default branch as an option
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) =>
branchInfo.id !== defaultBranchId,
});
const branchInfo = await selectBranchAction.runAsync(ctx);
if (!branchInfo) {
// We know the user has at least one branch, since we have `defaultBranchId`
throw new Error(
`You don't have a second branch to roll out. Create it with 'eas branch:create'`
);
}
return branchInfo;
}
async resolveBranchNameAsync(
ctx: EASUpdateContext,
branchName: string
): Promise<UpdateBranchBasicInfoFragment> {
const { graphqlClient, app } = ctx;
return await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId: app.projectId,
name: branchName,
});
}
}
@@ -1,118 +0,0 @@
import assert from 'assert';
import chalk from 'chalk';
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import {
editRolloutBranchMapping,
getRollout,
getRolloutBranchMapping,
getRolloutInfo,
isConstrainedRolloutInfo,
isRollout,
} from '../branch-mapping';
import { promptForRolloutPercentAsync } from '../utils';
export type NonInteractiveOptions = {
percent: number;
};
function isNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): options is NonInteractiveOptions {
return !!options.percent;
}
function assertNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): asserts options is NonInteractiveOptions {
assert(
isNonInteractiveOptions(options),
'--percent is required for editing a rollout in non-interactive mode.'
);
}
/**
* Edit an existing rollout for the project.
*/
export class EditRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: Partial<NonInteractiveOptions> = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
const { nonInteractive, graphqlClient } = ctx;
if (nonInteractive) {
assertNonInteractiveOptions(this.options);
}
const channelObject = await this.getChannelObjectAsync(ctx);
const rollout = getRollout(channelObject);
const { rolledOutBranch, defaultBranch } = rollout;
const promptMessage = `What percent of users should be rolled out to the ${rolledOutBranch.name} branch ?`;
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
if (percent === 0 || percent === 100) {
Log.warn(
`Editing the percent to ${percent} will not end the rollout. You'll need to end the rollout from the main menu.`
);
}
const oldBranchMapping = getRolloutBranchMapping(channelObject.branchMapping);
const newBranchMapping = editRolloutBranchMapping(oldBranchMapping, percent);
Log.newLine();
Log.log(
`📝 ${chalk.bold(percent)}% of users will be rolled out to the ${chalk.bold(
rolledOutBranch.name
)} branch and ${chalk.bold(100 - percent)}% will remain on the ${chalk.bold(
defaultBranch.name
)} branch.`
);
const confirmEdit = await this.confirmEditAsync(ctx);
if (!confirmEdit) {
throw new Error('Aborting...');
}
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: channelObject.id,
branchMapping: JSON.stringify(newBranchMapping),
});
Log.addNewLineIfNone();
Log.log(`✅ Successfuly updated rollout`);
return newChannelInfo;
}
async confirmEditAsync(ctx: EASUpdateContext): Promise<boolean> {
const { nonInteractive } = ctx;
if (nonInteractive) {
return true;
}
return await confirmAsync({
message: `Confirm changes?`,
});
}
async getChannelObjectAsync(ctx: EASUpdateContext): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
if (!isRollout(this.channelInfo)) {
throw new Error(
`The channel ${chalk.bold(
this.channelInfo.name
)} is not a rollout. To end a rollout, you must specify a channel with an ongoing rollout.`
);
}
const rolloutInfo = getRolloutInfo(this.channelInfo);
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
...(isConstrainedRolloutInfo(rolloutInfo)
? { filter: { runtimeVersions: [rolloutInfo.runtimeVersion] } }
: {}),
});
}
}
@@ -1,216 +0,0 @@
import assert from 'assert';
import chalk from 'chalk';
import { getAlwaysTrueBranchMapping } from '../../channel/branch-mapping';
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { republishAsync } from '../../update/republish';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import formatFields from '../../utils/formatFields';
import {
Rollout,
getRollout,
getRolloutInfo,
isConstrainedRolloutInfo,
isRollout,
} from '../branch-mapping';
import { formatBranchWithUpdateGroup } from '../utils';
export enum EndOutcome {
REPUBLISH_AND_REVERT = 'republish-and-revert',
REVERT = 'revert',
}
export type GeneralOptions = {
privateKeyPath: string | null;
};
export type NonInteractiveOptions = {
outcome: EndOutcome;
};
function isNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): options is NonInteractiveOptions {
return !!options.outcome;
}
function assertNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): asserts options is NonInteractiveOptions {
assert(
isNonInteractiveOptions(options),
'--outcome is required for ending a rollout in non-interactive mode.'
);
}
/**
* End an existing rollout for the project.
*/
export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: Partial<NonInteractiveOptions> & GeneralOptions
) {}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
const { nonInteractive } = ctx;
if (nonInteractive) {
assertNonInteractiveOptions(this.options);
}
const channelObject = await this.getChannelObjectAsync(ctx);
const rollout = getRollout(channelObject);
const { rolledOutBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
let outcome: EndOutcome;
if (!rolledOutUpdateGroup) {
Log.log(`⚠️ There is no update group being served on the ${rolledOutBranch.name} branch.`);
assert(
this.options.outcome !== EndOutcome.REPUBLISH_AND_REVERT,
`The only valid outcome for this rollout is to revert users back to the ${rollout.defaultBranch.name} branch. `
);
outcome = EndOutcome.REVERT;
} else {
outcome = this.options.outcome ?? (await this.selectOutcomeAsync(rollout));
}
const didConfirm = await this.confirmOutcomeAsync(ctx, outcome, rollout);
if (!didConfirm) {
throw new Error('Aborting...');
}
return await this.performOutcomeAsync(ctx, rollout, outcome);
}
async getChannelObjectAsync(ctx: EASUpdateContext): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
if (!isRollout(this.channelInfo)) {
throw new Error(
`The channel ${chalk.bold(
this.channelInfo.name
)} is not a rollout. To end a rollout, you must specify a channel with an ongoing rollout.`
);
}
const rolloutInfo = getRolloutInfo(this.channelInfo);
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
...(isConstrainedRolloutInfo(rolloutInfo)
? { filter: { runtimeVersions: [rolloutInfo.runtimeVersion] } }
: {}),
});
}
async selectOutcomeAsync(rollout: Rollout): Promise<EndOutcome> {
const { rolledOutBranch, percentRolledOut, defaultBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
const defaultUpdateGroup = defaultBranch.updateGroups[0];
const outcomes = [
{
value: EndOutcome.REPUBLISH_AND_REVERT,
title: formatBranchWithUpdateGroup(rolledOutUpdateGroup, rolledOutBranch, percentRolledOut),
},
{
value: EndOutcome.REVERT,
title: formatBranchWithUpdateGroup(
defaultUpdateGroup,
defaultBranch,
100 - percentRolledOut
),
},
];
const { outcome: selectedOutcome } = await promptAsync({
type: 'select',
name: 'outcome',
message: `Which update group would you like to serve?`,
choices: outcomes,
});
Log.newLine();
if (selectedOutcome === EndOutcome.REPUBLISH_AND_REVERT) {
Log.log(
`➡️ 📱 The update group you chose is served by branch ${chalk.bold(rolledOutBranch.name)}`
);
} else {
Log.log(
`➡️ 📱 The update group you chose is served by branch ${chalk.bold(defaultBranch.name)}`
);
}
return selectedOutcome;
}
async performOutcomeAsync(
ctx: EASUpdateContext,
rollout: Rollout,
outcome: EndOutcome
): Promise<UpdateChannelBasicInfoFragment> {
const { graphqlClient, app } = ctx;
const { rolledOutBranch, defaultBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
if (outcome === EndOutcome.REPUBLISH_AND_REVERT) {
const codeSigningInfo = await getCodeSigningInfoAsync(
ctx.app.exp,
this.options.privateKeyPath ?? undefined
);
const arbitraryUpdate = rolledOutUpdateGroup[0];
const { message: oldUpdateMessage, group: oldGroupId } = arbitraryUpdate;
const newUpdateMessage = `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`;
await republishAsync({
graphqlClient,
app,
updatesToPublish: rolledOutUpdateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
})),
codeSigningInfo,
targetBranch: { branchId: defaultBranch.id, branchName: defaultBranch.name },
updateMessage: newUpdateMessage,
});
}
const alwaysTrueDefaultBranchMapping = getAlwaysTrueBranchMapping(defaultBranch.id);
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: this.channelInfo.id,
branchMapping: JSON.stringify(alwaysTrueDefaultBranchMapping),
});
Log.addNewLineIfNone();
Log.log(`⬅️ Reverted all users back to branch ${chalk.bold(defaultBranch.name)}`);
Log.log(`✅ Successfully ended rollout`);
return newChannelInfo;
}
async confirmOutcomeAsync(
ctx: EASUpdateContext,
selectedOutcome: EndOutcome,
rollout: Rollout
): Promise<boolean> {
const { nonInteractive } = ctx;
if (nonInteractive) {
return true;
}
const { rolledOutBranch, defaultBranch } = rollout;
Log.newLine();
if (selectedOutcome === EndOutcome.REPUBLISH_AND_REVERT) {
Log.log(`Ending the rollout will do the following:`);
const actions = formatFields([
{
label: '1.',
value: `🔁 Republish the update group from ${chalk.bold(
rolledOutBranch.name
)} onto ${chalk.bold(defaultBranch.name)}`,
},
{ label: '2.', value: `⬅️ Revert all users back to ${chalk.bold(defaultBranch.name)}` },
]);
Log.log(actions);
} else {
Log.log(
`⬅️ Ending the rollout will revert all users back to ${chalk.bold(defaultBranch.name)}`
);
}
return await confirmAsync({
message: `Continue?`,
});
}
}
@@ -1,96 +0,0 @@
import assert from 'assert';
import chalk from 'chalk';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
import { promptAsync } from '../../prompts';
import { getRolloutInfo, isConstrainedRolloutInfo, isRollout } from '../branch-mapping';
import { printRollout } from '../utils';
import {
EditRollout,
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
} from './EditRollout';
import {
EndRollout,
GeneralOptions as EndRolloutGeneralOptions,
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
} from './EndRollout';
export enum ManageRolloutActions {
EDIT = 'Edit',
END = 'End',
GO_BACK = 'Go back',
}
/**
* Manage a rollout for the project.
*/
export class ManageRollout implements EASUpdateAction<EASUpdateAction> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: {
callingAction?: EASUpdateAction;
action?: ManageRolloutActions.EDIT | ManageRolloutActions.END;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
EndRolloutGeneralOptions
) {}
public async runAsync(ctx: EASUpdateContext): Promise<EASUpdateAction> {
const { nonInteractive } = ctx;
if (nonInteractive) {
throw new Error(`rollout selection cannot be run in non-interactive mode.`);
}
const channelObject = await this.getChannelObjectAsync(ctx);
printRollout(channelObject);
const action = this.options.action ?? (await this.selectActionAsync());
switch (action as ManageRolloutActions) {
case ManageRolloutActions.EDIT:
return new EditRollout(this.channelInfo, this.options);
case ManageRolloutActions.END:
return new EndRollout(this.channelInfo, this.options);
case ManageRolloutActions.GO_BACK:
assert(this.options.callingAction, 'calling action must be defined');
return this.options.callingAction;
}
}
async selectActionAsync(): Promise<ManageRolloutActions> {
const manageOptions = [ManageRolloutActions.EDIT, ManageRolloutActions.END];
if (this.options.callingAction) {
manageOptions.push(ManageRolloutActions.GO_BACK);
}
const { manageOption: selectedManageOption } = await promptAsync({
type: 'select',
name: 'manageOption',
message: `What would you like to do?`,
choices: manageOptions.map(manageOption => ({
value: manageOption,
title: manageOption,
})),
});
return selectedManageOption;
}
async getChannelObjectAsync(ctx: EASUpdateContext): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
if (!isRollout(this.channelInfo)) {
throw new Error(
`The channel ${chalk.bold(
this.channelInfo.name
)} is not a rollout. To manage a rollout, you must specify a channel with an ongoing rollout.`
);
}
const rolloutInfo = getRolloutInfo(this.channelInfo);
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
...(isConstrainedRolloutInfo(rolloutInfo)
? { filter: { runtimeVersions: [rolloutInfo.runtimeVersion] } }
: {}),
});
}
}
@@ -1,77 +0,0 @@
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import { CodeSigningInfo } from '../../utils/code-signing';
import {
CreateRollout,
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
} from './CreateRollout';
import {
EditRollout,
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
} from './EditRollout';
import {
EndRollout,
GeneralOptions as EndRolloutGeneralOptions,
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
} from './EndRollout';
import { ManageRolloutActions } from './ManageRollout';
import { MainMenuActions, RolloutActions } from './RolloutMainMenu';
/**
* Control a rollout in non interactive mode.
*/
export class NonInteractiveRollout implements EASUpdateAction<void> {
constructor(
private options: {
channelName?: string;
codeSigningInfo?: CodeSigningInfo;
action?: RolloutActions;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
EndRolloutGeneralOptions &
Partial<CreateRolloutNonInteractiveOptions>
) {}
public async runAsync(ctx: EASUpdateContext): Promise<void> {
const { channelName, action } = this.options;
const { nonInteractive, app, graphqlClient } = ctx;
if (!nonInteractive) {
throw new Error(`This action is meant for non-interactive mode.`);
}
if (!channelName) {
throw new Error(
`The channel argument is required in non-interactive mode. Run eas channel:rollout [channel-name]`
);
}
const channelInfo = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: app.projectId,
channelName,
});
if (!action) {
throw new Error(`--action is required in non-interactive mode.`);
}
await this.runActionAsync(ctx, action, channelInfo, this.options);
}
async runActionAsync(
ctx: EASUpdateContext,
action: RolloutActions,
channelInfo: UpdateChannelBasicInfoFragment,
options: Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
EndRolloutGeneralOptions &
Partial<CreateRolloutNonInteractiveOptions>
): Promise<UpdateChannelBasicInfoFragment> {
switch (action) {
case MainMenuActions.CREATE_NEW:
return await new CreateRollout(channelInfo, options).runAsync(ctx);
case ManageRolloutActions.EDIT:
return await new EditRollout(channelInfo, options).runAsync(ctx);
case ManageRolloutActions.END:
return await new EndRollout(channelInfo, options).runAsync(ctx);
}
}
}
@@ -1,149 +0,0 @@
import assert from 'assert';
import { SelectChannel } from '../../channel/actions/SelectChannel';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { isRollout } from '../branch-mapping';
import {
CreateRollout,
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
} from './CreateRollout';
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from './EditRollout';
import {
GeneralOptions as EndRolloutGeneralOptions,
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
} from './EndRollout';
import { ManageRollout, ManageRolloutActions } from './ManageRollout';
import { SelectRollout } from './SelectRollout';
export enum MainMenuActions {
CREATE_NEW = 'Create a new rollout',
MANAGE_EXISTING = 'Manage an existing rollout',
}
export type RolloutActions =
| MainMenuActions.CREATE_NEW
| ManageRolloutActions.EDIT
| ManageRolloutActions.END;
/**
* Manage a rollout for the project.
*/
export class RolloutMainMenu implements EASUpdateAction<void> {
constructor(
private options: {
channelName?: string;
action?: RolloutActions;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
EndRolloutGeneralOptions &
Partial<CreateRolloutNonInteractiveOptions>
) {}
public async runAsync(ctx: EASUpdateContext): Promise<void> {
const { action } = this.options;
const { nonInteractive } = ctx;
if (nonInteractive) {
throw new Error(`rollout main menu cannot be run in non-interactive mode.`);
}
const menuOption = action ? this.toMainMenuAction(action) : await this.promptMenuActionAsync();
await this.runActionAsync(ctx, menuOption);
}
async runActionAsync(ctx: EASUpdateContext, menuAction: MainMenuActions): Promise<null> {
const { channelName } = this.options;
switch (menuAction) {
case MainMenuActions.CREATE_NEW: {
const channelInfo = channelName
? await this.resolveChannelNameAsync(ctx, channelName)
: await this.selectChannelToRolloutAsync(ctx);
await new CreateRollout(channelInfo, this.options).runAsync(ctx);
return null;
}
case MainMenuActions.MANAGE_EXISTING: {
const channelInfo = channelName
? await this.resolveChannelNameAsync(ctx, channelName)
: await this.selectRolloutAsync(ctx);
if (!channelInfo) {
Log.log('You dont have any rollouts.');
} else {
assert(
this.options.action !== MainMenuActions.CREATE_NEW,
'Invalid route for create action'
);
const manageAction = await new ManageRollout(channelInfo, {
...this.options,
action: this.options.action,
callingAction: this,
}).runAsync(ctx);
await manageAction.runAsync(ctx);
}
return null;
}
}
}
async selectRolloutAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
const selectRollout = new SelectRollout();
const channelInfo = await selectRollout.runAsync(ctx);
return channelInfo;
}
async selectChannelToRolloutAsync(
ctx: EASUpdateContext
): Promise<UpdateChannelBasicInfoFragment> {
let hasSomeChannel = false;
const selectChannelAction = new SelectChannel({
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) => {
hasSomeChannel = true;
return !isRollout(channelInfo);
},
});
const channelInfo = await selectChannelAction.runAsync(ctx);
if (!channelInfo) {
const error = hasSomeChannel
? new Error('All your channels are already part of a rollout.')
: new Error(`You dont have any channels. Create one with \`eas channel:create\``);
throw error;
}
return channelInfo;
}
async resolveChannelNameAsync(
ctx: EASUpdateContext,
channelName: string
): Promise<UpdateChannelBasicInfoFragment> {
const { graphqlClient, app } = ctx;
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: app.projectId,
channelName,
});
}
toMainMenuAction(action: RolloutActions): MainMenuActions {
if (action === MainMenuActions.CREATE_NEW) {
return MainMenuActions.CREATE_NEW;
} else if (action === ManageRolloutActions.EDIT || action === ManageRolloutActions.END) {
return MainMenuActions.MANAGE_EXISTING;
} else {
throw new Error(`Action not supported yet: ` + action);
}
}
async promptMenuActionAsync(): Promise<MainMenuActions> {
const menuOptions = [MainMenuActions.CREATE_NEW, MainMenuActions.MANAGE_EXISTING];
const { menuOption: selectedMenuOption } = await promptAsync({
type: 'select',
name: 'menuOption',
message: `What would you like to do?`,
choices: menuOptions.map(menuOption => ({
value: menuOption,
title: menuOption,
})),
});
return selectedMenuOption;
}
}
@@ -1,38 +0,0 @@
import { SelectChannel } from '../../channel/actions/SelectChannel';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { Ora, ora } from '../../ora';
import { Edge, QueryParams } from '../../utils/relay';
import { isRollout } from '../branch-mapping';
/**
* Select an existing rollout for the project.
*/
export class SelectRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment | null> {
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
let assetSpinner: Ora | null = null;
const afterEachFilterQuery = (
_externalQueryParams: QueryParams,
totalNodesFetched: number,
_dataset: Edge<UpdateChannelBasicInfoFragment>[],
willFetchAgain: boolean
): void => {
if (willFetchAgain && !assetSpinner) {
assetSpinner = ora().start('Fetching channels...');
}
if (assetSpinner) {
assetSpinner.text = `Fetched ${totalNodesFetched} channels`;
}
};
const selectChannelAction = new SelectChannel({
printedType: 'rollout',
filterPredicate: isRollout,
afterEachFilterQuery,
});
const channelInfo = await selectChannelAction.runAsync(ctx);
if (assetSpinner) {
(assetSpinner as Ora).succeed(`Fetched all channels`);
}
return channelInfo;
}
}
@@ -1,201 +0,0 @@
import assert from 'assert';
import chalk from 'chalk';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { RuntimeFragment, UpdateBranchBasicInfoFragment } from '../../graphql/generated';
import { RuntimeQuery } from '../../graphql/queries/RuntimeQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log, { learnMore } from '../../log';
import { confirmAsync } from '../../prompts';
import { Connection, QueryParams, selectPaginatedAsync } from '../../utils/relay';
import { formatRuntimeWithUpdateGroup } from '../utils';
/**
* Select a runtime from a branch
*/
export class SelectRuntime implements EASUpdateAction<string | null> {
private printedType;
constructor(
private branchInfo: UpdateBranchBasicInfoFragment,
private options: {
anotherBranchToIntersectRuntimesBy?: UpdateBranchBasicInfoFragment;
} = {}
) {
this.printedType = options.anotherBranchToIntersectRuntimesBy
? `compatible runtime`
: `runtime`;
}
warnNoRuntime(): void {
if (this.options.anotherBranchToIntersectRuntimesBy) {
const intersectBranchName = this.options.anotherBranchToIntersectRuntimesBy.name;
Log.warn(
`⚠️ Branches ${this.branchInfo.name} and ${intersectBranchName} dont have any updates with the same runtime.`
);
Log.warn(
`Your updates could be misconfigured. ${learnMore('https://expo.fyi/eas-update-rollouts')}`
);
} else {
// no runtime on branch means no updates published on branch
Log.warn(`⚠️ There are no updates published on branch ${this.branchInfo.name}.`);
}
}
formatCantFindRuntime(): string {
return `🕵️ Not finding the update you were looking for? ${learnMore(
'https://expo.fyi/eas-update-rollouts'
)}`;
}
public async runAsync(ctx: EASUpdateContext): Promise<string | null> {
const { nonInteractive, graphqlClient, app } = ctx;
const { projectId } = app;
if (nonInteractive) {
throw new NonInteractiveError(`runtime selection cannot be run in non-interactive mode.`);
}
const newestRuntimeConnection = await this.getNewestRuntimeAsync(graphqlClient, {
appId: projectId,
branchName: this.branchInfo.name,
anotherBranchIdToIntersectRuntimesBy: this.options.anotherBranchToIntersectRuntimesBy?.id,
});
if (newestRuntimeConnection.edges.length === 0) {
Log.addNewLineIfNone();
this.warnNoRuntime();
return null;
}
const onlyOneRuntime =
newestRuntimeConnection.edges.length === 1 && !newestRuntimeConnection.pageInfo.hasNextPage;
Log.log(`✅ ${beginSentence(this.printedType)}${onlyOneRuntime ? '' : 's'} detected`);
if (onlyOneRuntime) {
const runtime = newestRuntimeConnection.edges[0].node;
const formattedRuntimeWithGroup = await this.displayLatestUpdateGroupAsync({
graphqlClient,
appId: projectId,
branchName: this.branchInfo.name,
runtime,
});
Log.addNewLineIfNone();
Log.log(formattedRuntimeWithGroup);
Log.addNewLineIfNone();
const useRuntime = await confirmAsync({
message: `Target ${this.printedType} ${chalk.bold(runtime.version)}?`,
});
if (useRuntime) {
return runtime.version;
} else {
Log.newLine();
Log.warn(this.formatCantFindRuntime());
return null;
}
}
Log.log(this.formatCantFindRuntime());
const selectedRuntime = await this.selectRuntimesAsync(graphqlClient, {
appId: projectId,
});
if (!selectedRuntime) {
throw new Error(`No ${this.printedType} selected`);
}
return selectedRuntime.version;
}
async getNewestRuntimeAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
anotherBranchIdToIntersectRuntimesBy,
}: {
appId: string;
branchName: string;
anotherBranchIdToIntersectRuntimesBy?: string;
}
): Promise<Connection<RuntimeFragment>> {
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: branchName,
first: 1,
filter: {
branchId: anotherBranchIdToIntersectRuntimesBy,
},
});
}
async displayLatestUpdateGroupAsync({
graphqlClient,
appId,
branchName,
runtime,
}: {
graphqlClient: ExpoGraphqlClient;
appId: string;
branchName: string;
runtime: RuntimeFragment;
}): Promise<string> {
const updateGroups = await UpdateQuery.viewUpdateGroupsOnBranchAsync(graphqlClient, {
appId,
branchName,
limit: 1,
offset: 0,
filter: {
runtimeVersions: [runtime.version],
},
});
assert(
updateGroups.length < 2,
`Expected at most one update group. Received: ${JSON.stringify(updateGroups)}`
);
return formatRuntimeWithUpdateGroup(updateGroups[0], runtime, branchName);
}
async selectRuntimesAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
batchSize = 5,
}: {
appId: string;
batchSize?: number;
}
): Promise<RuntimeFragment | null> {
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: this.branchInfo.name,
first: queryParams.first,
after: queryParams.after,
last: queryParams.last,
before: queryParams.before,
filter: {
branchId: this.options.anotherBranchToIntersectRuntimesBy?.id,
},
});
};
const getTitleAsync = async (runtime: RuntimeFragment): Promise<string> => {
return await this.displayLatestUpdateGroupAsync({
graphqlClient,
appId,
branchName: this.branchInfo.name,
runtime,
});
};
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType: 'target runtime',
pageSize: batchSize,
});
}
}
function beginSentence(phrase: string): string {
if (typeof phrase !== 'string' || phrase.length === 0) {
return phrase; // Return the input without any modification if it's not a string or empty
}
return phrase.charAt(0).toUpperCase() + phrase.slice(1);
}
@@ -1,88 +0,0 @@
import { testBasicChannelInfo, testBasicChannelInfo2 } from '../../../channel/__tests__/fixtures';
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import { promptAsync } from '../../../prompts';
import { standardBranchMapping } from '../../__tests__/fixtures';
import { SelectRollout } from '../SelectRollout';
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/ChannelQuery');
describe(SelectRollout, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no rollouts', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBeNull();
});
it('still prompts if project has only one rollout', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicChannelInfo,
}));
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBe(testBasicChannelInfo);
expect(promptAsync).toHaveBeenCalledTimes(1);
});
it('returns the selected rollout', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicChannelInfo,
}));
const ctx = createCtxMock();
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBe(testBasicChannelInfo);
});
it('filters the channels for rollouts', async () => {
const standardBranchMappingChannel = { ...testBasicChannelInfo };
standardBranchMappingChannel.branchMapping = JSON.stringify(standardBranchMapping);
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: standardBranchMappingChannel, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBeNull();
});
it('throws an error in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const selectRollout = new SelectRollout();
// dont fail if users are running in non-interactive mode
await expect(selectRollout.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
});
});
@@ -1,393 +0,0 @@
import assert from 'assert';
import {
BranchMapping,
BranchMappingAlwaysTrue,
BranchMappingNode,
BranchMappingValidationError,
alwaysTrue,
assertNodeObject,
assertNumber,
assertStatement,
assertString,
getBranchMapping,
getNodesFromStatement,
isAlwaysTrue,
isAndStatement,
isStatement,
} from '../channel/branch-mapping';
import { getUpdateBranch } from '../channel/utils';
import { UpdateChannelBasicInfoFragment } from '../graphql/generated';
import { UpdateBranchObject, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
export type Rollout = LegacyRollout | ConstrainedRollout;
export type RolloutInfo = LegacyRolloutInfo | ConstrainedRolloutInfo;
type ConstrainedRollout = LegacyRollout & {
runtimeVersion: string;
};
type LegacyRollout = {
rolledOutBranch: UpdateBranchObject;
defaultBranch: UpdateBranchObject;
} & LegacyRolloutInfo;
type ConstrainedRolloutInfo = LegacyRolloutInfo & {
runtimeVersion: string;
};
type LegacyRolloutInfo = {
rolledOutBranchId: string;
percentRolledOut: number;
defaultBranchId: string;
};
export type RolloutBranchMapping = LegacyRolloutBranchMapping | ConstrainedRolloutBranchMapping;
type RolloutNode = {
clientKey: 'rolloutToken';
branchMappingOperator: 'hash_lt';
operand: number;
};
type RuntimeVersionNode = {
clientKey: 'runtimeVersion';
branchMappingOperator: '==';
operand: string;
};
type RtvConstrainedRolloutNode =
| ['and', RolloutNode, RuntimeVersionNode]
| ['and', RuntimeVersionNode, RolloutNode];
export type LegacyRolloutBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: RolloutNode;
},
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
}
];
};
export type ConstrainedRolloutBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: RtvConstrainedRolloutNode;
},
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
}
];
};
export function isLegacyRolloutInfo(rollout: RolloutInfo): rollout is LegacyRolloutInfo {
return !isConstrainedRolloutInfo(rollout);
}
export function isConstrainedRolloutInfo(rollout: RolloutInfo): rollout is ConstrainedRolloutInfo {
return 'runtimeVersion' in rollout;
}
export function isConstrainedRollout(rollout: Rollout): rollout is ConstrainedRollout {
return isConstrainedRolloutInfo(rollout);
}
export function getRolloutInfo(basicChannelInfo: UpdateChannelBasicInfoFragment): RolloutInfo {
const rolloutBranchMapping = getRolloutBranchMapping(basicChannelInfo.branchMapping);
return getRolloutInfoFromBranchMapping(rolloutBranchMapping);
}
export function getRolloutInfoFromBranchMapping(branchMapping: RolloutBranchMapping): RolloutInfo {
const rolledOutBranchId = branchMapping.data[0].branchId;
const defaultBranchId = branchMapping.data[1].branchId;
if (isRtvConstrainedRollout(branchMapping)) {
const statementNode = branchMapping.data[0].branchMappingLogic;
assertStatement(statementNode);
const nodesFromStatement = getNodesFromStatement(statementNode);
const runtimeVersionNode = nodesFromStatement.find(isRuntimeVersionNode);
assert(runtimeVersionNode, 'Runtime version node must be defined.');
assertNodeObject(runtimeVersionNode);
const runtimeVersion = runtimeVersionNode.operand;
assertString(runtimeVersion);
const rolloutNode = nodesFromStatement.find(isRolloutNode);
assert(rolloutNode, 'Rollout node must be defined.');
assertNodeObject(rolloutNode);
const operand = rolloutNode.operand;
assertNumber(operand);
return {
rolledOutBranchId,
percentRolledOut: Math.round(operand * 100),
runtimeVersion,
defaultBranchId,
};
} else {
const rolloutNode = branchMapping.data[0].branchMappingLogic;
assertNodeObject(rolloutNode);
const operand = rolloutNode.operand;
assertNumber(operand);
return {
rolledOutBranchId,
percentRolledOut: Math.round(operand * 100),
defaultBranchId,
};
}
}
export function getRollout(channel: UpdateChannelObject): Rollout {
const rolloutBranchMapping = getRolloutBranchMapping(channel.branchMapping);
const rolledOutBranchId = rolloutBranchMapping.data[0].branchId;
const rolledOutBranch = getUpdateBranch(channel, rolledOutBranchId);
const defaultBranchId = rolloutBranchMapping.data[1].branchId;
const defaultBranch = getUpdateBranch(channel, defaultBranchId);
const rolloutInfo = getRolloutInfo(channel);
return composeRollout(rolloutInfo, defaultBranch, rolledOutBranch);
}
export function composeRollout(
rolloutInfo: RolloutInfo,
defaultBranch: UpdateBranchObject,
rolledOutBranch: UpdateBranchObject
): Rollout {
if (rolloutInfo.defaultBranchId !== defaultBranch.id) {
throw new BranchMappingValidationError(
`Default branch id must match. Received: ${JSON.stringify(rolloutInfo)} ${defaultBranch.id}`
);
}
if (rolloutInfo.rolledOutBranchId !== rolledOutBranch.id) {
throw new BranchMappingValidationError(
`Rolled out branch id must match. Received: ${JSON.stringify(rolloutInfo)} ${
rolledOutBranch.id
}`
);
}
return {
...rolloutInfo,
rolledOutBranch,
defaultBranch,
};
}
export function getRolloutBranchMapping(branchMappingString: string): RolloutBranchMapping {
const branchMapping = getBranchMapping(branchMappingString);
assertRolloutBranchMapping(branchMapping);
return branchMapping;
}
/**
* Detect if a branch mapping is a rollout.
*
* Types of rollout:
* 1. Legacy unconstrained rollout:
* Maps to a rollout branch via a rollout token
* Falls back to a default branch
*
* Example:
* {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
}
*
* 2. RTV constrained rollout:
* Maps to a rollout branch via a rollout token, constrained by runtime version
* Falls back to a default branch
*
* Example:
* {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
}
*/
export function isRolloutBranchMapping(
branchMapping: BranchMapping
): branchMapping is RolloutBranchMapping {
return isUnconstrainedRollout(branchMapping) || isRtvConstrainedRollout(branchMapping);
}
export function isRollout(channelInfo: UpdateChannelBasicInfoFragment): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isRolloutBranchMapping(branchMapping);
}
export function createRolloutBranchMapping({
defaultBranchId,
rolloutBranchId,
percent,
runtimeVersion,
}: {
defaultBranchId: string;
rolloutBranchId: string;
percent: number;
runtimeVersion: string;
}): ConstrainedRolloutBranchMapping {
assertPercent(percent);
return {
version: 0,
data: [
{
branchId: rolloutBranchId,
branchMappingLogic: [
'and',
{
operand: runtimeVersion,
clientKey: 'runtimeVersion',
branchMappingOperator: '==',
},
{
operand: percent / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
],
},
{ branchId: defaultBranchId, branchMappingLogic: alwaysTrue() },
],
};
}
export function editRolloutBranchMapping(
branchMapping: RolloutBranchMapping,
percent: number
): RolloutBranchMapping {
assertPercent(percent);
if (isRtvConstrainedRollout(branchMapping)) {
return editRtvConstrainedRollout(branchMapping, percent);
} else {
return editLegacyRollout(branchMapping, percent);
}
}
function editRtvConstrainedRollout(
branchMapping: ConstrainedRolloutBranchMapping,
percent: number
): ConstrainedRolloutBranchMapping {
const newBranchMapping = { ...branchMapping };
const statementNode = newBranchMapping.data[0].branchMappingLogic;
const nodesFromStatement = getNodesFromStatement(statementNode);
const rolloutNode = nodesFromStatement.find(isRolloutNode);
assert(rolloutNode, 'Rollout node must be defined.');
rolloutNode.operand = percent / 100;
return newBranchMapping;
}
function editLegacyRollout(
branchMapping: LegacyRolloutBranchMapping,
percent: number
): LegacyRolloutBranchMapping {
const newBranchMapping = { ...branchMapping };
const rolloutNode = newBranchMapping.data[0].branchMappingLogic;
rolloutNode.operand = percent / 100;
return newBranchMapping;
}
function isRtvConstrainedRollout(
branchMapping: BranchMapping
): branchMapping is ConstrainedRolloutBranchMapping {
if (branchMapping.data.length !== 2) {
return false;
}
const hasRtvRolloutNode = isRtvConstrainedRolloutNode(branchMapping.data[0].branchMappingLogic);
const defaultsToAlwaysTrueNode = isAlwaysTrue(branchMapping.data[1].branchMappingLogic);
return hasRtvRolloutNode && defaultsToAlwaysTrueNode;
}
function isRtvConstrainedRolloutNode(node: BranchMappingNode): node is RtvConstrainedRolloutNode {
if (!isStatement(node) || !isAndStatement(node)) {
return false;
}
const statementNodes = getNodesFromStatement(node);
if (statementNodes.length !== 2) {
return false;
}
const hasRuntimeVersionNode = statementNodes.some(isRuntimeVersionNode);
const hasRolloutNode = statementNodes.some(isRolloutNode);
return hasRuntimeVersionNode && hasRolloutNode;
}
function isUnconstrainedRollout(
branchMapping: BranchMapping
): branchMapping is LegacyRolloutBranchMapping {
if (branchMapping.data.length !== 2) {
return false;
}
const hasRolloutNode = isRolloutNode(branchMapping.data[0].branchMappingLogic);
const defaultsToAlwaysTrueNode = isAlwaysTrue(branchMapping.data[1].branchMappingLogic);
return hasRolloutNode && defaultsToAlwaysTrueNode;
}
function isRuntimeVersionNode(node: BranchMappingNode): node is RuntimeVersionNode {
if (typeof node === 'string') {
return false;
}
if (Array.isArray(node)) {
return false;
}
return node.clientKey === 'runtimeVersion' && node.branchMappingOperator === '==';
}
function isRolloutNode(node: BranchMappingNode): node is RolloutNode {
if (typeof node === 'string') {
return false;
}
if (Array.isArray(node)) {
return false;
}
return node.clientKey === 'rolloutToken' && node.branchMappingOperator === 'hash_lt';
}
export function assertRolloutBranchMapping(
branchMapping: BranchMapping
): asserts branchMapping is RolloutBranchMapping {
if (!isRolloutBranchMapping(branchMapping)) {
throw new BranchMappingValidationError(
'Branch mapping node must be a rollout. Received: ' + JSON.stringify(branchMapping)
);
}
}
function assertPercent(percent: number): void {
const isPercent = Number.isInteger(percent) && percent >= 0 && percent <= 100;
if (!isPercent) {
throw new BranchMappingValidationError(
`The percentage must be an integer between 0 and 100 inclusive. Received: ${percent}`
);
}
}
-112
View File
@@ -1,112 +0,0 @@
import chalk from 'chalk';
import { RuntimeFragment, UpdateFragment } from '../graphql/generated';
import { UpdateBranchObject, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import { promptAsync } from '../prompts';
import { FormattedUpdateGroupDescription, getUpdateGroupDescriptions } from '../update/utils';
import formatFields from '../utils/formatFields';
import { Rollout, getRollout, isConstrainedRollout } from './branch-mapping';
export function printRollout(channel: UpdateChannelObject): void {
const rollout = getRollout(channel);
displayRolloutDetails(channel.name, rollout);
}
export function displayRolloutDetails(channelName: string, rollout: Rollout): void {
const rolledOutPercent = rollout.percentRolledOut;
Log.newLine();
Log.log(chalk.bold('🚀 Rollout:'));
Log.log(
formatFields([
{ label: 'Channel', value: channelName },
...(isConstrainedRollout(rollout)
? [{ label: 'Runtime version', value: rollout.runtimeVersion }]
: []),
{
label: 'Branches',
value: `${rollout.rolledOutBranch.name} (${rolledOutPercent}%), ${
rollout.defaultBranch.name
} (${100 - rolledOutPercent}%)`,
},
])
);
Log.addNewLineIfNone();
}
export function formatBranchWithUpdateGroup(
maybeUpdateGroup: UpdateFragment[] | undefined | null,
branch: UpdateBranchObject,
percentRolledOut: number
): string {
const lines: string[] = [];
lines.push(
chalk.bold(
`➡️ 📱 Latest update on the ${chalk.bold(branch.name)} branch (${percentRolledOut}%)`
)
);
if (!maybeUpdateGroup) {
lines.push(`No updates for target runtime`);
} else {
const [updateGroupDescription] = getUpdateGroupDescriptions([maybeUpdateGroup]);
lines.push(...formatUpdateGroup(updateGroupDescription));
}
return lines.join('\n ');
}
export function formatRuntimeWithUpdateGroup(
maybeUpdateGroup: UpdateFragment[] | undefined | null,
runtime: RuntimeFragment,
branchName: string
): string {
const lines: string[] = [];
lines.push(
chalk.bold(
`➡️ 📱 Latest update on the ${chalk.bold(branchName)} branch served to runtime ${chalk.bold(
runtime.version
)}:`
)
);
if (!maybeUpdateGroup) {
lines.push(`No updates published for this runtime`);
} else {
const [updateGroupDescription] = getUpdateGroupDescriptions([maybeUpdateGroup]);
lines.push(...formatUpdateGroup(updateGroupDescription));
}
return lines.join('\n ');
}
function formatUpdateGroup(updateGroup: FormattedUpdateGroupDescription): string[] {
const lines: string[] = [];
const formattedLines = formatFields([
{ label: 'Message', value: updateGroup.message ?? 'N/A' },
{ label: 'Runtime version', value: updateGroup.runtimeVersion ?? 'N/A' },
{ label: 'Platforms', value: updateGroup.platforms ?? 'N/A' },
{ label: 'Group ID', value: updateGroup.group ?? 'N/A' },
]).split('\n');
lines.push(...formattedLines);
return lines;
}
export async function promptForRolloutPercentAsync({
promptMessage,
}: {
promptMessage: string;
}): Promise<number> {
const { name: rolloutPercent } = await promptAsync({
type: 'text',
name: 'name',
format: value => {
return parseInt(value, 10);
},
message: promptMessage,
initial: 0,
validate: (rolloutPercent: string): true | string => {
const floatValue = parseFloat(rolloutPercent);
return Number.isInteger(floatValue) && floatValue >= 0 && floatValue <= 100
? true
: 'The rollout percentage must be an integer between 0 and 100 inclusive.';
},
});
return rolloutPercent;
}
@@ -12,7 +12,7 @@ import {
AmbiguousApplicationIdError,
getApplicationIdAsync,
} from '../../project/android/applicationId';
import capitalizeFirstLetter from '../../utils/expodash/capitalize';
import capitalize from '../../utils/expodash/capitalize';
import { ArchiveSource } from '../ArchiveSource';
import { resolveArchiveSource } from '../commons';
import { SubmissionContext } from '../context';
@@ -33,12 +33,9 @@ export default class AndroidSubmitCommand {
const track = this.resolveTrack();
const releaseStatus = this.resolveReleaseStatus();
const archiveSource = this.resolveArchiveSource();
const rollout = this.resolveRollout();
const serviceAccountSource = await this.resolveServiceAccountSourceAsync();
const errored = [track, releaseStatus, archiveSource, serviceAccountSource, rollout].filter(
r => !r.ok
);
const errored = [track, releaseStatus, archiveSource, serviceAccountSource].filter(r => !r.ok);
if (errored.length > 0) {
const message = errored.map(err => err.reason?.message).join('\n');
Log.error(message);
@@ -49,7 +46,6 @@ export default class AndroidSubmitCommand {
projectId: this.ctx.projectId,
track: track.enforceValue(),
releaseStatus: releaseStatus.enforceValue(),
rollout: rollout.enforceValue(),
archiveSource: archiveSource.enforceValue(),
serviceAccountSource: serviceAccountSource.enforceValue(),
changesNotSentForReview: this.ctx.profile.changesNotSentForReview,
@@ -77,7 +73,7 @@ export default class AndroidSubmitCommand {
if (!track) {
return result(SubmissionAndroidTrack.Internal);
}
const capitalizedTrack = capitalizeFirstLetter(track);
const capitalizedTrack = capitalize(track);
if (capitalizedTrack in SubmissionAndroidTrack) {
return result(
SubmissionAndroidTrack[capitalizedTrack as keyof typeof SubmissionAndroidTrack]
@@ -98,7 +94,7 @@ export default class AndroidSubmitCommand {
if (!releaseStatus) {
return result(SubmissionAndroidReleaseStatus.Completed);
}
const capitalizedReleaseStatus = capitalizeFirstLetter(releaseStatus);
const capitalizedReleaseStatus = capitalize(releaseStatus);
if (capitalizedReleaseStatus in SubmissionAndroidReleaseStatus) {
return result(
SubmissionAndroidReleaseStatus[
@@ -116,12 +112,6 @@ export default class AndroidSubmitCommand {
}
}
private resolveRollout(): Result<number | undefined> {
const { rollout } = this.ctx.profile;
return result(rollout);
}
private resolveArchiveSource(): Result<ArchiveSource> {
try {
return result(resolveArchiveSource(this.ctx));
@@ -27,7 +27,7 @@ import {
export interface AndroidSubmissionOptions
extends Pick<
AndroidSubmissionConfigInput,
'track' | 'releaseStatus' | 'changesNotSentForReview' | 'rollout'
'track' | 'releaseStatus' | 'changesNotSentForReview'
> {
projectId: string;
archiveSource: ArchiveSource;
@@ -111,12 +111,11 @@ export default class AndroidSubmitter extends BaseSubmitter<
options: AndroidSubmissionOptions,
{ serviceAccountKeyResult }: ResolvedSourceOptions
): AndroidSubmissionConfigInput {
const { track, releaseStatus, changesNotSentForReview, rollout } = options;
const { track, releaseStatus, changesNotSentForReview } = options;
return {
track,
changesNotSentForReview,
releaseStatus,
rollout,
...serviceAccountKeyResult.result,
};
}
@@ -125,7 +124,7 @@ export default class AndroidSubmitter extends BaseSubmitter<
options: AndroidSubmissionOptions,
{ archive, serviceAccountKeyResult }: ResolvedSourceOptions
): SummaryData {
const { projectId, track, releaseStatus, changesNotSentForReview, rollout } = options;
const { projectId, track, releaseStatus, changesNotSentForReview } = options;
// structuring order affects table rows order
return {
@@ -134,7 +133,6 @@ export default class AndroidSubmitter extends BaseSubmitter<
changesNotSentForReview: changesNotSentForReview ?? undefined,
releaseStatus: releaseStatus ?? undefined,
formattedServiceAccount: formatServiceAccountSummary(serviceAccountKeyResult),
rollout: rollout ?? undefined,
...formatArchiveSourceSummary(archive),
};
}
@@ -145,7 +143,6 @@ type SummaryData = {
formattedServiceAccount: string;
projectId: string;
releaseStatus?: SubmissionAndroidReleaseStatus;
rollout?: number;
track: SubmissionAndroidTrack;
} & ArchiveSourceSummaryFields;
@@ -157,7 +154,6 @@ const SummaryHumanReadableKeys: Record<keyof SummaryData, string> = {
formattedServiceAccount: 'Google Service Account Key',
projectId: 'Project ID',
releaseStatus: 'Release status',
rollout: 'Rollout',
track: 'Release track',
};
@@ -145,46 +145,6 @@ describe(AndroidSubmitCommand, () => {
});
});
it('sends a request to EAS Submit when using inProgress status', async () => {
const projectId = uuidv4();
const graphqlClient = {} as any as ExpoGraphqlClient;
const analytics = instance(mock<Analytics>());
const ctx = await createSubmissionContextAsync({
platform: Platform.ANDROID,
projectDir: testProject.projectRoot,
archiveFlags: {
url: 'http://expo.dev/fake.apk',
},
profile: {
serviceAccountKeyPath: '/google-service-account.json',
track: AndroidReleaseTrack.internal,
releaseStatus: AndroidReleaseStatus.inProgress,
changesNotSentForReview: false,
},
nonInteractive: false,
actor: mockJester,
graphqlClient,
analytics,
exp: testProject.appJSON.expo,
projectId,
});
const command = new AndroidSubmitCommand(ctx);
await command.runAsync();
expect(SubmissionMutation.createAndroidSubmissionAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
archiveSource: { type: SubmissionArchiveSourceType.Url, url: 'http://expo.dev/fake.apk' },
config: {
googleServiceAccountKeyJson: fakeFiles['/google-service-account.json'],
releaseStatus: SubmissionAndroidReleaseStatus.InProgress,
track: SubmissionAndroidTrack.Internal,
changesNotSentForReview: false,
},
});
});
it('assigns the build ID to submission', async () => {
const projectId = uuidv4();
const graphqlClient = {} as any as ExpoGraphqlClient;
@@ -1,52 +0,0 @@
import { Workflow } from '@expo/eas-build-job';
import {
DEFAULT_BARE_RUNTIME_VERSION,
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49,
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48,
getDefaultRuntimeVersion,
} from '../configure';
describe(getDefaultRuntimeVersion, () => {
it('gets the right rtv version/policy', () => {
expect(getDefaultRuntimeVersion(Workflow.MANAGED, '48.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, '49.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, '50.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, 'sdf')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, undefined)).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, '48.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, '49.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, '50.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, 'sdf')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, undefined)).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, '48.0.0')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, '49.0.0')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, '50.0.0')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, 'sdf')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, undefined)).toBe(
DEFAULT_BARE_RUNTIME_VERSION
);
});
});
@@ -115,7 +115,6 @@ function mockUpdateBranches(branchNames: string[]): UpdateBranch[] {
createdAt: '2022-12-07T02:24:29.786Z',
updatedAt: '2022-12-07T02:24:29.786Z',
updates: [],
runtimes: { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } },
updateGroups: [
[
{
@@ -149,7 +148,6 @@ function mockUpdateBranches(branchNames: string[]): UpdateBranch[] {
updatedAt: '2022-12-07T02:24:29.786Z',
updateGroups: [],
updates: [],
runtimes: { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } },
__typename: 'UpdateBranch' as const,
},
codeSigningInfo: null,
+19 -97
View File
@@ -3,40 +3,29 @@ import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJsonAccessor } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import semver from 'semver';
import { getEASUpdateURL } from '../api';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../graphql/generated';
import Log, { learnMore } from '../log';
import { RequestedPlatform, appPlatformDisplayNames } from '../platform';
import { createOrModifyExpoConfigAsync, isUsingStaticExpoConfig } from '../project/expoConfig';
import { createOrModifyExpoConfigAsync } from '../project/expoConfig';
import {
installExpoUpdatesAsync,
isExpoUpdatesInstalledAsDevDependency,
isExpoUpdatesInstalledOrAvailable,
} from '../project/projectUtils';
import { resolveWorkflowPerPlatformAsync } from '../project/workflow';
import { confirmAsync } from '../prompts';
import { syncUpdatesConfigurationAsync as syncAndroidUpdatesConfigurationAsync } from './android/UpdatesModule';
import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } from './ios/UpdatesModule';
export const DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49 = { policy: 'appVersion' } as const;
export const DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48 = { policy: 'sdkVersion' } as const;
export const DEFAULT_MANAGED_RUNTIME_VERSION = { policy: 'sdkVersion' } as const;
export const DEFAULT_BARE_RUNTIME_VERSION = '1.0.0' as const;
export function getDefaultRuntimeVersion(
workflow: Workflow,
sdkVersion: string | undefined
): NonNullable<ExpoConfig['runtimeVersion']> {
if (workflow === Workflow.GENERIC) {
return DEFAULT_BARE_RUNTIME_VERSION;
}
// Expo Go supports loading appVersion SDK 49 and above
const hasSupportedSdk = sdkVersion && semver.satisfies(sdkVersion, '>= 49.0.0');
return hasSupportedSdk
? DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
: DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48;
function getDefaultRuntimeVersion(workflow: Workflow): NonNullable<ExpoConfig['runtimeVersion']> {
return workflow === Workflow.GENERIC
? DEFAULT_BARE_RUNTIME_VERSION
: DEFAULT_MANAGED_RUNTIME_VERSION;
}
function isRuntimeEqual(
@@ -118,10 +107,8 @@ async function ensureEASUpdatesIsConfiguredInExpoConfigAsync({
(['all', 'android'].includes(platform) && !androidRuntimeVersion) ||
(['all', 'ios'].includes(platform) && !iosRuntimeVersion)
) {
androidRuntimeVersion =
androidRuntimeVersion ?? getDefaultRuntimeVersion(workflows.android, exp.sdkVersion);
iosRuntimeVersion =
iosRuntimeVersion ?? getDefaultRuntimeVersion(workflows.ios, exp.sdkVersion);
androidRuntimeVersion = androidRuntimeVersion ?? getDefaultRuntimeVersion(workflows.android);
iosRuntimeVersion = iosRuntimeVersion ?? getDefaultRuntimeVersion(workflows.ios);
if (platform === 'all' && isRuntimeEqual(androidRuntimeVersion, iosRuntimeVersion)) {
modifyConfig.runtimeVersion = androidRuntimeVersion;
@@ -167,16 +154,6 @@ async function ensureEASUpdatesIsConfiguredInExpoConfigAsync({
}
}
function serializeRuntimeVersionToString(
runtimeVersion: NonNullable<ExpoConfig['runtimeVersion']>
): string {
if (typeof runtimeVersion === 'object') {
return JSON.stringify(runtimeVersion);
} else {
return runtimeVersion;
}
}
function logEasUpdatesAutoConfig({
modifyConfig,
exp,
@@ -198,21 +175,21 @@ function logEasUpdatesAutoConfig({
Log.withTick(
`Configured runtimeVersion for ${appPlatformDisplayNames[AppPlatform.Android]} and ${
appPlatformDisplayNames[AppPlatform.Ios]
} with "${serializeRuntimeVersionToString(androidRuntime)}"`
} with "${modifyConfig.android?.runtimeVersion ?? modifyConfig.runtimeVersion}"`
);
} else {
if (androidRuntime) {
Log.withTick(
`Configured runtimeVersion for ${
appPlatformDisplayNames[AppPlatform.Android]
} with "${serializeRuntimeVersionToString(androidRuntime)}"`
`Configured runtimeVersion for ${appPlatformDisplayNames[AppPlatform.Android]} with "${
modifyConfig.android?.runtimeVersion ?? modifyConfig.runtimeVersion
}"`
);
}
if (iosRuntime) {
Log.withTick(
`Configured runtimeVersion for ${
appPlatformDisplayNames[AppPlatform.Ios]
} with "${serializeRuntimeVersionToString(iosRuntime)}"`
`Configured runtimeVersion for ${appPlatformDisplayNames[AppPlatform.Ios]} with "${
modifyConfig.ios?.runtimeVersion ?? modifyConfig.runtimeVersion
}"`
);
}
}
@@ -347,8 +324,7 @@ export async function ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir: stri
/**
* Make sure EAS Update is fully configured in the current project.
* This goes over a checklist and performs the following checks or changes:
* - Ensure `updates.useClassicUpdates` (SDK 49) is not set in the app config
* - Ensure the `expo-updates` package is currently installed.
* - Enure the `expo-updates` package is currently installed.
* - Ensure `app.json` is configured for EAS Updates
* - Sets `runtimeVersion` if not set
* - Sets `updates.url` if not set
@@ -357,7 +333,7 @@ export async function ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir: stri
export async function ensureEASUpdateIsConfiguredAsync(
graphqlClient: ExpoGraphqlClient,
{
exp: expMaybeWithoutUpdates,
exp: expWithoutUpdates,
projectId,
projectDir,
platform,
@@ -368,17 +344,9 @@ export async function ensureEASUpdateIsConfiguredAsync(
platform: RequestedPlatform | null;
}
): Promise<void> {
// EAS Update and SDK 49's "useClassicUpdates" option are mutually exclusive
if (expMaybeWithoutUpdates.updates?.useClassicUpdates) {
expMaybeWithoutUpdates = await ensureUseClassicUpdatesIsRemovedAsync({
exp: expMaybeWithoutUpdates,
projectDir,
});
}
const hasExpoUpdates = isExpoUpdatesInstalledOrAvailable(
projectDir,
expMaybeWithoutUpdates.sdkVersion
expWithoutUpdates.sdkVersion
);
const hasExpoUpdatesInDevDependencies = isExpoUpdatesInstalledAsDevDependency(projectDir);
if (!hasExpoUpdates && !hasExpoUpdatesInDevDependencies) {
@@ -399,7 +367,7 @@ export async function ensureEASUpdateIsConfiguredAsync(
const workflows = await resolveWorkflowPerPlatformAsync(projectDir);
const { projectChanged, exp: expWithUpdates } =
await ensureEASUpdatesIsConfiguredInExpoConfigAsync({
exp: expMaybeWithoutUpdates,
exp: expWithoutUpdates,
projectDir,
projectId,
platform,
@@ -424,49 +392,3 @@ export async function ensureEASUpdateIsConfiguredAsync(
Log.newLine();
}
}
export async function ensureUseClassicUpdatesIsRemovedAsync({
exp: expMaybeWithoutUpdates,
projectDir,
}: {
exp: ExpoConfig;
projectDir: string;
}): Promise<ExpoConfig> {
if (!isUsingStaticExpoConfig(projectDir)) {
throw new Error(
`Your app config sets "updates.useClassicUpdates" but EAS Update does not support classic updates. Remove "useClassicUpdates" from your app config and run this command again.`
);
}
const shouldEditConfig = await confirmAsync({
message: `Your app config sets "updates.useClassicUpdates" but EAS Update does not support classic updates. Remove "updates.useClassicUpdates" from your app config?`,
});
if (!shouldEditConfig) {
throw new Error(
`Manually remove "updates.useClassicUpdates" from your app config and run this command again.`
);
}
const editedExpoConfig = mergeExpoConfig(expMaybeWithoutUpdates, {
updates: { useClassicUpdates: undefined },
}) as ExpoConfig;
const result = await createOrModifyExpoConfigAsync(projectDir, editedExpoConfig);
switch (result.type) {
case 'success':
Log.withTick(`Removed "updates.useClassicUpdates"`);
expMaybeWithoutUpdates = editedExpoConfig;
break;
case 'warn':
case 'fail':
throw new Error(result.message);
default:
throw new Error(
`Unexpected result type "${result.type}" received when modifying the project config.`
);
}
return editedExpoConfig;
}
-179
View File
@@ -1,179 +0,0 @@
import { ExpoConfig } from '@expo/config';
import assert from 'assert';
import nullthrows from 'nullthrows';
import { getUpdateGroupUrl } from '../build/utils/url';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import fetch from '../fetch';
import { Update, UpdateInfoGroup } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import Log, { link } from '../log';
import { ora } from '../ora';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import {
CodeSigningInfo,
checkManifestBodyAgainstUpdateInfoGroup,
getManifestBodyAsync,
signBody,
} from '../utils/code-signing';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
export type UpdateToRepublish = {
groupId: string;
branchId: string;
branchName: string;
} & Pick<
Update,
| 'message'
| 'runtimeVersion'
| 'manifestFragment'
| 'platform'
| 'gitCommitHash'
| 'codeSigningInfo'
>;
/**
* @param updatesToPublish The update group to republish
* @param targetBranch The branch to repubish the update group on
*/
export async function republishAsync({
graphqlClient,
app,
updatesToPublish,
targetBranch,
updateMessage,
codeSigningInfo,
json,
}: {
graphqlClient: ExpoGraphqlClient;
app: { exp: ExpoConfig; projectId: string };
updatesToPublish: UpdateToRepublish[];
targetBranch: { branchName: string; branchId: string };
updateMessage: string;
codeSigningInfo?: CodeSigningInfo;
json?: boolean;
}): Promise<void> {
const { branchName: targetBranchName, branchId: targetBranchId } = targetBranch;
// The update group properties are the same for all updates
assert(updatesToPublish.length > 0, 'Updates to republish must be provided');
const arbitraryUpdate = updatesToPublish[0];
const isSameGroup = (update: UpdateToRepublish): boolean =>
update.groupId === arbitraryUpdate.groupId &&
update.branchId === arbitraryUpdate.branchId &&
update.branchName === arbitraryUpdate.branchName &&
update.runtimeVersion === arbitraryUpdate.runtimeVersion;
assert(updatesToPublish.every(isSameGroup), 'All updates must belong to the same update group');
const { runtimeVersion } = arbitraryUpdate;
// If codesigning was created for the original update, we need to add it to the republish.
// If one wishes to not sign the republish or sign with a different key, a normal publish should
// be performed.
const shouldRepublishWithCodesigning = updatesToPublish.some(update => update.codeSigningInfo);
if (shouldRepublishWithCodesigning) {
if (!codeSigningInfo) {
throw new Error(
'Must specify --private-key-path argument to sign republished update group for code signing'
);
}
for (const update of updatesToPublish) {
if (
nullthrows(update.codeSigningInfo).alg !== codeSigningInfo.codeSigningMetadata.alg ||
nullthrows(update.codeSigningInfo).keyid !== codeSigningInfo.codeSigningMetadata.keyid
) {
throw new Error(
'Republished updates must use the same code signing key and algorithm as original update'
);
}
}
Log.withTick(`The republished update group will be signed`);
}
const publishIndicator = ora('Republishing...').start();
let updatesRepublished: Awaited<ReturnType<typeof PublishMutation.publishUpdateGroupAsync>>;
try {
const updateInfoGroup = Object.fromEntries(
updatesToPublish.map(update => [update.platform, JSON.parse(update.manifestFragment)])
);
updatesRepublished = await PublishMutation.publishUpdateGroupAsync(graphqlClient, [
{
branchId: targetBranchId,
runtimeVersion,
message: updateMessage,
updateInfoGroup,
gitCommitHash: updatesToPublish[0].gitCommitHash,
awaitingCodeSigningInfo: !!codeSigningInfo,
},
]);
if (codeSigningInfo) {
Log.log('🔒 Signing republished update group');
await Promise.all(
updatesRepublished.map(async newUpdate => {
const response = await fetch(newUpdate.manifestPermalink, {
method: 'GET',
headers: { accept: 'multipart/mixed' },
});
const manifestBody = nullthrows(await getManifestBodyAsync(response));
checkManifestBodyAgainstUpdateInfoGroup(
manifestBody,
nullthrows(nullthrows(updateInfoGroup)[newUpdate.platform as keyof UpdateInfoGroup])
);
const manifestSignature = signBody(manifestBody, codeSigningInfo);
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
alg: codeSigningInfo.codeSigningMetadata.alg,
keyid: codeSigningInfo.codeSigningMetadata.keyid,
sig: manifestSignature,
});
})
);
}
publishIndicator.succeed('Republished update group');
} catch (error: any) {
publishIndicator.fail('Failed to republish update group');
throw error;
}
if (json) {
return printJsonOnlyOutput(updatesRepublished);
}
const updatesRepublishedByPlatform = Object.fromEntries(
updatesRepublished.map(update => [update.platform, update])
);
const arbitraryRepublishedUpdate = updatesRepublished[0];
const updateGroupUrl = getUpdateGroupUrl(
(await getOwnerAccountForProjectIdAsync(graphqlClient, app.projectId)).name,
app.exp.slug,
arbitraryRepublishedUpdate.group
);
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Branch', value: targetBranchName },
{ label: 'Runtime version', value: arbitraryRepublishedUpdate.runtimeVersion },
{ label: 'Platform', value: updatesRepublished.map(update => update.platform).join(', ') },
{ label: 'Update group ID', value: arbitraryRepublishedUpdate.group },
...(updatesRepublishedByPlatform.android
? [{ label: 'Android update ID', value: updatesRepublishedByPlatform.android.id }]
: []),
...(updatesRepublishedByPlatform.ios
? [{ label: 'iOS update ID', value: updatesRepublishedByPlatform.ios.id }]
: []),
{ label: 'Message', value: updateMessage },
{ label: 'Website link', value: link(updateGroupUrl, { dim: false }) },
])
);
}
+15 -48
View File
@@ -1,4 +1,3 @@
import assert from 'assert';
import openBrowserAsync from 'better-opn';
import http from 'http';
import { Socket } from 'node:net';
@@ -6,48 +5,22 @@ import querystring from 'querystring';
import Log from '../log';
const successBody = `
<!DOCTYPE html>
<html lang="en">
<head>
<title>Expo SSO Login</title>
<meta charset="utf-8">
<style type="text/css">
html {
margin: 0;
padding: 0
}
body {
background-color: #fff;
font-family: Tahoma,Verdana;
font-size: 16px;
color: #000;
max-width: 100%;
box-sizing: border-box;
padding: .5rem;
margin: 1em;
overflow-wrap: break-word
}
</style>
</head>
<body>
SSO login complete. You may now close this tab and return to the command prompt.
</body>
</html>`;
export async function getSessionUsingBrowserAuthFlowAsync({
expoWebsiteUrl,
}: {
export async function getSessionUsingBrowserAuthFlowAsync(options: {
expoWebsiteUrl: string;
serverPort: number;
}): Promise<string> {
const { expoWebsiteUrl, serverPort } = options;
if (!expoWebsiteUrl || !serverPort) {
throw new Error('Expo website URL and local server port are required.');
}
const scheme = 'http';
const hostname = 'localhost';
const path = '/auth/callback';
const redirectUri = `${scheme}://${hostname}:${serverPort}${path}`;
const buildExpoSsoLoginUrl = (port: number): string => {
const buildExpoSsoLoginUrl = (): string => {
const data = {
app_redirect_uri: `${scheme}://${hostname}:${port}${path}`,
app_redirect_uri: redirectUri,
};
const params = querystring.stringify(data);
return `${expoWebsiteUrl}/sso-login?${params}`;
@@ -71,8 +44,8 @@ export async function getSessionUsingBrowserAuthFlowAsync({
throw new Error('Request missing session_secret search parameter.');
}
resolve(sessionSecret);
response.writeHead(200, { 'Content-Type': 'text/html' });
response.write(successBody);
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.write(`Website login has completed. You can now close this tab.`);
response.end();
} catch (error) {
reject(error);
@@ -86,17 +59,8 @@ export async function getSessionUsingBrowserAuthFlowAsync({
}
);
server.listen(0, hostname, () => {
server.listen(serverPort, hostname, () => {
Log.log('Waiting for browser login...');
const address = server.address();
assert(
address !== null && typeof address === 'object',
'Server address and port should be set after listening has begun'
);
const port = address.port;
const authorizeUrl = buildExpoSsoLoginUrl(port);
openBrowserAsync(authorizeUrl);
});
server.on('connection', connection => {
@@ -106,6 +70,9 @@ export async function getSessionUsingBrowserAuthFlowAsync({
connections.delete(connection);
});
});
const authorizeUrl = buildExpoSsoLoginUrl();
openBrowserAsync(authorizeUrl);
});
};
@@ -1,4 +1,4 @@
import { getExpoWebsiteBaseUrl } from '../api';
import { getExpoWebsiteBaseUrl, getSsoLocalServerPortAsync } from '../api';
import { getSessionUsingBrowserAuthFlowAsync } from './expoSsoLauncher';
import { fetchUserAsync } from './fetchUser';
@@ -7,9 +7,11 @@ export async function fetchSessionSecretAndSsoUserAsync(): Promise<{
id: string;
username: string;
}> {
const sessionSecret = await getSessionUsingBrowserAuthFlowAsync({
const config = {
expoWebsiteUrl: getExpoWebsiteBaseUrl(),
});
serverPort: await getSsoLocalServerPortAsync(),
};
const sessionSecret = await getSessionUsingBrowserAuthFlowAsync(config);
const userData = await fetchUserAsync({ sessionSecret });
@@ -1,782 +0,0 @@
import { promptAsync } from '../../prompts';
import {
Connection,
FilterPagination,
NEXT_PAGE_OPTION,
PREV_PAGE_OPTION,
QueryParams,
selectPaginatedAsync,
} from '../relay';
jest.mock('../../prompts');
describe(FilterPagination, () => {
describe(FilterPagination.getPageAsync, () => {
let queryParams: QueryParams;
let queryAsync: (queryParams: QueryParams) => Promise<Connection<any>>;
let filterPredicate: () => boolean;
let beforeEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[]
) => void;
let afterEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[],
willFetchAgain: boolean
) => void;
beforeEach(() => {
queryParams = {};
queryAsync = jest.fn();
filterPredicate = jest.fn();
beforeEachQuery = jest.fn();
afterEachQuery = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
test('should call getFirstItemsAsync when isFirstAfter is true', async () => {
const originalFirstAfter = FilterPagination.isFirstAfter;
const originalGetFirstItemsAsync = FilterPagination.getFirstItemsAsync;
FilterPagination.isFirstAfter = jest.fn().mockReturnValue(true) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
first: number;
after?: string;
};
FilterPagination.getFirstItemsAsync = jest.fn().mockResolvedValue({});
await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery,
afterEachQuery,
});
expect(FilterPagination.isFirstAfter).toHaveBeenCalledWith(queryParams);
expect(FilterPagination.getFirstItemsAsync).toHaveBeenCalledWith(
queryParams,
expect.any(Object)
);
FilterPagination.isFirstAfter = originalFirstAfter;
FilterPagination.getFirstItemsAsync = originalGetFirstItemsAsync;
});
test('should call getLastItemsAsync when isLastBefore is true', async () => {
const originalIsLastBefore = FilterPagination.isLastBefore;
const originalGetLastItemsAsync = FilterPagination.getLastItemsAsync;
FilterPagination.isLastBefore = jest.fn().mockReturnValue(true) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
last: number;
before?: string;
};
FilterPagination.getLastItemsAsync = jest.fn().mockResolvedValue({});
await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery,
afterEachQuery,
});
expect(FilterPagination.isLastBefore).toHaveBeenCalledWith(queryParams);
expect(FilterPagination.getLastItemsAsync).toHaveBeenCalledWith(
queryParams,
expect.any(Object)
);
FilterPagination.isLastBefore = originalIsLastBefore;
FilterPagination.getLastItemsAsync = originalGetLastItemsAsync;
});
test('should throw an error for invalid query params', async () => {
const originalIsFirstAfter = FilterPagination.isFirstAfter;
const originalIsLastBefore = FilterPagination.isLastBefore;
FilterPagination.isFirstAfter = jest.fn().mockReturnValue(false) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
first: number;
after?: string;
};
FilterPagination.isLastBefore = jest.fn().mockReturnValue(false) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
last: number;
before?: string;
};
await expect(
FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery,
afterEachQuery,
})
).rejects.toThrowError('Invalid query params');
FilterPagination.isFirstAfter = originalIsFirstAfter;
FilterPagination.isLastBefore = originalIsLastBefore;
});
});
describe(FilterPagination.isFirstAfter, () => {
test('should return true for object with first property', () => {
const connectionArgs = { first: 10 };
expect(FilterPagination.isFirstAfter(connectionArgs)).toBe(true);
});
test('should return false for object without first property', () => {
const connectionArgs = { last: 10 };
expect(FilterPagination.isFirstAfter(connectionArgs)).toBe(false);
});
});
describe(FilterPagination.isLastBefore, () => {
test('should return true for object with last property', () => {
const connectionArgs = { last: 10 };
expect(FilterPagination.isLastBefore(connectionArgs)).toBe(true);
});
test('should return false for object without last property', () => {
const connectionArgs = { first: 10 };
expect(FilterPagination.isLastBefore(connectionArgs)).toBe(false);
});
});
describe(FilterPagination.getFirstItemsAsync, () => {
let queryArgs: {
first: number;
after?: string | undefined;
};
let internalBatchSize: number;
let maxNodesFetched: number;
let filterPredicate: () => boolean;
let queryAsync: (queryParams: QueryParams) => Promise<Connection<any>>;
let beforeEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[]
) => void;
let afterEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[],
willFetchAgain: boolean
) => void;
beforeEach(() => {
queryArgs = { first: 10, after: 'cursor' };
internalBatchSize = 100;
maxNodesFetched = 10000;
filterPredicate = jest.fn();
queryAsync = jest.fn();
beforeEachQuery = jest.fn();
afterEachQuery = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
test('should call queryAsync with internalBatchSize and after', async () => {
const pageInfo = { hasNextPage: false };
const result = { edges: [], pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(queryAsync).toHaveBeenCalledWith({ first: internalBatchSize, after: queryArgs.after });
});
test('should call filterPredicate for each batch edge', async () => {
const pageInfo = { hasNextPage: false };
const edges = [{ node: 'node1' }, { node: 'node2' }, { node: 'node3' }];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(filterPredicate).toHaveBeenCalledTimes(edges.length);
expect(filterPredicate).toHaveBeenCalledWith(edges[0].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[1].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[2].node);
});
test('should append batch edges to the dataset', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const connection = await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(connection.edges).toEqual(edges);
});
test('should stop appending batch edges when dataset reaches limit', async () => {
const pageInfo = { hasNextPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
{ node: 'node3', cursor: 'cursor3' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const limit = 1;
const connection = await FilterPagination.getFirstItemsAsync(
{ first: limit },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}
);
expect(connection.edges.length).toBe(limit);
expect(connection.edges).toEqual([edges[0]]);
});
test('should call beforeEachQuery before each query', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalled();
});
test('should call afterEachQuery after each query', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalled();
});
test('should pass the correct arguments to beforeEachQuery', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalledWith(queryArgs, 0, expect.any(Array));
});
test('should pass the correct arguments to afterEachQuery', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalledWith(
queryArgs,
edges.length,
expect.any(Array),
pageInfo.hasNextPage
);
});
test('should throw an error when maxNodesFetched is exceeded', async () => {
const pageInfo = { hasNextPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
maxNodesFetched = edges.length;
await expect(
FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
})
).rejects.toThrowError(`Max nodes of ${maxNodesFetched} fetched`);
});
});
describe(FilterPagination.getLastItemsAsync, () => {
let queryArgs: {
last: number;
before?: string | undefined;
};
let internalBatchSize: number;
let maxNodesFetched: number;
let filterPredicate: () => boolean;
let queryAsync: (queryParams: QueryParams) => Promise<Connection<any>>;
let beforeEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[]
) => void;
let afterEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[],
willFetchAgain: boolean
) => void;
beforeEach(() => {
queryArgs = { last: 10, before: 'cursor' };
internalBatchSize = 100;
maxNodesFetched = 10000;
filterPredicate = jest.fn();
queryAsync = jest.fn();
beforeEachQuery = jest.fn();
afterEachQuery = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
test('should call queryAsync with internalBatchSize and after', async () => {
const pageInfo = { hasPreviousPage: false };
const result = { edges: [], pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(queryAsync).toHaveBeenCalledWith({
last: internalBatchSize,
before: queryArgs.before,
});
});
test('should call filterPredicate for each batch edge', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [{ node: 'node1' }, { node: 'node2' }, { node: 'node3' }];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(filterPredicate).toHaveBeenCalledTimes(edges.length);
expect(filterPredicate).toHaveBeenCalledWith(edges[0].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[1].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[2].node);
});
test('should append batch edges to the dataset', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const connection = await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(connection.edges).toEqual(edges);
});
test('should stop appending batch edges when dataset reaches limit', async () => {
const pageInfo = { hasPreviousPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
{ node: 'node3', cursor: 'cursor3' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const limit = 1;
const connection = await FilterPagination.getLastItemsAsync(
{ last: limit },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}
);
expect(connection.edges.length).toBe(limit);
expect(connection.edges).toEqual([edges[2]]);
});
test('should call beforeEachQuery before each query', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalled();
});
test('should call afterEachQuery after each query', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalled();
});
test('should pass the correct arguments to beforeEachQuery', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalledWith(queryArgs, 0, expect.any(Array));
});
test('should pass the correct arguments to afterEachQuery', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalledWith(
queryArgs,
edges.length,
expect.any(Array),
pageInfo.hasPreviousPage
);
});
test('should throw an error when maxNodesFetched is exceeded', async () => {
const pageInfo = { hasPreviousPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
maxNodesFetched = edges.length;
await expect(
FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
})
).rejects.toThrowError(`Max nodes of ${maxNodesFetched} fetched`);
});
});
});
describe(selectPaginatedAsync, () => {
const queryAsync = jest.fn();
const getTitleAsync = jest.fn();
const printedType = 'Node';
const pageSize = 10;
afterEach(() => {
jest.clearAllMocks();
});
test('returns null when there are no items', async () => {
queryAsync.mockResolvedValueOnce({
edges: [],
});
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toBeNull();
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
});
test('still prompts if there is only one item avaiable', async () => {
const node = { id: '1', name: 'Node 1' };
queryAsync.mockResolvedValueOnce({
edges: [{ node }],
pageInfo: { hasNextPage: false },
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: node,
}));
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toEqual(node);
expect(queryAsync).toHaveBeenCalledWith({
first: pageSize,
});
expect(promptAsync).toBeCalledTimes(1);
});
test('prompts for selection when there are multiple items', async () => {
const node1 = { id: '1', name: 'Node 1' };
const node2 = { id: '2', name: 'Node 2' };
queryAsync.mockResolvedValue({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: { hasNextPage: false },
});
getTitleAsync.mockResolvedValueOnce('Node 1');
getTitleAsync.mockResolvedValueOnce('Node 2');
jest.mocked(promptAsync).mockImplementation(async () => ({
item: node1,
}));
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toEqual(node1);
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
expect(getTitleAsync).toHaveBeenCalledWith(node1);
expect(getTitleAsync).toHaveBeenCalledWith(node2);
});
test('selects the previous page', async () => {
const node1 = { id: '1', name: 'Node 1' };
const node2 = { id: '2', name: 'Node 2' };
const node3 = { id: '3', name: 'Node 3' };
queryAsync.mockResolvedValueOnce({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: {
endCursor: 'endCursor',
hasNextPage: true,
},
});
queryAsync.mockResolvedValueOnce({
edges: [{ node: node3 }],
pageInfo: {
startCursor: 'startCursor',
hasPreviousPage: true,
},
});
getTitleAsync.mockResolvedValueOnce('Node 1');
getTitleAsync.mockResolvedValueOnce('Node 2');
getTitleAsync.mockResolvedValueOnce('Node 3');
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: NEXT_PAGE_OPTION.value,
}));
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: PREV_PAGE_OPTION.value,
}));
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: node1,
}));
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toEqual(node1);
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
expect(queryAsync).toHaveBeenCalledWith({
last: pageSize,
before: 'startCursor',
});
expect(getTitleAsync).toHaveBeenCalledWith(node1);
expect(getTitleAsync).toHaveBeenCalledWith(node2);
expect(getTitleAsync).toHaveBeenCalledWith(node3);
});
test('selects the next page', async () => {
const node1 = { id: '1', name: 'Node 1' };
const node2 = { id: '2', name: 'Node 2' };
const node3 = { id: '3', name: 'Node 3' };
queryAsync.mockResolvedValueOnce({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: {
endCursor: 'endCursor',
hasNextPage: true,
},
});
queryAsync.mockResolvedValueOnce({
edges: [{ node: node3 }],
pageInfo: {
startCursor: 'startCursor',
hasPreviousPage: true,
},
});
getTitleAsync.mockResolvedValueOnce('Node 1');
getTitleAsync.mockResolvedValueOnce('Node 2');
getTitleAsync.mockResolvedValueOnce('Node 3');
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: NEXT_PAGE_OPTION.value,
}));
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: node3,
}));
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toEqual(node3);
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
expect(queryAsync).toHaveBeenCalledWith({
first: pageSize,
after: 'endCursor',
});
expect(getTitleAsync).toHaveBeenCalledWith(node1);
expect(getTitleAsync).toHaveBeenCalledWith(node2);
expect(getTitleAsync).toHaveBeenCalledWith(node3);
});
});
@@ -1,13 +1,10 @@
import capitalizeFirstLetter from '../capitalize';
import capitalize from '../capitalize';
describe(capitalizeFirstLetter, () => {
describe(capitalize, () => {
it('capitalizes the string', () => {
expect(capitalizeFirstLetter('dominik')).toBe('Dominik');
expect(capitalize('dominik')).toBe('Dominik');
});
it('works with an empty string', () => {
expect(capitalizeFirstLetter('')).toBe('');
});
it('does not change the case of other letters', () => {
expect(capitalizeFirstLetter('inProgress')).toBe('InProgress');
expect(capitalize('')).toBe('');
});
});
@@ -1,3 +1,3 @@
export default function capitalizeFirstLetter(s: string): string {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
export default function capitalize(s: string): string {
return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : '';
}
+11
View File
@@ -0,0 +1,11 @@
import freeportAsync from 'freeport-async';
/** Get a free port or throw an error. */
export async function getFreePortAsync(rangeStart: number): Promise<number> {
const port = await freeportAsync(rangeStart, { hostnames: [null, 'localhost'] });
if (!port) {
throw new Error('No available port found');
}
return port;
}
-358
View File
@@ -1,358 +0,0 @@
import assert from 'assert';
import { PageInfo } from '../graphql/generated';
import { promptAsync } from '../prompts';
export type Connection<T> = {
edges: Edge<T>[];
pageInfo: PageInfo;
};
export type Edge<T> = {
cursor: string;
node: T;
};
export type QueryParams = {
first?: number;
after?: string;
last?: number;
before?: string;
};
/**
*
* Pagination that performs client side filtering on the nodes returned from a relay compliant datasource.
*
* @param queryParams The query params for the pagination.
* @param queryAsync A promise based function for querying.
* @param filterPredicate A predicate function to filter the node.
* @param beforeEachQuery Optional. A callback function to be called before each query
* @param afterEachQuery Optional. A callback function to be called after each query.
* @param internalBatchSize Optional. The batch size of queryAsync. Defaults to 100.
* @param maxNodesFetched Optional. The maximum number of nodes to fetch. Defaults to 10_000.
* @param beforeEachQuery Optional. A callback function to be called before each query
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @param afterEachQuery Optional. A callback function to be called after each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @args willFetchAgain If the query will fetch again to get a complete page.
*
* @throws {Error} - If an error occurs during execution of the query or pagination.
*/
export class FilterPagination {
static async getPageAsync<T>({
queryParams,
queryAsync,
filterPredicate,
internalBatchSize = 100,
maxNodesFetched = 10_000,
beforeEachQuery,
afterEachQuery,
}: {
queryParams: QueryParams;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
filterPredicate: (node: T) => boolean;
internalBatchSize?: number;
maxNodesFetched?: number;
beforeEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[]
) => void;
afterEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[],
willFetchAgain: boolean
) => void;
}): Promise<Connection<T>> {
if (this.isFirstAfter(queryParams)) {
return await this.getFirstItemsAsync(queryParams, {
queryAsync,
filterPredicate,
internalBatchSize,
maxNodesFetched,
beforeEachQuery,
afterEachQuery,
});
} else if (this.isLastBefore(queryParams)) {
return await this.getLastItemsAsync(queryParams, {
queryAsync,
filterPredicate,
internalBatchSize,
maxNodesFetched,
beforeEachQuery,
afterEachQuery,
});
}
throw new Error('Invalid query params');
}
static isFirstAfter(connectionArgs: QueryParams): connectionArgs is {
first: number;
after?: string;
} {
return 'first' in connectionArgs;
}
static isLastBefore(connectionArgs: {
first?: number;
after?: string;
last?: number;
before?: string;
}): connectionArgs is {
last: number;
before?: string;
} {
return 'last' in connectionArgs;
}
static async getFirstItemsAsync<T>(
{ first, after }: { first: number; after?: string },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}: {
internalBatchSize?: number;
maxNodesFetched: number;
filterPredicate: (node: T) => boolean;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
beforeEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[]
) => void;
afterEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[],
willFetchAgain: boolean
) => void;
}
): Promise<Connection<T>> {
const limit = first + 1;
const dataset = [] as Edge<T>[];
let hasMore = true;
let afterInternal: string | undefined = after;
let totalNodesFetched = 0;
while (hasMore && dataset.length < limit) {
if (beforeEachQuery) {
beforeEachQuery({ first, after }, totalNodesFetched, dataset);
}
const result = await queryAsync({ first: internalBatchSize, after: afterInternal });
const { edges: batchEdges, pageInfo } = result;
const batch = batchEdges.filter(edge => filterPredicate(edge.node));
const nodesRemaining = limit - dataset.length;
dataset.push(...batch.slice(0, nodesRemaining));
hasMore = pageInfo.hasNextPage;
afterInternal = pageInfo.endCursor ?? undefined;
totalNodesFetched += batchEdges.length;
if (afterEachQuery) {
afterEachQuery(
{ first, after },
totalNodesFetched,
dataset,
hasMore && dataset.length < limit
);
}
if (totalNodesFetched >= maxNodesFetched) {
throw new Error(`Max nodes of ${maxNodesFetched} fetched`);
}
}
const edges = dataset.slice(0, first);
return {
edges,
pageInfo: {
hasNextPage: dataset.length > first,
hasPreviousPage: false, // cannot be computed efficiently
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
}
static async getLastItemsAsync<T>(
{ last, before }: { last: number; before?: string },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}: {
internalBatchSize?: number;
maxNodesFetched: number;
filterPredicate: (node: T) => boolean;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
beforeEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[]
) => void;
afterEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[],
willFetchAgain: boolean
) => void;
}
): Promise<Connection<T>> {
const limit = last + 1;
const dataset = [] as Edge<T>[];
let hasMore = true;
let beforeInternal: string | undefined = before;
let totalNodesFetched = 0;
while (hasMore && dataset.length < limit) {
if (beforeEachQuery) {
beforeEachQuery({ last, before }, totalNodesFetched, dataset);
}
const result = await queryAsync({ last: internalBatchSize, before: beforeInternal });
const { edges: batchEdges, pageInfo } = result;
const batch = batchEdges.filter(edge => filterPredicate(edge.node));
const nodesRemaining = limit - dataset.length;
// relay orders pages from first to last, so we reverse the batch to to choose the last n
const nodesChosen = batch.reverse().slice(0, nodesRemaining);
dataset.push(...nodesChosen);
hasMore = pageInfo.hasPreviousPage;
beforeInternal = pageInfo.startCursor ?? undefined;
totalNodesFetched += batchEdges.length;
if (afterEachQuery) {
afterEachQuery(
{ last, before },
totalNodesFetched,
dataset,
hasMore && dataset.length < limit
);
}
if (totalNodesFetched >= maxNodesFetched) {
throw new Error(`Max nodes of ${maxNodesFetched} fetched`);
}
}
// we reverse our dataset again to restore the original order of first to last to match relay
const edges = dataset.slice(0, last).reverse();
return {
edges,
pageInfo: {
hasNextPage: false, // cannot be computed efficiently,
hasPreviousPage: dataset.length > last,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
}
}
export async function selectPaginatedAsync<T>({
queryAsync,
getTitleAsync,
printedType,
pageSize,
}: {
pageSize: number;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
getTitleAsync: (node: T) => Promise<string>;
printedType: string;
}): Promise<T | null> {
return await selectPaginatedInternalAsync({
queryAsync,
getTitleAsync,
printedType,
queryParams: { first: pageSize },
});
}
export const PREV_PAGE_OPTION = {
value: Symbol('PREV_PAGE'),
title: '⬆️ Previous page',
};
export const NEXT_PAGE_OPTION = {
value: Symbol('NEXT_PAGE'),
title: '⬇️ Next page',
};
async function selectPaginatedInternalAsync<T>({
queryAsync,
getTitleAsync,
printedType,
queryParams,
}: {
queryParams: QueryParams;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
getTitleAsync: (node: T) => Promise<string>;
printedType: string;
}): Promise<T | null> {
const limit = queryParams.first ?? queryParams.last;
assert(limit, 'queryParams must have either first or last');
const connection = await queryAsync(queryParams);
const { edges, pageInfo } = connection;
if (edges.length === 0) {
return null;
}
/*
* The Relay spec has a weird definition on hasNextPage and hasPreviousPage:
* 'If the client is paginating with last/before, then the server must return true if prior edges
* exist, otherwise false. If the client is paginating with first/after, then the client may
* return true if edges prior to after exist, if it can do so efficiently, otherwise may return false.'
*
* This means if we are paginating with first/after, we can't rely on pageInfo.hasPreviousPage and vice versa.
*/
const {
endCursor,
hasNextPage: serverResponseHasNextPage,
startCursor,
hasPreviousPage: serverResponseHasPreviousPage,
} = pageInfo;
const hasPreviousPage = serverResponseHasPreviousPage || queryParams.after;
const hasNextPage = serverResponseHasNextPage || queryParams.before;
const nodes = edges.map(edge => edge.node);
const options: { value: symbol | T; title: string }[] = [];
if (hasPreviousPage) {
options.push(PREV_PAGE_OPTION);
}
const nodeTitles = await Promise.all(nodes.map(node => getTitleAsync(node)));
options.push(...nodes.map((node, index) => ({ value: node, title: nodeTitles[index] })));
if (hasNextPage) {
options.push(NEXT_PAGE_OPTION);
}
const { item: selectedItem } = await promptAsync({
type: 'select',
name: 'item',
message: `Select a ${printedType}`,
choices: options.map(option => ({
value: option.value,
title: option.title,
})),
});
if (selectedItem === PREV_PAGE_OPTION.value) {
return await selectPaginatedInternalAsync({
queryParams: {
last: limit,
before: startCursor ?? undefined,
},
queryAsync,
getTitleAsync,
printedType,
});
} else if (selectedItem === NEXT_PAGE_OPTION.value) {
return await selectPaginatedInternalAsync({
queryParams: {
first: limit,
after: endCursor ?? undefined,
},
queryAsync,
getTitleAsync,
printedType,
});
} else {
return selectedItem as T;
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.build",
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true
},
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
}
+2 -2
View File
@@ -7,8 +7,8 @@
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strict": true,
"target": "ES2019",
"outDir": "build",
+4 -5
View File
@@ -1,12 +1,12 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with eas.json",
"version": "4.1.0",
"version": "3.13.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@babel/code-frame": "7.18.6",
"@expo/eas-build-job": "1.0.30",
"@expo/eas-build-job": "1.0.13",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "10.1.0",
@@ -34,11 +34,10 @@
"repository": "expo/eas-cli",
"scripts": {
"build": "tsc --project tsconfig.build.json",
"build-ci": "tsc --project tsconfig.build-ci.json",
"watch": "yarn build --watch --preserveWatchOutput",
"typecheck": "tsc --project tsconfig.build-ci.json",
"typecheck": "tsc",
"prepack": "yarn rebuild",
"rebuild": "rimraf build && yarn build-ci",
"rebuild": "rimraf build && yarn build",
"test": "jest",
"clean": "rimraf build node_modules yarn-error.log"
},
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more