mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fea1ad16e | ||
|
|
2a57a2eb3d | ||
|
|
5628b78523 | ||
|
|
19d1e6bffd | ||
|
|
88b068511c | ||
|
|
842efcf3ab | ||
|
|
f5f08aa506 | ||
|
|
9f7f412cfb | ||
|
|
e369ec1e25 | ||
|
|
b353441f31 | ||
|
|
4db35e73c6 | ||
|
|
10bc872dc9 | ||
|
|
fedc39ea48 | ||
|
|
6046723769 | ||
|
|
afaee9df38 | ||
|
|
de39d6f00d | ||
|
|
401751fffa | ||
|
|
7f7f1c6a9a | ||
|
|
279d6716d2 | ||
|
|
92977d9a53 | ||
|
|
31871a9eda | ||
|
|
f122f42149 | ||
|
|
a9a49d6b97 | ||
|
|
1d94414334 | ||
|
|
0b73dc32f6 | ||
|
|
ef8ad24639 | ||
|
|
24dbe6e7cc | ||
|
|
aad026febb | ||
|
|
6ae8125c0e | ||
|
|
87d143b310 | ||
|
|
346dee9ea5 | ||
|
|
4308d21927 | ||
|
|
207f623338 | ||
|
|
601101844c | ||
|
|
7027eaa974 | ||
|
|
b3203345ea | ||
|
|
5ed2d01146 | ||
|
|
6fc64d337f | ||
|
|
f7ed4cfa20 | ||
|
|
47968a1b8b | ||
|
|
7c779b3dc0 | ||
|
|
50474ec5e9 | ||
|
|
46ff97f173 | ||
|
|
b263dffbde | ||
|
|
8d57828f7e | ||
|
|
fd9561cfc3 | ||
|
|
93ce451bc7 | ||
|
|
554e433548 | ||
|
|
12a1cb7011 | ||
|
|
360de90eea | ||
|
|
ea02fcebda | ||
|
|
0b5194b1a5 | ||
|
|
05799aba9c | ||
|
|
2cece65dbf | ||
|
|
17b6b4ec45 | ||
|
|
4f892c66e7 | ||
|
|
fd99365655 | ||
|
|
7dca2c8021 | ||
|
|
55bce8d82a | ||
|
|
1f5533e5e3 | ||
|
|
51d0555baa | ||
|
|
2137176a65 | ||
|
|
3fda52c8d7 | ||
|
|
b252b5b582 | ||
|
|
2befe410cf | ||
|
|
1463e0de74 | ||
|
|
e963558ef6 | ||
|
|
fe57c055b6 | ||
|
|
045840111e | ||
|
|
9a6068c1f2 | ||
|
|
d06a77bd6d | ||
|
|
7bcf83d141 | ||
|
|
cd9323e115 | ||
|
|
34145c38ca | ||
|
|
57dd372957 | ||
|
|
5232641217 | ||
|
|
3c3d3150c9 | ||
|
|
fd322d4189 | ||
|
|
77254f6be1 | ||
|
|
44fcbbaf17 | ||
|
|
db841b45ee | ||
|
|
568669d384 | ||
|
|
aa50bf11ce | ||
|
|
119133360b | ||
|
|
3f0f25dd46 | ||
|
|
e821ae95e1 | ||
|
|
f4e51b4fbc | ||
|
|
963bea9b03 | ||
|
|
9bf2020b2b | ||
|
|
50e695b5a3 | ||
|
|
f3577deac4 | ||
|
|
f11c0b4d34 | ||
|
|
88411e8fc0 |
No files matched your search
@@ -29,7 +29,7 @@ jobs:
|
||||
with:
|
||||
node-version: 18
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- run: yarn build-ci
|
||||
- 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
|
||||
- run: yarn build-ci
|
||||
- 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
|
||||
- run: yarn build-ci
|
||||
- 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
|
||||
run: yarn build-ci
|
||||
- name: Publish packages to npm
|
||||
run: yarn lerna publish from-package --yes --no-verify-access
|
||||
env:
|
||||
|
||||
+150
-3
@@ -8,16 +8,163 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 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
|
||||
|
||||
- Make branch mapping utility files have no dependencies. ([#1993](https://github.com/expo/eas-cli/pull/1993) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [4.1.1](https://github.com/expo/eas-cli/releases/tag/v4.1.1) - 2023-08-08
|
||||
|
||||
### 🧹 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))
|
||||
|
||||
### 🧹 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))
|
||||
- Bump config dependencies. ([#1913](https://github.com/expo/eas-cli/pull/1913) by [@quinlanj](https://github.com/quinlanj))
|
||||
- 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))
|
||||
|
||||
## [3.14.0](https://github.com/expo/eas-cli/releases/tag/v3.14.0) - 2023-06-20
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "3.14.0",
|
||||
"version": "4.1.2",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
],
|
||||
"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",
|
||||
|
||||
+142
-133
@@ -55,69 +55,68 @@ 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`
|
||||
|
||||
@@ -134,7 +133,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -151,7 +150,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -168,7 +167,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -182,7 +181,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -232,7 +231,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -253,7 +252,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -273,7 +272,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -293,7 +292,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -316,7 +315,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -346,7 +345,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -363,7 +362,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -380,7 +379,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -415,7 +414,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/v3.14.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -453,7 +452,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -479,7 +478,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -503,7 +502,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -552,7 +551,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -571,7 +570,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -590,7 +589,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/v3.14.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -607,7 +606,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -628,7 +627,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -650,7 +649,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -670,7 +669,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -678,23 +677,31 @@ Roll a new branch out on a channel incrementally.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas channel:rollout [CHANNEL] [--branch <value>] [--percent <value>] [--end] [--json --non-interactive]
|
||||
$ 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]
|
||||
|
||||
ARGUMENTS
|
||||
CHANNEL channel on which the rollout should be done
|
||||
|
||||
FLAGS
|
||||
--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
|
||||
--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
|
||||
|
||||
DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -717,7 +724,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -738,7 +745,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -755,7 +762,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/credentials.ts)_
|
||||
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/credentials.ts)_
|
||||
|
||||
## `eas device:create`
|
||||
|
||||
@@ -769,7 +776,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/v3.14.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -789,7 +796,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -810,7 +817,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -831,7 +838,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -845,7 +852,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -859,7 +866,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -947,7 +954,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -964,7 +971,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/v3.14.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -981,7 +988,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/v3.14.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas open`
|
||||
|
||||
@@ -995,7 +1002,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1009,7 +1016,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1031,7 +1038,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas secret:create`
|
||||
|
||||
@@ -1054,7 +1061,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/v3.14.0/packages/eas-cli/src/commands/secret/create.ts)_
|
||||
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/secret/create.ts)_
|
||||
|
||||
## `eas secret:delete`
|
||||
|
||||
@@ -1072,7 +1079,7 @@ DESCRIPTION
|
||||
delete an environment secret by ID
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/secret/delete.ts)_
|
||||
|
||||
## `eas secret:list`
|
||||
|
||||
@@ -1086,7 +1093,7 @@ DESCRIPTION
|
||||
list environment secrets available for your current app
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/secret/list.ts)_
|
||||
|
||||
## `eas secret:push`
|
||||
|
||||
@@ -1106,7 +1113,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/v3.14.0/packages/eas-cli/src/commands/secret/push.ts)_
|
||||
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/secret/push.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1136,7 +1143,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.14.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1144,11 +1151,12 @@ publish an update group
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ 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>]
|
||||
$ 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>]
|
||||
[--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
|
||||
@@ -1157,7 +1165,6 @@ 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
|
||||
@@ -1169,7 +1176,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1187,7 +1194,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1208,7 +1215,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1230,7 +1237,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -1238,23 +1245,26 @@ roll back to an existing update
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:republish [--channel <value> | --branch <value> | --group <value>] [--message <value>] [-p
|
||||
android|ios|all] [--json --non-interactive]
|
||||
$ eas update:republish [--channel <value> | --branch <value> | --group <value>] [-m <value>] [-p android|ios|all]
|
||||
[--private-key-path <value>] [--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 to republish from
|
||||
--channel=<value> Channel name to select an update to republish from
|
||||
--branch=<value> Branch name to select an update group to republish from
|
||||
--channel=<value> Channel name to select an update group 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/v3.14.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -1274,7 +1284,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -1295,7 +1305,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -1315,7 +1325,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -1333,7 +1343,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -1355,7 +1365,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -1372,7 +1382,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v4.1.2/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -1388,5 +1398,4 @@ DESCRIPTION
|
||||
ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
<!-- commandsstop -->
|
||||
Generated
+1778
-262
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "3.14.0",
|
||||
"version": "4.1.2",
|
||||
"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.3",
|
||||
"@expo/config-plugins": "7.2.4",
|
||||
"@expo/config-types": "49.0.0",
|
||||
"@expo/eas-build-job": "1.0.13",
|
||||
"@expo/eas-json": "3.13.0",
|
||||
"@expo/eas-build-job": "1.0.30",
|
||||
"@expo/eas-json": "4.1.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": "5.0.7",
|
||||
"@expo/prebuild-config": "6.2.5",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.0",
|
||||
"@expo/steps": "1.0.23",
|
||||
"@expo/steps": "1.0.34",
|
||||
"@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": "3.1.1",
|
||||
"@urql/exchange-retry": "1.0.0",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
@@ -47,7 +47,6 @@
|
||||
"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",
|
||||
@@ -205,11 +204,12 @@
|
||||
"scripts": {
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"rebuild": "rimraf build && yarn build",
|
||||
"rebuild": "rimraf build && yarn build-ci",
|
||||
"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",
|
||||
"typecheck": "tsc --project tsconfig.build-ci.json",
|
||||
"test": "jest",
|
||||
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
|
||||
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -105,14 +104,3 @@ 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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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',
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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,6 +13,8 @@ import {
|
||||
EasBuildFreeTierDisabledAndroidError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
EasBuildFreeTierDisabledIOSError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
RequestValidationError,
|
||||
@@ -25,12 +27,18 @@ 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"
|
||||
}
|
||||
]`;
|
||||
@@ -280,6 +288,45 @@ 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', () => {
|
||||
@@ -424,6 +471,29 @@ 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;
|
||||
@@ -455,6 +525,29 @@ 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;
|
||||
|
||||
@@ -124,8 +124,7 @@ export async function prepareAndroidBuildAsync(
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
|
||||
const isCustomBuild = ctx.buildProfile.config !== undefined;
|
||||
return !ctx.buildProfile.withoutCredentials && !isCustomBuild;
|
||||
return !ctx.buildProfile.withoutCredentials;
|
||||
}
|
||||
|
||||
async function ensureAndroidCredentialsAsync(
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -42,6 +43,9 @@ import {
|
||||
EasBuildFreeTierDisabledAndroidError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
EasBuildFreeTierDisabledIOSError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
EasBuildLegacyResourceClassNotAvailableError,
|
||||
EasBuildProjectArchiveUploadError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
@@ -183,8 +187,11 @@ 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,
|
||||
};
|
||||
|
||||
@@ -206,15 +213,19 @@ 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 graphQLError = error.graphQLErrors[0];
|
||||
const requestIdLine = graphQLError?.extensions?.requestId
|
||||
? `\nRequest ID: ${graphQLError.extensions.requestId}`
|
||||
: '';
|
||||
const originalErrorMessage = graphQLError?.message
|
||||
? `\nError message: ${graphQLError.message}`
|
||||
: '';
|
||||
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('');
|
||||
throw new Error(
|
||||
`Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.${requestIdLine}${originalErrorMessage}`
|
||||
`Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.${errorMessage}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
@@ -246,6 +257,10 @@ 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(
|
||||
|
||||
@@ -8,8 +8,14 @@ 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,6 +55,5 @@ export async function ensureIosCredentialsForBuildResignAsync(
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
|
||||
const isCustomBuild = buildCtx.buildProfile.config !== undefined;
|
||||
return !buildCtx.buildProfile.simulator && !isCustomBuild;
|
||||
return !buildCtx.buildProfile.simulator && !buildCtx.buildProfile.withoutCredentials;
|
||||
}
|
||||
@@ -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.24';
|
||||
const PLUGIN_PACKAGE_VERSION = '1.0.38';
|
||||
|
||||
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 from '../log';
|
||||
import Log, { learnMore } from '../log';
|
||||
import {
|
||||
RequestedPlatform,
|
||||
appPlatformDisplayNames,
|
||||
@@ -118,6 +118,13 @@ 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,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BranchBasicInfo, ChannelBasicInfo, UpdateChannelInfoWithBranches } from '../utils';
|
||||
|
||||
export const testChannelBasicInfo: ChannelBasicInfo = {
|
||||
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
|
||||
name: 'production',
|
||||
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}',
|
||||
};
|
||||
|
||||
export const testBasicBranchInfo1: BranchBasicInfo = {
|
||||
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
|
||||
name: 'wrong-channel',
|
||||
};
|
||||
|
||||
export const testBasicBranchInfo2: BranchBasicInfo = {
|
||||
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
|
||||
name: 'production',
|
||||
};
|
||||
export const channelInfoWithBranches: UpdateChannelInfoWithBranches<BranchBasicInfo> = {
|
||||
...testChannelBasicInfo,
|
||||
updateBranches: [testBasicBranchInfo1, testBasicBranchInfo2],
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
emptyBranchMapping,
|
||||
rolloutBranchMapping,
|
||||
standardBranchMapping,
|
||||
} from '../../rollout/__tests__/fixtures';
|
||||
import {
|
||||
BranchMappingValidationError,
|
||||
assertVersion,
|
||||
getAlwaysTrueBranchMapping,
|
||||
getBranchMapping,
|
||||
getStandardBranchId,
|
||||
isAlwaysTrueBranchMapping,
|
||||
isEmptyBranchMapping,
|
||||
} from '../branch-mapping';
|
||||
import { testChannelBasicInfo } from './branch-mapping-fixtures';
|
||||
|
||||
describe(assertVersion, () => {
|
||||
it('throws if the branch mapping is not the correct version', () => {
|
||||
expect(() => assertVersion(testChannelBasicInfo, 5)).toThrowError(BranchMappingValidationError);
|
||||
});
|
||||
it('asserts the correct version', () => {
|
||||
assertVersion(testChannelBasicInfo, 0);
|
||||
expect(getBranchMapping(testChannelBasicInfo.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(testChannelBasicInfo)).toThrowError(
|
||||
BranchMappingValidationError
|
||||
);
|
||||
});
|
||||
it('gets a standard branch id', () => {
|
||||
const channelObjectWithStandardMapping = { ...testChannelBasicInfo };
|
||||
channelObjectWithStandardMapping.branchMapping = JSON.stringify(standardBranchMapping);
|
||||
expect(getStandardBranchId(channelObjectWithStandardMapping)).toBe(
|
||||
standardBranchMapping.data[0].branchId
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
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',
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getUpdateBranch } from '../utils';
|
||||
import { channelInfoWithBranches, testBasicBranchInfo1 } from './branch-mapping-fixtures';
|
||||
|
||||
describe(getUpdateBranch, () => {
|
||||
it('gets the update branch', () => {
|
||||
expect(getUpdateBranch(channelInfoWithBranches, '754bf17f-efc0-46ab-8a59-a03f20e53e9b')).toBe(
|
||||
testBasicBranchInfo1
|
||||
);
|
||||
expect(() => getUpdateBranch(channelInfoWithBranches, 'not-a-branch-ID')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { ChannelBasicInfo } from './utils';
|
||||
|
||||
// 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: ChannelBasicInfo): boolean {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
return isEmptyBranchMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function hasStandardBranchMap(channelInfo: ChannelBasicInfo): boolean {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
return isAlwaysTrueBranchMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function getStandardBranchId(channelInfo: ChannelBasicInfo): 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: ChannelBasicInfo, 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 {}
|
||||
@@ -0,0 +1,52 @@
|
||||
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`)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import chalk from 'chalk';
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -12,6 +13,7 @@ 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';
|
||||
@@ -19,7 +21,7 @@ import {
|
||||
paginatedQueryWithConfirmPromptAsync,
|
||||
paginatedQueryWithSelectPromptAsync,
|
||||
} from '../utils/queries';
|
||||
import { logChannelDetails } from './utils';
|
||||
import { logChannelDetails } from './print-utils';
|
||||
|
||||
export const CHANNELS_LIMIT = 25;
|
||||
|
||||
@@ -227,11 +229,11 @@ export async function createChannelOnAppAsync(
|
||||
updateChannel {
|
||||
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
|
||||
id
|
||||
name
|
||||
branchMapping
|
||||
...UpdateChannelBasicInfoFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateChannelBasicInfoFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
|
||||
@@ -1,117 +1,35 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
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;
|
||||
}[];
|
||||
export type ChannelBasicInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
export type BranchBasicInfo = {
|
||||
id: string;
|
||||
name: 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.');
|
||||
export type UpdateChannelInfoWithBranches<Branch extends BranchBasicInfo> = ChannelBasicInfo & {
|
||||
updateBranches: Branch[];
|
||||
};
|
||||
|
||||
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 };
|
||||
function getUpdateBranchNullable<Branch extends BranchBasicInfo>(
|
||||
channel: UpdateChannelInfoWithBranches<Branch>,
|
||||
branchId: string
|
||||
): Branch | null {
|
||||
const updateBranches = channel.updateBranches;
|
||||
const updateBranch = updateBranches.find(branch => branch.id === branchId);
|
||||
return updateBranch ?? null;
|
||||
}
|
||||
|
||||
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`)
|
||||
export function getUpdateBranch<Branch extends BranchBasicInfo>(
|
||||
channel: UpdateChannelInfoWithBranches<Branch>,
|
||||
branchId: string
|
||||
): Branch {
|
||||
const updateBranch = getUpdateBranchNullable(channel, branchId);
|
||||
if (!updateBranch) {
|
||||
throw new Error(
|
||||
`Could not find branch with id "${branchId}" in branch-mapping of channel "${channel.name}"`
|
||||
);
|
||||
}
|
||||
return updateBranch;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from '@oclif/core';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import {
|
||||
@@ -193,10 +194,22 @@ export default abstract class EasCommand extends Command {
|
||||
if (err instanceof EasCommandError) {
|
||||
Log.error(err.message);
|
||||
} else if (err instanceof CombinedError && err?.graphQLErrors) {
|
||||
let cleanMessage = err.message.replace('[GraphQL] ', '');
|
||||
cleanMessage += err?.graphQLErrors?.[0]?.extensions?.requestId
|
||||
? `\nRequest ID: ${err.graphQLErrors[0].extensions.requestId}`
|
||||
: '';
|
||||
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');
|
||||
Log.error(cleanMessage);
|
||||
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
|
||||
} else {
|
||||
|
||||
@@ -172,6 +172,44 @@ 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');
|
||||
@@ -186,7 +224,7 @@ describe(EasCommand.name, () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('re-throws the error with a different default base message in case of graphQLError', async () => {
|
||||
it('re-throws the error with a different default base message in case of CombinedError', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
@@ -200,6 +238,34 @@ 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.');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
import { getConfig, modifyConfigAsync } from '@expo/config';
|
||||
import { getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
|
||||
import { vol } from 'memfs';
|
||||
import { anything, instance, mock, when } from 'ts-mockito';
|
||||
|
||||
@@ -26,6 +26,10 @@ 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,11 +3,10 @@ import {
|
||||
Client,
|
||||
OperationContext,
|
||||
OperationResult,
|
||||
PromisifiedSource,
|
||||
OperationResultSource,
|
||||
TypedDocumentNode,
|
||||
cacheExchange,
|
||||
createClient as createUrqlClient,
|
||||
dedupExchange,
|
||||
fetchExchange,
|
||||
} from '@urql/core';
|
||||
import { retryExchange } from '@urql/exchange-retry';
|
||||
@@ -22,7 +21,7 @@ export interface ExpoGraphqlClient extends Client {
|
||||
query: DocumentNode | TypedDocumentNode<Data, Variables> | string,
|
||||
variables: Variables,
|
||||
context: Partial<OperationContext> & { additionalTypenames: string[] }
|
||||
): PromisifiedSource<OperationResult<Data, Variables>>;
|
||||
): OperationResultSource<OperationResult<Data, Variables>>;
|
||||
}
|
||||
|
||||
export function createGraphqlClient(authInfo: {
|
||||
@@ -32,7 +31,6 @@ export function createGraphqlClient(authInfo: {
|
||||
return createUrqlClient({
|
||||
url: getExpoApiBaseUrl() + '/graphql',
|
||||
exchanges: [
|
||||
dedupExchange,
|
||||
cacheExchange,
|
||||
retryExchange({
|
||||
maxDelayMs: 4000,
|
||||
|
||||
@@ -11,7 +11,10 @@ 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 } from '../../update/configure';
|
||||
import {
|
||||
ensureEASUpdateIsConfiguredInEasJsonAsync,
|
||||
ensureUseClassicUpdatesIsRemovedAsync,
|
||||
} from '../../update/configure';
|
||||
import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
|
||||
import { getVcsClient } from '../../vcs';
|
||||
|
||||
@@ -63,6 +66,12 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
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<UpdateChannelBranchMappingMutation['updateChannel']['editUpdateChannel']> {
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
|
||||
@@ -29,11 +34,11 @@ export async function updateChannelBranchMappingAsync(
|
||||
updateChannel {
|
||||
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
|
||||
id
|
||||
name
|
||||
branchMapping
|
||||
...UpdateChannelBasicInfoFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateChannelBasicInfoFragmentNode)}
|
||||
`,
|
||||
{ channelId, branchMapping }
|
||||
)
|
||||
@@ -95,8 +100,10 @@ export default class ChannelEdit extends EasCommand {
|
||||
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
|
||||
});
|
||||
|
||||
if (existingChannel.updateBranches.length > 1) {
|
||||
if (isRollout(existingChannel)) {
|
||||
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
|
||||
|
||||
@@ -1,297 +1,49 @@
|
||||
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 { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync, selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { updateChannelBranchMappingAsync } from './edit';
|
||||
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';
|
||||
|
||||
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;
|
||||
enum ActionRawFlagValue {
|
||||
CREATE = 'create',
|
||||
EDIT = 'edit',
|
||||
END = 'end',
|
||||
}
|
||||
type ChannelRolloutRawArgsAndFlags = {
|
||||
channel?: string;
|
||||
action?: ActionRawFlagValue;
|
||||
percent?: number;
|
||||
outcome?: EndOutcome;
|
||||
'non-interactive': boolean;
|
||||
branch?: string;
|
||||
'runtime-version'?: string;
|
||||
'private-key-path'?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
type ChannelRolloutArgsAndFlags = {
|
||||
channelName?: string;
|
||||
action?: RolloutActions;
|
||||
nonInteractive: boolean;
|
||||
json?: boolean;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
EndRolloutGeneralOptions &
|
||||
Partial<CreateRolloutNonInteractiveOptions>;
|
||||
|
||||
export default class ChannelRollout extends EasCommand {
|
||||
static override description = 'Roll a new branch out on a channel incrementally.';
|
||||
@@ -304,17 +56,68 @@ export default class ChannelRollout extends EasCommand {
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
branch: Flags.string({
|
||||
description: 'branch to rollout',
|
||||
action: Flags.enum({
|
||||
description: 'Rollout action to perform',
|
||||
options: Object.values(ActionRawFlagValue),
|
||||
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',
|
||||
description:
|
||||
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
|
||||
required: false,
|
||||
}),
|
||||
end: Flags.boolean({
|
||||
description: 'end the rollout',
|
||||
default: 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,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -325,164 +128,63 @@ export default class ChannelRollout extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ChannelRollout);
|
||||
const argsAndFlags = this.sanitizeArgsAndFlags({ ...flags, ...args });
|
||||
const {
|
||||
args: { channel: channelNameArg },
|
||||
flags: {
|
||||
json: jsonFlag,
|
||||
end: endFlag,
|
||||
branch: branchName,
|
||||
percent,
|
||||
'non-interactive': nonInteractive,
|
||||
},
|
||||
} = await this.parse(ChannelRollout);
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
privateProjectConfig: { exp, projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelRollout, {
|
||||
nonInteractive,
|
||||
nonInteractive: argsAndFlags.nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
if (argsAndFlags.json) {
|
||||
// TODO(quin): implement json output
|
||||
throw new Error('Developer Preview doesnt support JSON output yet');
|
||||
}
|
||||
|
||||
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'
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const app = { projectId, exp };
|
||||
const ctx = {
|
||||
projectId,
|
||||
nonInteractive: argsAndFlags.nonInteractive,
|
||||
graphqlClient,
|
||||
app,
|
||||
};
|
||||
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,
|
||||
});
|
||||
if (argsAndFlags.nonInteractive) {
|
||||
await new NonInteractiveRollout(argsAndFlags).runAsync(ctx);
|
||||
} 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);
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`✨ This command is in Developer Preview and has not been released to production yet. Website support is coming soon.`
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
await new RolloutMainMenu(argsAndFlags).runAsync(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
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 { 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;
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import { vol } from 'memfs';
|
||||
import { AppJSONConfig, ExpoConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import { DirectoryJSON, vol } from 'memfs';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
|
||||
@@ -8,12 +8,17 @@ 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 { CodeSigningInfo, UpdateFragment } from '../../../graphql/generated';
|
||||
import { 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';
|
||||
@@ -33,12 +38,6 @@ 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');
|
||||
@@ -52,6 +51,8 @@ jest.mock('../../../ora', () => ({
|
||||
start: () => ({ succeed: () => {}, fail: () => {} }),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../utils/code-signing');
|
||||
jest.mock('../../../fetch');
|
||||
|
||||
describe(UpdateRepublish.name, () => {
|
||||
afterEach(() => vol.reset());
|
||||
@@ -156,18 +157,45 @@ describe(UpdateRepublish.name, () => {
|
||||
});
|
||||
|
||||
it('re-creates update using codesigning with --group and --message', async () => {
|
||||
const flags = ['--group=1234', '--message=test-republish'];
|
||||
const flags = [
|
||||
'--group=1234',
|
||||
'--message=test-republish',
|
||||
`--private-key-path=./keys/test-private-key.pem`,
|
||||
];
|
||||
const codeSigning = {
|
||||
alg: 'alg',
|
||||
alg: 'rsa-v1_5-sha256',
|
||||
keyid: 'keyid',
|
||||
sig: 'sig',
|
||||
};
|
||||
|
||||
mockTestProject();
|
||||
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');
|
||||
|
||||
// Mock queries to retrieve the update and code signing info
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, codeSigningInfo: codeSigningStub }]);
|
||||
.mockResolvedValue([{ ...updateStub, codeSigningInfo: codeSigning }]);
|
||||
// Mock mutations to store the new update
|
||||
jest.mocked(PublishMutation.publishUpdateGroupAsync).mockResolvedValue([
|
||||
{
|
||||
@@ -287,8 +315,12 @@ 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',
|
||||
@@ -309,6 +341,7 @@ function mockTestProject({
|
||||
projectId: configuredProjectId,
|
||||
},
|
||||
},
|
||||
...extraManifest,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -316,6 +349,7 @@ 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 run 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 use 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 with EAS Update!`);
|
||||
Log.log(`🎉 Your app is configured to use EAS Update!`);
|
||||
Log.newLine();
|
||||
const easJsonExists = await easJsonExistsAsync(projectDir);
|
||||
if (!easJsonExists) {
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
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, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import Log from '../../log';
|
||||
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 formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
const defaultRepublishPlatforms: Platform[] = ['android', 'ios'];
|
||||
|
||||
@@ -27,6 +23,7 @@ type UpdateRepublishRawFlags = {
|
||||
group?: string;
|
||||
message?: string;
|
||||
platform: string;
|
||||
'private-key-path'?: string;
|
||||
'non-interactive': boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
@@ -37,34 +34,21 @@ 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 to republish from',
|
||||
description: 'Channel name to select an update group to republish from',
|
||||
exclusive: ['branch', 'group'],
|
||||
}),
|
||||
branch: Flags.string({
|
||||
description: 'Branch name to select an update to republish from',
|
||||
description: 'Branch name to select an update group to republish from',
|
||||
exclusive: ['channel', 'group'],
|
||||
}),
|
||||
group: Flags.string({
|
||||
@@ -73,7 +57,7 @@ export default class UpdateRepublish extends EasCommand {
|
||||
}),
|
||||
message: Flags.string({
|
||||
char: 'm',
|
||||
description: 'Short message describing the republished update',
|
||||
description: 'Short message describing the republished update group',
|
||||
required: false,
|
||||
}),
|
||||
platform: Flags.enum({
|
||||
@@ -82,6 +66,10 @@ 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,
|
||||
};
|
||||
|
||||
@@ -105,6 +93,8 @@ 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)
|
||||
@@ -124,110 +114,39 @@ export default class UpdateRepublish extends EasCommand {
|
||||
}
|
||||
|
||||
if (rawFlags.platform === 'all') {
|
||||
Log.withTick(`The republished update will appear only on: ${rawFlags.platform}`);
|
||||
Log.withTick(`The republished update group 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 that wasn't published for all platforms.`);
|
||||
Log.warn(`You are republishing an update group that wasn't published for all platforms.`);
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`The republished update will appear on the same platforms it was originally published on: ${platformsFromUpdates.join(
|
||||
`The republished update group 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);
|
||||
|
||||
// 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 }) },
|
||||
])
|
||||
);
|
||||
const arbitraryUpdate = updatesToPublish[0];
|
||||
await republishAsync({
|
||||
graphqlClient,
|
||||
app: { exp, projectId },
|
||||
updatesToPublish,
|
||||
targetBranch: { branchId: arbitraryUpdate.branchId, branchName: arbitraryUpdate.branchName },
|
||||
updateMessage,
|
||||
codeSigningInfo,
|
||||
json: flags.json,
|
||||
});
|
||||
}
|
||||
|
||||
sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
|
||||
private 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');
|
||||
@@ -245,6 +164,7 @@ export default class UpdateRepublish extends EasCommand {
|
||||
groupId,
|
||||
platform,
|
||||
updateMessage: rawFlags.message,
|
||||
privateKeyPath,
|
||||
json: rawFlags.json ?? false,
|
||||
nonInteractive,
|
||||
};
|
||||
@@ -258,15 +178,15 @@ async function getOrAskUpdatesAsync(
|
||||
flags: UpdateRepublishFlags
|
||||
): Promise<UpdateToRepublish[]> {
|
||||
if (flags.groupId) {
|
||||
const updateGroups = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
|
||||
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
|
||||
groupId: flags.groupId,
|
||||
});
|
||||
|
||||
return updateGroups.map(group => ({
|
||||
...group,
|
||||
groupId: group.group,
|
||||
branchId: group.branch.id,
|
||||
branchName: group.branch.name,
|
||||
return updateGroup.map(update => ({
|
||||
...update,
|
||||
groupId: update.group,
|
||||
branchId: update.branch.id,
|
||||
branchName: update.branch.name,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -303,17 +223,17 @@ async function askUpdatesFromBranchNameAsync(
|
||||
throw new Error('Must supply --group when in non-interactive mode');
|
||||
}
|
||||
|
||||
const updateGroups = await selectUpdateGroupOnBranchAsync(graphqlClient, {
|
||||
const updateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
|
||||
projectId,
|
||||
branchName,
|
||||
paginatedQueryOptions: getPaginatedQueryOptions({ json, 'non-interactive': nonInteractive }),
|
||||
});
|
||||
|
||||
return updateGroups.map(group => ({
|
||||
...group,
|
||||
groupId: group.id,
|
||||
branchId: group.branch.id,
|
||||
branchName: group.branch.name,
|
||||
return updateGroup.map(update => ({
|
||||
...update,
|
||||
groupId: update.group,
|
||||
branchId: update.branch.id,
|
||||
branchName: update.branch.name,
|
||||
}));
|
||||
}
|
||||
/** Ask the user which update needs to be republished by channel name, this requires interactive mode */
|
||||
|
||||
+4
-4
@@ -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<AndroidAppBuildCredentials>(
|
||||
.mutation<SetDefaultAndroidAppBuildCredentialsMutation>(
|
||||
gql`
|
||||
mutation AndroidAppBuildCredentialsMutation(
|
||||
mutation SetDefaultAndroidAppBuildCredentialsMutation(
|
||||
$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;
|
||||
return data.androidAppBuildCredentials.setDefault;
|
||||
},
|
||||
async setKeystoreAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const runCurrentMachineMethodAsync = jest.fn();
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -13,6 +14,7 @@ jest.mock('prompts');
|
||||
jest.mock('../developerPortalMethod');
|
||||
jest.mock('../inputMethod');
|
||||
jest.mock('../registrationUrlMethod');
|
||||
jest.mock('../currentMachineMethod');
|
||||
|
||||
beforeEach(() => {
|
||||
const promptsMock = jest.mocked(prompts);
|
||||
@@ -22,6 +24,7 @@ beforeEach(() => {
|
||||
});
|
||||
jest.mocked(runRegistrationUrlMethodAsync).mockClear();
|
||||
jest.mocked(runInputMethodAsync).mockClear();
|
||||
jest.mocked(runCurrentMachineMethodAsync).mockClear();
|
||||
});
|
||||
|
||||
describe(DeviceCreateAction, () => {
|
||||
@@ -118,5 +121,36 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
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,6 +21,8 @@ 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 }));
|
||||
|
||||
@@ -35,7 +37,7 @@ describe(runInputMethodAsync, () => {
|
||||
|
||||
await runInputMethodAsync(graphqlClient, accountId, appleTeam);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(2);
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -13,6 +14,7 @@ export enum RegistrationMethod {
|
||||
WEBSITE,
|
||||
INPUT,
|
||||
DEVELOPER_PORTAL,
|
||||
CURRENT_MACHINE,
|
||||
EXIT,
|
||||
}
|
||||
|
||||
@@ -37,6 +39,8 @@ 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);
|
||||
@@ -66,6 +70,12 @@ 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),
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
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,6 +19,7 @@ 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(
|
||||
@@ -105,7 +106,7 @@ async function findUnregisteredPortalDevicesAsync(
|
||||
return portalDevices.filter(
|
||||
portalDevice =>
|
||||
!(portalDevice.attributes.udid in expoRegisteredDevicesByUdid) &&
|
||||
[DeviceClass.IPAD, DeviceClass.IPHONE, DeviceClass.APPLE_TV].includes(
|
||||
[DeviceClass.IPAD, DeviceClass.IPHONE, DeviceClass.APPLE_TV, DeviceClass.MAC].includes(
|
||||
portalDevice.attributes.deviceClass
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2,18 +2,17 @@ import chalk from 'chalk';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppleDeviceMutation } from '../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
|
||||
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
|
||||
import { AppleTeam } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { isValidUDID, normalizeUDID } from '../../udids';
|
||||
import { formatNewDevice } from '../../utils/formatDevice';
|
||||
|
||||
interface DeviceData {
|
||||
udid: string;
|
||||
name?: string;
|
||||
deviceClass: AppleDeviceClass | null;
|
||||
}
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import {
|
||||
DeviceData,
|
||||
printDeviceData,
|
||||
promptForDeviceClassAsync,
|
||||
promptForNameAsync,
|
||||
promptForUDIDAsync,
|
||||
} from './utils';
|
||||
|
||||
export async function runInputMethodAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -78,16 +77,7 @@ async function collectDeviceDataAsync(
|
||||
deviceClass,
|
||||
};
|
||||
|
||||
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();
|
||||
printDeviceData(deviceData, appleTeam);
|
||||
|
||||
const registrationConfirmed = await confirmAsync({
|
||||
message: 'Is this what you want to register?',
|
||||
@@ -100,56 +90,3 @@ This device will ${chalk.bold(
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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();
|
||||
}
|
||||
@@ -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 and iPads) for internal distribution of your app.
|
||||
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones, iPads and Macs) 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 provisiong profile.
|
||||
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones and iPads.
|
||||
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.
|
||||
|
||||
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,3 +3,9 @@ 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,6 +9,7 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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' },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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 {}
|
||||
@@ -6,7 +6,16 @@ export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult
|
||||
const { data, error } = await promise;
|
||||
|
||||
if (error) {
|
||||
if (error.graphQLErrors.some(e => e?.extensions?.isTransient)) {
|
||||
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)
|
||||
)
|
||||
) {
|
||||
Log.error(`We've encountered a transient error. Try again shortly.`);
|
||||
}
|
||||
throw error;
|
||||
|
||||
+295
-51
@@ -112,6 +112,8 @@ 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>>;
|
||||
@@ -466,20 +468,14 @@ 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 = {
|
||||
@@ -526,17 +522,11 @@ 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'];
|
||||
clientIdentifier: Scalars['String'];
|
||||
endSessionEndpoint?: Maybe<Scalars['String']>;
|
||||
authorizationUrl: 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 = {
|
||||
@@ -972,6 +962,7 @@ export type AndroidSubmissionConfig = {
|
||||
/** @deprecated archiveType is deprecated and will be null */
|
||||
archiveType?: Maybe<SubmissionAndroidArchiveType>;
|
||||
releaseStatus?: Maybe<SubmissionAndroidReleaseStatus>;
|
||||
rollout?: Maybe<Scalars['Float']>;
|
||||
track: SubmissionAndroidTrack;
|
||||
};
|
||||
|
||||
@@ -982,6 +973,7 @@ export type AndroidSubmissionConfigInput = {
|
||||
googleServiceAccountKeyId?: InputMaybe<Scalars['String']>;
|
||||
googleServiceAccountKeyJson?: InputMaybe<Scalars['String']>;
|
||||
releaseStatus?: InputMaybe<SubmissionAndroidReleaseStatus>;
|
||||
rollout?: InputMaybe<Scalars['Float']>;
|
||||
track: SubmissionAndroidTrack;
|
||||
};
|
||||
|
||||
@@ -1017,6 +1009,7 @@ 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 */
|
||||
@@ -1373,16 +1366,19 @@ 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 = {
|
||||
@@ -1661,7 +1657,8 @@ export type AppleDevice = {
|
||||
|
||||
export enum AppleDeviceClass {
|
||||
Ipad = 'IPAD',
|
||||
Iphone = 'IPHONE'
|
||||
Iphone = 'IPHONE',
|
||||
Mac = 'MAC'
|
||||
}
|
||||
|
||||
export type AppleDeviceInput = {
|
||||
@@ -2009,7 +2006,8 @@ export type BackgroundJobReceiptQueryByIdArgs = {
|
||||
};
|
||||
|
||||
export enum BackgroundJobResultType {
|
||||
GithubBuild = 'GITHUB_BUILD'
|
||||
GithubBuild = 'GITHUB_BUILD',
|
||||
Void = 'VOID'
|
||||
}
|
||||
|
||||
export enum BackgroundJobState {
|
||||
@@ -2072,6 +2070,7 @@ 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 */
|
||||
@@ -2242,6 +2241,17 @@ 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>;
|
||||
@@ -2387,6 +2397,14 @@ 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',
|
||||
@@ -2592,6 +2610,19 @@ 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'];
|
||||
@@ -2704,6 +2735,11 @@ export type DeleteRobotResult = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
export type DeleteSsoUserResult = {
|
||||
__typename?: 'DeleteSSOUserResult';
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
export type DeleteUpdateBranchResult = {
|
||||
__typename?: 'DeleteUpdateBranchResult';
|
||||
id: Scalars['ID'];
|
||||
@@ -3092,6 +3128,54 @@ 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;
|
||||
@@ -3099,7 +3183,7 @@ export type GitHubRepository = {
|
||||
githubRepositoryIdentifier: Scalars['Int'];
|
||||
githubRepositoryUrl?: Maybe<Scalars['String']>;
|
||||
id: Scalars['ID'];
|
||||
metadata?: Maybe<GitHubRepositoryMetadata>;
|
||||
metadata: GitHubRepositoryMetadata;
|
||||
nodeIdentifier: Scalars['String'];
|
||||
};
|
||||
|
||||
@@ -3112,7 +3196,6 @@ export type GitHubRepositoryMetadata = {
|
||||
githubRepoUrl: Scalars['String'];
|
||||
lastPushed: Scalars['DateTime'];
|
||||
lastUpdated: Scalars['DateTime'];
|
||||
openGraphImageUrl?: Maybe<Scalars['String']>;
|
||||
private: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
@@ -3628,6 +3711,8 @@ 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 */
|
||||
@@ -3680,6 +3765,11 @@ export type MeMutationDeleteAccountArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MeMutationDeleteSsoUserArgs = {
|
||||
ssoUserId: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type MeMutationDeleteSecondFactorDeviceArgs = {
|
||||
otp: Scalars['String'];
|
||||
userSecondFactorDeviceId: Scalars['ID'];
|
||||
@@ -3753,12 +3843,27 @@ 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'
|
||||
SubmissionComplete = 'SUBMISSION_COMPLETE',
|
||||
Test = 'TEST'
|
||||
}
|
||||
|
||||
export type NotificationMetadata = BuildLimitThresholdExceededMetadata | BuildPlanCreditThresholdExceededMetadata | TestNotificationMetadata;
|
||||
|
||||
export type NotificationSubscription = {
|
||||
__typename?: 'NotificationSubscription';
|
||||
account?: Maybe<Account>;
|
||||
@@ -3799,8 +3904,17 @@ 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'
|
||||
Email = 'EMAIL',
|
||||
Web = 'WEB'
|
||||
}
|
||||
|
||||
export type Offer = {
|
||||
@@ -4084,6 +4198,8 @@ 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 */
|
||||
@@ -4194,17 +4310,20 @@ 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. */
|
||||
/**
|
||||
* Top-level query object for querying Users.
|
||||
* @deprecated Public user queries are no longer supported
|
||||
*/
|
||||
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'. */
|
||||
@@ -4256,6 +4375,23 @@ 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';
|
||||
@@ -4301,6 +4437,8 @@ 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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -4343,24 +4481,6 @@ 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'];
|
||||
@@ -4737,6 +4857,11 @@ export type SubscriptionDetailsPlanEnablementArgs = {
|
||||
serviceMetric: EasServiceMetric;
|
||||
};
|
||||
|
||||
export type TestNotificationMetadata = {
|
||||
__typename?: 'TestNotificationMetadata';
|
||||
message: Scalars['String'];
|
||||
};
|
||||
|
||||
export type TimelineActivityConnection = {
|
||||
__typename?: 'TimelineActivityConnection';
|
||||
edges: Array<TimelineActivityEdge>;
|
||||
@@ -4798,12 +4923,22 @@ 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'];
|
||||
@@ -4909,6 +5044,16 @@ 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'];
|
||||
};
|
||||
@@ -5016,7 +5161,7 @@ export type User = Actor & UserActor & {
|
||||
/** Discord account linked to a user */
|
||||
discordUser?: Maybe<DiscordUser>;
|
||||
displayName: Scalars['String'];
|
||||
email?: Maybe<Scalars['String']>;
|
||||
email: Scalars['String'];
|
||||
emailVerified: Scalars['Boolean'];
|
||||
/**
|
||||
* Server feature gate values for this actor, optionally filtering by desired gates.
|
||||
@@ -5054,6 +5199,7 @@ export type User = Actor & UserActor & {
|
||||
/** @deprecated No longer supported */
|
||||
twitterUsername?: Maybe<Scalars['String']>;
|
||||
username: Scalars['String'];
|
||||
websiteNotifications: Array<Notification>;
|
||||
};
|
||||
|
||||
|
||||
@@ -5142,6 +5288,8 @@ export type UserActor = {
|
||||
/** @deprecated No longer supported */
|
||||
twitterUsername?: Maybe<Scalars['String']>;
|
||||
username: Scalars['String'];
|
||||
/** Web notifications linked to a user */
|
||||
websiteNotifications: Array<Notification>;
|
||||
};
|
||||
|
||||
|
||||
@@ -5179,6 +5327,46 @@ 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 */
|
||||
@@ -5320,9 +5508,15 @@ export type UserPermission = {
|
||||
|
||||
export type UserQuery = {
|
||||
__typename?: 'UserQuery';
|
||||
/** Query a User by ID */
|
||||
/**
|
||||
* Query a User by ID
|
||||
* @deprecated Public user queries are no longer supported
|
||||
*/
|
||||
byId: User;
|
||||
/** Query a User by username */
|
||||
/**
|
||||
* Query a User by username
|
||||
* @deprecated Public user queries are no longer supported
|
||||
*/
|
||||
byUsername: User;
|
||||
};
|
||||
|
||||
@@ -5515,6 +5709,14 @@ 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'];
|
||||
@@ -5998,7 +6200,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 } | 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, 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 CreateIosSubmissionMutationVariables = Exact<{
|
||||
appId: Scalars['ID'];
|
||||
@@ -6008,7 +6210,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 } | 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, 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 CreateUploadSessionMutationVariables = Exact<{
|
||||
type: UploadSessionType;
|
||||
@@ -6080,6 +6282,17 @@ 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'];
|
||||
@@ -6102,7 +6315,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 } | 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, 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 ViewBuildsOnAppQueryVariables = Exact<{
|
||||
appId: Scalars['String'];
|
||||
@@ -6117,6 +6330,7 @@ export type ViewBuildsOnAppQuery = { __typename?: 'RootQuery', app: { __typename
|
||||
export type ViewUpdateChannelOnAppQueryVariables = Exact<{
|
||||
appId: Scalars['String'];
|
||||
channelName: Scalars['String'];
|
||||
filter?: InputMaybe<UpdatesFilter>;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -6129,7 +6343,18 @@ 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, 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 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 EnvironmentSecretsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String'];
|
||||
@@ -6152,6 +6377,19 @@ 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;
|
||||
}>;
|
||||
@@ -6164,7 +6402,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 } | 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, 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 GetAllSubmissionsForAppQueryVariables = Exact<{
|
||||
appId: Scalars['String'];
|
||||
@@ -6175,7 +6413,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 } | 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, 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 ViewUpdatesByGroupQueryVariables = Exact<{
|
||||
groupId: Scalars['ID'];
|
||||
@@ -6231,18 +6469,24 @@ 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 } | 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, 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 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 } | 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, 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 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,8 +5,11 @@ 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,
|
||||
@@ -15,6 +18,7 @@ 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']
|
||||
@@ -24,7 +28,7 @@ export const BranchQuery = {
|
||||
async getBranchByNameAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, name }: ViewBranchQueryVariables
|
||||
): Promise<NonNullable<ViewBranchQuery['app']['byId']['updateBranchByName']>> {
|
||||
): Promise<UpdateBranchBasicInfoFragment> {
|
||||
const response = await withErrorHandlingAsync<ViewBranchQuery>(
|
||||
graphqlClient
|
||||
.query<ViewBranchQuery, ViewBranchQueryVariables>(
|
||||
@@ -35,11 +39,12 @@ export const BranchQuery = {
|
||||
id
|
||||
updateBranchByName(name: $name) {
|
||||
id
|
||||
name
|
||||
...UpdateBranchBasicInfoFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateBranchBasicInfoFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
@@ -88,6 +93,56 @@ 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,31 +5,55 @@ 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';
|
||||
|
||||
export type UpdateChannelObject = NonNullable<
|
||||
type ViewUpdateChannelsOnAppObject = NonNullable<
|
||||
ViewUpdateChannelsOnAppQuery['app']['byId']['updateChannels']
|
||||
>[number];
|
||||
|
||||
export type UpdateChannelByNameObject = NonNullable<
|
||||
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 }: ViewUpdateChannelOnAppQueryVariables
|
||||
{ appId, channelName, filter }: ViewUpdateChannelOnAppQueryVariables
|
||||
): Promise<UpdateChannelByNameObject> {
|
||||
const response = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<ViewUpdateChannelOnAppQuery, ViewUpdateChannelOnAppQueryVariables>(
|
||||
gql`
|
||||
query ViewUpdateChannelOnApp($appId: String!, $channelName: String!) {
|
||||
query ViewUpdateChannelOnApp(
|
||||
$appId: String!
|
||||
$channelName: String!
|
||||
$filter: UpdatesFilter
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
@@ -41,7 +65,7 @@ export const ChannelQuery = {
|
||||
updateBranches(offset: 0, limit: 5) {
|
||||
id
|
||||
name
|
||||
updateGroups(offset: 0, limit: 1) {
|
||||
updateGroups(offset: 0, limit: 1, filter: $filter) {
|
||||
id
|
||||
...UpdateFragment
|
||||
}
|
||||
@@ -52,7 +76,7 @@ export const ChannelQuery = {
|
||||
}
|
||||
${print(UpdateFragmentNode)}
|
||||
`,
|
||||
{ appId, channelName },
|
||||
{ appId, channelName, filter },
|
||||
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
|
||||
)
|
||||
.toPromise()
|
||||
@@ -80,6 +104,7 @@ export const ChannelQuery = {
|
||||
updateChannels(offset: $offset, limit: $limit) {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
branchMapping
|
||||
updateBranches(offset: 0, limit: 5) {
|
||||
id
|
||||
@@ -108,4 +133,57 @@ 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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const RuntimeFragmentNode = gql`
|
||||
fragment RuntimeFragment on Runtime {
|
||||
id
|
||||
version
|
||||
}
|
||||
`;
|
||||
@@ -18,6 +18,7 @@ export const SubmissionFragmentNode = gql`
|
||||
applicationIdentifier
|
||||
track
|
||||
releaseStatus
|
||||
rollout
|
||||
}
|
||||
iosConfig {
|
||||
ascAppIdentifier
|
||||
|
||||
@@ -2,15 +2,17 @@ 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
|
||||
name
|
||||
...UpdateBranchBasicInfoFragment
|
||||
updates(offset: 0, limit: 10) {
|
||||
id
|
||||
...UpdateFragment
|
||||
}
|
||||
}
|
||||
${print(UpdateFragmentNode)}
|
||||
${print(UpdateBranchBasicInfoFragmentNode)}
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const UpdateBranchBasicInfoFragmentNode = gql`
|
||||
fragment UpdateBranchBasicInfoFragment on UpdateBranch {
|
||||
id
|
||||
name
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const UpdateChannelBasicInfoFragmentNode = gql`
|
||||
fragment UpdateChannelBasicInfoFragment on UpdateChannel {
|
||||
id
|
||||
name
|
||||
branchMapping
|
||||
}
|
||||
`;
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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<
|
||||
@@ -93,19 +94,24 @@ export function ensureExpoConfigExists(projectDir: string): void {
|
||||
}
|
||||
|
||||
async function ensureStaticExpoConfigIsValidAsync(projectDir: string): Promise<void> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (paths?.staticConfigPath?.endsWith('app.json') && !paths?.dynamicConfigPath) {
|
||||
const staticConfig = await JsonFile.readAsync(paths.staticConfigPath);
|
||||
if (isUsingStaticExpoConfig(projectDir)) {
|
||||
const staticConfigPath = nullthrows(getConfigFilePaths(projectDir).staticConfigPath);
|
||||
const staticConfig = await JsonFile.readAsync(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(paths.staticConfigPath, staticConfig);
|
||||
await JsonFile.writeAsync(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,6 +3,7 @@ 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;
|
||||
@@ -13,6 +14,7 @@ export async function getManagedApplicationTargetEntitlementsAsync(
|
||||
env: Record<string, string>
|
||||
): Promise<JSONObject> {
|
||||
const originalProcessEnv: NodeJS.ProcessEnv = process.env;
|
||||
|
||||
try {
|
||||
process.env = {
|
||||
...process.env,
|
||||
@@ -24,6 +26,7 @@ export async function getManagedApplicationTargetEntitlementsAsync(
|
||||
projectRoot: projectDir,
|
||||
platforms: ['ios'],
|
||||
introspect: true,
|
||||
ignoreExistingNativeFiles: await hasIgnoredIosProjectAsync(projectDir),
|
||||
});
|
||||
return expWithMods.ios?.entitlements || {};
|
||||
} finally {
|
||||
|
||||
@@ -38,7 +38,7 @@ const AppExtensionsConfigSchema = Joi.array().items(
|
||||
})
|
||||
);
|
||||
|
||||
export async function resolveMangedProjectTargetsAsync({
|
||||
export async function resolveManagedProjectTargetsAsync({
|
||||
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 resolveMangedProjectTargetsAsync(opts);
|
||||
return await resolveManagedProjectTargetsAsync(opts);
|
||||
} else {
|
||||
throw new Error(`Unknown workflow: ${workflow}`);
|
||||
}
|
||||
|
||||
@@ -644,7 +644,9 @@ export async function getRuntimeVersionObjectAsync(
|
||||
Workflow.MANAGED;
|
||||
if (!isManaged) {
|
||||
throw new Error(
|
||||
'Runtime version policies are only supported in the managed workflow. In the bare workflow, runtime version needs to be set manually.'
|
||||
`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'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,3 +44,15 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
channelInfoWithBranches,
|
||||
testBasicBranchInfo1,
|
||||
testBasicBranchInfo2,
|
||||
testChannelBasicInfo,
|
||||
} from '../../channel/__tests__/branch-mapping-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(channelInfoWithBranches);
|
||||
const rolloutInfo = getRolloutInfo(testChannelBasicInfo);
|
||||
const composedRollout = composeRollout(rolloutInfo, testBasicBranchInfo2, testBasicBranchInfo1);
|
||||
expect(composedRollout).toEqual(rollout);
|
||||
});
|
||||
it('throws if the branches to not match the rollout info', () => {
|
||||
const rolloutInfo = getRolloutInfo(testChannelBasicInfo);
|
||||
expect(() =>
|
||||
composeRollout(rolloutInfo, testBasicBranchInfo1, testBasicBranchInfo2)
|
||||
).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 = { ...channelInfoWithBranches };
|
||||
notRollout.branchMapping = JSON.stringify(standardBranchMapping);
|
||||
expect(() => getRollout(notRollout)).toThrowError();
|
||||
});
|
||||
it('gets a constrained rollout', () => {
|
||||
const constrainedRollout = { ...channelInfoWithBranches };
|
||||
const rolloutBranchMappingWithCorrectBranch = { ...rolloutBranchMapping };
|
||||
rolloutBranchMappingWithCorrectBranch.data[0].branchId = testBasicBranchInfo1.id;
|
||||
rolloutBranchMappingWithCorrectBranch.data[1].branchId = testBasicBranchInfo2.id;
|
||||
constrainedRollout.branchMapping = JSON.stringify(rolloutBranchMapping);
|
||||
|
||||
const rollout = getRollout(constrainedRollout);
|
||||
expect(rollout.percentRolledOut).toEqual(10);
|
||||
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
|
||||
expect(rollout.defaultBranch).toEqual(testBasicBranchInfo2);
|
||||
expect(rollout.rolledOutBranch).toEqual(testBasicBranchInfo1);
|
||||
expect(rollout.defaultBranchId).toEqual(testBasicBranchInfo2.id);
|
||||
expect(rollout.rolledOutBranchId).toEqual(testBasicBranchInfo1.id);
|
||||
});
|
||||
it('gets a legacy rollout', () => {
|
||||
const rollout = getRollout(channelInfoWithBranches);
|
||||
expect(rollout.percentRolledOut).toEqual(15);
|
||||
expect(isLegacyRolloutInfo(rollout)).toBe(true);
|
||||
expect(rollout.defaultBranch).toEqual(testBasicBranchInfo2);
|
||||
expect(rollout.rolledOutBranch).toEqual(testBasicBranchInfo1);
|
||||
expect(rollout.defaultBranchId).toEqual(testBasicBranchInfo2.id);
|
||||
expect(rollout.rolledOutBranchId).toEqual(testBasicBranchInfo1.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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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: [],
|
||||
};
|
||||
@@ -0,0 +1,332 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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] } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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,
|
||||
UpdateBranchObject,
|
||||
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<UpdateBranchObject>): 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<UpdateBranchObject>,
|
||||
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<UpdateBranchObject>
|
||||
): 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?`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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] } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
BranchMapping,
|
||||
BranchMappingAlwaysTrue,
|
||||
BranchMappingNode,
|
||||
BranchMappingValidationError,
|
||||
alwaysTrue,
|
||||
assertNodeObject,
|
||||
assertNumber,
|
||||
assertStatement,
|
||||
assertString,
|
||||
getBranchMapping,
|
||||
getNodesFromStatement,
|
||||
isAlwaysTrue,
|
||||
isAndStatement,
|
||||
isStatement,
|
||||
} from '../channel/branch-mapping';
|
||||
import {
|
||||
BranchBasicInfo,
|
||||
ChannelBasicInfo,
|
||||
UpdateChannelInfoWithBranches,
|
||||
getUpdateBranch,
|
||||
} from '../channel/utils';
|
||||
|
||||
export type Rollout<Branch extends BranchBasicInfo> =
|
||||
| LegacyRollout<Branch>
|
||||
| ConstrainedRollout<Branch>;
|
||||
export type RolloutInfo = LegacyRolloutInfo | ConstrainedRolloutInfo;
|
||||
type ConstrainedRollout<Branch extends BranchBasicInfo> = LegacyRollout<Branch> & {
|
||||
runtimeVersion: string;
|
||||
};
|
||||
|
||||
type LegacyRollout<Branch extends BranchBasicInfo> = {
|
||||
rolledOutBranch: Branch;
|
||||
defaultBranch: Branch;
|
||||
} & 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<Branch extends BranchBasicInfo>(
|
||||
rollout: Rollout<Branch>
|
||||
): rollout is ConstrainedRollout<Branch> {
|
||||
return isConstrainedRolloutInfo(rollout);
|
||||
}
|
||||
|
||||
export function getRolloutInfo(basicChannelInfo: ChannelBasicInfo): 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);
|
||||
if (!runtimeVersionNode) {
|
||||
throw new BranchMappingValidationError('Runtime version node must be defined.');
|
||||
}
|
||||
|
||||
assertNodeObject(runtimeVersionNode);
|
||||
const runtimeVersion = runtimeVersionNode.operand;
|
||||
assertString(runtimeVersion);
|
||||
|
||||
const rolloutNode = nodesFromStatement.find(isRolloutNode);
|
||||
if (!rolloutNode) {
|
||||
throw new BranchMappingValidationError('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<Branch extends BranchBasicInfo>(
|
||||
channel: UpdateChannelInfoWithBranches<Branch>
|
||||
): Rollout<Branch> {
|
||||
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<Branch>(rolloutInfo, defaultBranch, rolledOutBranch);
|
||||
}
|
||||
|
||||
export function composeRollout<Branch extends BranchBasicInfo>(
|
||||
rolloutInfo: RolloutInfo,
|
||||
defaultBranch: Branch,
|
||||
rolledOutBranch: Branch
|
||||
): Rollout<Branch> {
|
||||
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: ChannelBasicInfo): 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);
|
||||
if (!rolloutNode) {
|
||||
throw new BranchMappingValidationError('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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
RuntimeFragment,
|
||||
UpdateBranchBasicInfoFragment,
|
||||
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<UpdateBranchBasicInfoFragment>
|
||||
): 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 capitalize from '../../utils/expodash/capitalize';
|
||||
import capitalizeFirstLetter from '../../utils/expodash/capitalize';
|
||||
import { ArchiveSource } from '../ArchiveSource';
|
||||
import { resolveArchiveSource } from '../commons';
|
||||
import { SubmissionContext } from '../context';
|
||||
@@ -33,9 +33,12 @@ 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].filter(r => !r.ok);
|
||||
const errored = [track, releaseStatus, archiveSource, serviceAccountSource, rollout].filter(
|
||||
r => !r.ok
|
||||
);
|
||||
if (errored.length > 0) {
|
||||
const message = errored.map(err => err.reason?.message).join('\n');
|
||||
Log.error(message);
|
||||
@@ -46,6 +49,7 @@ 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,
|
||||
@@ -73,7 +77,7 @@ export default class AndroidSubmitCommand {
|
||||
if (!track) {
|
||||
return result(SubmissionAndroidTrack.Internal);
|
||||
}
|
||||
const capitalizedTrack = capitalize(track);
|
||||
const capitalizedTrack = capitalizeFirstLetter(track);
|
||||
if (capitalizedTrack in SubmissionAndroidTrack) {
|
||||
return result(
|
||||
SubmissionAndroidTrack[capitalizedTrack as keyof typeof SubmissionAndroidTrack]
|
||||
@@ -94,7 +98,7 @@ export default class AndroidSubmitCommand {
|
||||
if (!releaseStatus) {
|
||||
return result(SubmissionAndroidReleaseStatus.Completed);
|
||||
}
|
||||
const capitalizedReleaseStatus = capitalize(releaseStatus);
|
||||
const capitalizedReleaseStatus = capitalizeFirstLetter(releaseStatus);
|
||||
if (capitalizedReleaseStatus in SubmissionAndroidReleaseStatus) {
|
||||
return result(
|
||||
SubmissionAndroidReleaseStatus[
|
||||
@@ -112,6 +116,12 @@ 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'
|
||||
'track' | 'releaseStatus' | 'changesNotSentForReview' | 'rollout'
|
||||
> {
|
||||
projectId: string;
|
||||
archiveSource: ArchiveSource;
|
||||
@@ -111,11 +111,12 @@ export default class AndroidSubmitter extends BaseSubmitter<
|
||||
options: AndroidSubmissionOptions,
|
||||
{ serviceAccountKeyResult }: ResolvedSourceOptions
|
||||
): AndroidSubmissionConfigInput {
|
||||
const { track, releaseStatus, changesNotSentForReview } = options;
|
||||
const { track, releaseStatus, changesNotSentForReview, rollout } = options;
|
||||
return {
|
||||
track,
|
||||
changesNotSentForReview,
|
||||
releaseStatus,
|
||||
rollout,
|
||||
...serviceAccountKeyResult.result,
|
||||
};
|
||||
}
|
||||
@@ -124,7 +125,7 @@ export default class AndroidSubmitter extends BaseSubmitter<
|
||||
options: AndroidSubmissionOptions,
|
||||
{ archive, serviceAccountKeyResult }: ResolvedSourceOptions
|
||||
): SummaryData {
|
||||
const { projectId, track, releaseStatus, changesNotSentForReview } = options;
|
||||
const { projectId, track, releaseStatus, changesNotSentForReview, rollout } = options;
|
||||
|
||||
// structuring order affects table rows order
|
||||
return {
|
||||
@@ -133,6 +134,7 @@ export default class AndroidSubmitter extends BaseSubmitter<
|
||||
changesNotSentForReview: changesNotSentForReview ?? undefined,
|
||||
releaseStatus: releaseStatus ?? undefined,
|
||||
formattedServiceAccount: formatServiceAccountSummary(serviceAccountKeyResult),
|
||||
rollout: rollout ?? undefined,
|
||||
...formatArchiveSourceSummary(archive),
|
||||
};
|
||||
}
|
||||
@@ -143,6 +145,7 @@ type SummaryData = {
|
||||
formattedServiceAccount: string;
|
||||
projectId: string;
|
||||
releaseStatus?: SubmissionAndroidReleaseStatus;
|
||||
rollout?: number;
|
||||
track: SubmissionAndroidTrack;
|
||||
} & ArchiveSourceSummaryFields;
|
||||
|
||||
@@ -154,6 +157,7 @@ const SummaryHumanReadableKeys: Record<keyof SummaryData, string> = {
|
||||
formattedServiceAccount: 'Google Service Account Key',
|
||||
projectId: 'Project ID',
|
||||
releaseStatus: 'Release status',
|
||||
rollout: 'Rollout',
|
||||
track: 'Release track',
|
||||
};
|
||||
|
||||
|
||||
@@ -145,6 +145,46 @@ 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;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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,6 +115,7 @@ 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: [
|
||||
[
|
||||
{
|
||||
@@ -148,6 +149,7 @@ 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,
|
||||
|
||||
@@ -3,29 +3,40 @@ 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 } from '../project/expoConfig';
|
||||
import { createOrModifyExpoConfigAsync, isUsingStaticExpoConfig } 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 = { policy: 'sdkVersion' } as const;
|
||||
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_BARE_RUNTIME_VERSION = '1.0.0' as const;
|
||||
|
||||
function getDefaultRuntimeVersion(workflow: Workflow): NonNullable<ExpoConfig['runtimeVersion']> {
|
||||
return workflow === Workflow.GENERIC
|
||||
? DEFAULT_BARE_RUNTIME_VERSION
|
||||
: DEFAULT_MANAGED_RUNTIME_VERSION;
|
||||
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 isRuntimeEqual(
|
||||
@@ -107,8 +118,10 @@ async function ensureEASUpdatesIsConfiguredInExpoConfigAsync({
|
||||
(['all', 'android'].includes(platform) && !androidRuntimeVersion) ||
|
||||
(['all', 'ios'].includes(platform) && !iosRuntimeVersion)
|
||||
) {
|
||||
androidRuntimeVersion = androidRuntimeVersion ?? getDefaultRuntimeVersion(workflows.android);
|
||||
iosRuntimeVersion = iosRuntimeVersion ?? getDefaultRuntimeVersion(workflows.ios);
|
||||
androidRuntimeVersion =
|
||||
androidRuntimeVersion ?? getDefaultRuntimeVersion(workflows.android, exp.sdkVersion);
|
||||
iosRuntimeVersion =
|
||||
iosRuntimeVersion ?? getDefaultRuntimeVersion(workflows.ios, exp.sdkVersion);
|
||||
|
||||
if (platform === 'all' && isRuntimeEqual(androidRuntimeVersion, iosRuntimeVersion)) {
|
||||
modifyConfig.runtimeVersion = androidRuntimeVersion;
|
||||
@@ -154,6 +167,16 @@ 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,
|
||||
@@ -175,21 +198,21 @@ function logEasUpdatesAutoConfig({
|
||||
Log.withTick(
|
||||
`Configured runtimeVersion for ${appPlatformDisplayNames[AppPlatform.Android]} and ${
|
||||
appPlatformDisplayNames[AppPlatform.Ios]
|
||||
} with "${modifyConfig.android?.runtimeVersion ?? modifyConfig.runtimeVersion}"`
|
||||
} with "${serializeRuntimeVersionToString(androidRuntime)}"`
|
||||
);
|
||||
} else {
|
||||
if (androidRuntime) {
|
||||
Log.withTick(
|
||||
`Configured runtimeVersion for ${appPlatformDisplayNames[AppPlatform.Android]} with "${
|
||||
modifyConfig.android?.runtimeVersion ?? modifyConfig.runtimeVersion
|
||||
}"`
|
||||
`Configured runtimeVersion for ${
|
||||
appPlatformDisplayNames[AppPlatform.Android]
|
||||
} with "${serializeRuntimeVersionToString(androidRuntime)}"`
|
||||
);
|
||||
}
|
||||
if (iosRuntime) {
|
||||
Log.withTick(
|
||||
`Configured runtimeVersion for ${appPlatformDisplayNames[AppPlatform.Ios]} with "${
|
||||
modifyConfig.ios?.runtimeVersion ?? modifyConfig.runtimeVersion
|
||||
}"`
|
||||
`Configured runtimeVersion for ${
|
||||
appPlatformDisplayNames[AppPlatform.Ios]
|
||||
} with "${serializeRuntimeVersionToString(iosRuntime)}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -324,7 +347,8 @@ 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:
|
||||
* - Enure the `expo-updates` package is currently installed.
|
||||
* - Ensure `updates.useClassicUpdates` (SDK 49) is not set in the app config
|
||||
* - Ensure 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
|
||||
@@ -333,7 +357,7 @@ export async function ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir: stri
|
||||
export async function ensureEASUpdateIsConfiguredAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
exp: expWithoutUpdates,
|
||||
exp: expMaybeWithoutUpdates,
|
||||
projectId,
|
||||
projectDir,
|
||||
platform,
|
||||
@@ -344,9 +368,17 @@ 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,
|
||||
expWithoutUpdates.sdkVersion
|
||||
expMaybeWithoutUpdates.sdkVersion
|
||||
);
|
||||
const hasExpoUpdatesInDevDependencies = isExpoUpdatesInstalledAsDevDependency(projectDir);
|
||||
if (!hasExpoUpdates && !hasExpoUpdatesInDevDependencies) {
|
||||
@@ -367,7 +399,7 @@ export async function ensureEASUpdateIsConfiguredAsync(
|
||||
const workflows = await resolveWorkflowPerPlatformAsync(projectDir);
|
||||
const { projectChanged, exp: expWithUpdates } =
|
||||
await ensureEASUpdatesIsConfiguredInExpoConfigAsync({
|
||||
exp: expWithoutUpdates,
|
||||
exp: expMaybeWithoutUpdates,
|
||||
projectDir,
|
||||
projectId,
|
||||
platform,
|
||||
@@ -392,3 +424,49 @@ 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;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
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 }) },
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import openBrowserAsync from 'better-opn';
|
||||
import http from 'http';
|
||||
import { Socket } from 'node:net';
|
||||
@@ -5,22 +6,48 @@ import querystring from 'querystring';
|
||||
|
||||
import Log from '../log';
|
||||
|
||||
export async function getSessionUsingBrowserAuthFlowAsync(options: {
|
||||
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,
|
||||
}: {
|
||||
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 = (): string => {
|
||||
const buildExpoSsoLoginUrl = (port: number): string => {
|
||||
const data = {
|
||||
app_redirect_uri: redirectUri,
|
||||
app_redirect_uri: `${scheme}://${hostname}:${port}${path}`,
|
||||
};
|
||||
const params = querystring.stringify(data);
|
||||
return `${expoWebsiteUrl}/sso-login?${params}`;
|
||||
@@ -44,8 +71,8 @@ export async function getSessionUsingBrowserAuthFlowAsync(options: {
|
||||
throw new Error('Request missing session_secret search parameter.');
|
||||
}
|
||||
resolve(sessionSecret);
|
||||
response.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
response.write(`Website login has completed. You can now close this tab.`);
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
response.write(successBody);
|
||||
response.end();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
@@ -59,8 +86,17 @@ export async function getSessionUsingBrowserAuthFlowAsync(options: {
|
||||
}
|
||||
);
|
||||
|
||||
server.listen(serverPort, hostname, () => {
|
||||
server.listen(0, 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 => {
|
||||
@@ -70,9 +106,6 @@ export async function getSessionUsingBrowserAuthFlowAsync(options: {
|
||||
connections.delete(connection);
|
||||
});
|
||||
});
|
||||
|
||||
const authorizeUrl = buildExpoSsoLoginUrl();
|
||||
openBrowserAsync(authorizeUrl);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getExpoWebsiteBaseUrl, getSsoLocalServerPortAsync } from '../api';
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import { getSessionUsingBrowserAuthFlowAsync } from './expoSsoLauncher';
|
||||
import { fetchUserAsync } from './fetchUser';
|
||||
|
||||
@@ -7,11 +7,9 @@ export async function fetchSessionSecretAndSsoUserAsync(): Promise<{
|
||||
id: string;
|
||||
username: string;
|
||||
}> {
|
||||
const config = {
|
||||
const sessionSecret = await getSessionUsingBrowserAuthFlowAsync({
|
||||
expoWebsiteUrl: getExpoWebsiteBaseUrl(),
|
||||
serverPort: await getSsoLocalServerPortAsync(),
|
||||
};
|
||||
const sessionSecret = await getSessionUsingBrowserAuthFlowAsync(config);
|
||||
});
|
||||
|
||||
const userData = await fetchUserAsync({ sessionSecret });
|
||||
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
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,10 +1,13 @@
|
||||
import capitalize from '../capitalize';
|
||||
import capitalizeFirstLetter from '../capitalize';
|
||||
|
||||
describe(capitalize, () => {
|
||||
describe(capitalizeFirstLetter, () => {
|
||||
it('capitalizes the string', () => {
|
||||
expect(capitalize('dominik')).toBe('Dominik');
|
||||
expect(capitalizeFirstLetter('dominik')).toBe('Dominik');
|
||||
});
|
||||
it('works with an empty string', () => {
|
||||
expect(capitalize('')).toBe('');
|
||||
expect(capitalizeFirstLetter('')).toBe('');
|
||||
});
|
||||
it('does not change the case of other letters', () => {
|
||||
expect(capitalizeFirstLetter('inProgress')).toBe('InProgress');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,3 @@
|
||||
export default function capitalize(s: string): string {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : '';
|
||||
export default function capitalizeFirstLetter(s: string): string {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.build",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"strict": true,
|
||||
"target": "ES2019",
|
||||
"outDir": "build",
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user