Compare commits

...
96 Commits
Author SHA1 Message Date
Brent Vatne 1d45941f5d v0.14.0 2021-05-10 11:28:46 -07:00
Quinlan Jung 5e5badf11f [eas-cli] credentials manager DX nits (#383) 2021-05-03 19:16:14 -07:00
jkh 840c65946b [eas-cli] remove --force to fix eas publish (#381) 2021-05-03 15:35:52 -07:00
Quinlan Jung fdbd786126 [eas-cli] move from deprecated iosAppCredentialsArray to iosAppCredentialsList (#380) 2021-05-03 13:16:33 -07:00
Quinlan Jung d5365275e0 [eas-cli] beta android credentials manager (#378) 2021-04-29 12:07:20 -07:00
Quinlan Jung 886e48d3ee [eas-cli] android graphql types (#377) 2021-04-29 11:17:18 -07:00
Wojciech Kozyra 342c78a4f6 [eas-cli] disable pending build check (#373)
* [eas-cli] disable pending builds check

* update CHANGELOG.md
2021-04-28 15:13:51 +02:00
Axel Delafosse a483d94e26 [eas-cli] assert (#372) 2021-04-28 11:54:33 +02:00
Axel DelafosseandDominik Sokal b40412a7a1 [eas-cli] add QR code to install internal distribution build on device (#371)
* [website] add qr code to install internal distribution build on device

* [eas-cli] update changelog

* [eas-cli] sort imports

* [eas-cli] sort imports

* [eas-cli] improve getInstallUrl typing

Co-authored-by: Dominik Sokal <[email protected]>

* [eas-cli] fix typing

* [eas-cli] clean code

Co-authored-by: Dominik Sokal <[email protected]>
2021-04-28 11:43:21 +02:00
Quinlan Jung d652e04f7b [eas-cli] getAppAsync not used anywhere outside (#369) 2021-04-27 13:09:10 -07:00
Quinlan Jung 85326d1104 [eas-cli] merge beta and main cred utils (#368) 2021-04-27 13:08:51 -07:00
Quinlan Jung 28c087138b [eas-cli] replace printCredentials with printCredentialsBeta (#367) 2021-04-27 13:08:30 -07:00
jkh 8badf14ec3 [eas-cli] create asset keys without an extension (#366)
* Clients must be using expo-asset >= 8.3.2 in order to handle this new update format.
2021-04-27 06:02:12 -07:00
Wojciech Kozyra 4f1db29ef8 [eas-cli] increase build wait timeout (#370)
* [eas-cli] increase build wait timeout

* Update CHANGELOG.md
2021-04-27 14:19:23 +02:00
Quinlan Jung 2354dc1b71 update changelog 2021-04-26 12:16:21 -07:00
Quinlan Jung f106bfa6d7 [eas-cli] replace ctx.ios with ctx.newIos (#365)
remove old ctx.ios

ctx.newIos -> ctx.ios

fix lint

unused fixtures

remove unused fixtures

merge fixtures

fix lint
2021-04-26 12:11:36 -07:00
Quinlan Jung 1a7edbea06 [eas-cli] merge everything from actions/new to actions (#364)
delete unused actions

delete old inner class

remove uneeded dist cert utils

merge dist cert utils

remove unused functions of pprofile utils

pop out new directory
2021-04-26 12:10:27 -07:00
Quinlan Jung 5bfaee9e4d [eas-cli] swap beta credentials manager with current (#363) 2021-04-26 12:07:35 -07:00
Quinlan Jung 0cb2309d7d [eas-cli] add additionalTypenames to delete mutations to clear urql cache 2021-04-23 13:43:50 -07:00
Quinlan Jung 0415a1d97f changelog stuff 2021-04-23 12:47:59 -07:00
Quinlan Jung 249c60f4d1 [eas-cli] add remove provisioning profile to beta manager (#361) 2021-04-23 12:33:48 -07:00
Quinlan Jung c357cc378c [eas-cli] add high level actions to beta manager (#360) 2021-04-23 12:29:14 -07:00
Quinlan Jung f7c0d731b6 changelog stuff" 2021-04-22 22:48:38 -07:00
Quinlan Jung e852e3edac [eas-cli] add CreateDistCert and UseExistingDistCert to beta manager (#357)
* [eas-cli] add create and add existing dist cert options

* nits:

* fix dx
2021-04-22 17:27:17 -07:00
Quinlan Jung 2a283b74a3 [eas-cli][eas-json] add getBuildProfileNamesAsync helper (#351)
* [eas-cli][eas-json] getProfileNames helper

* tweak name

* pr feedback

* pr feedback
2021-04-22 13:47:18 -07:00
Quinlan Jung c749bc2372 [eas-cli] refactor UpdateCredentialsJson (#352)
* [eas-cli] refactor UpdateCredentialsJson

* fix broken tests from switching b64 pprofile test fixture

* fix lint

* add to beta manager
2021-04-22 12:02:45 -07:00
Brent Vatne 9c60f7190e Update CHANGELOG 2021-04-22 10:49:49 -07:00
Brent Vatne 8126b2276e v0.13.0 2021-04-22 10:16:06 -07:00
Dominik Sokal e1de21015f implement SetupInternalProvisioningProfile (#344) 2021-04-22 10:01:35 -07:00
Dominik Sokal 61e505f1f8 fix typo 2021-04-22 16:00:39 +02:00
Wojciech KozyraandDominik Sokal 2e3611161e fix api deprecation error (#359)
* fix api deprecation error

* update CHANGELOG.md

* Update packages/eas-cli/src/build/build.ts

Co-authored-by: Dominik Sokal <[email protected]>

Co-authored-by: Dominik Sokal <[email protected]>
2021-04-22 14:08:25 +02:00
Dominik Sokal 95f5eaaacf update changelog 2021-04-22 11:40:42 +02:00
Dominik Sokal 87a3ea99e2 v0.12.0 2021-04-22 10:32:28 +02:00
Quinlan Jung ddab65a55d [eas-cli] adhoc opposite name (#358) 2021-04-22 10:29:37 +02:00
Wojciech Kozyra 0cf8f8ce0d [eas-cli] support --clear-cache (#355)
* [eas-cli] support --clear-cache

* update CHANGELOG.md

* fix param description
2021-04-22 10:17:09 +02:00
Dominik Sokal 2b531ec70c update contributing doc (#353) 2021-04-22 10:04:44 +02:00
Dominik Sokal 6cd7966904 send username as build metadata property (#354)
* send username as build metadata property

* update changelog

* upgrade @expo/eas-build-job to 0.2.27
2021-04-21 14:38:25 +02:00
Dominik Sokal ad72ab990f Update CHANGELOG.md 2021-04-21 09:20:08 +02:00
Brent Vatne b53412fc00 Update CHANGELOG 2021-04-20 15:29:06 -07:00
Brent Vatne cbb2de9e11 v0.11.1 2021-04-20 15:00:15 -07:00
Brent Vatne 593c42f601 [eas-cli] Bump @expo/apple-utils to fix initial iOS app capabilities sync 2021-04-20 14:59:27 -07:00
Quinlan Jung 84b10b9dad [eas-cli] fix lint 2021-04-20 14:14:40 -07:00
Quinlan Jung 0f286061e4 [eas-cli] tests for SelectIosDistributionTypeGraphqlFromBuildProfile 2021-04-20 14:13:44 -07:00
Quinlan Jung c8aad01480 [eas-cli] non interactive support, aesthetic nits 2021-04-20 14:12:12 -07:00
Quinlan Jung d70a6bee69 [eas-cli] resolve ios distribution types (#348)
* [eas-cli] resolve ios distribution types

* pr feedback

* pr feedback
2021-04-20 12:46:55 -07:00
Dominik Sokal 67799b5c54 update changelog 2021-04-20 15:36:23 +02:00
Dominik Sokal 05b8eef622 v0.11.0 2021-04-20 15:08:57 +02:00
Dominik Sokal 50e56b5bc4 fix building APKs for Android managed projects (#349)
* fix building APKs for Android managed projects

* update CHANGELOG
2021-04-20 14:39:58 +02:00
Dominik Sokal 30ab4e62ec simplify credentials providers - remove auto option (#345)
* simplify credentials providers - remove auto option

* update changelog

* address PR feedback
2021-04-20 10:39:37 +02:00
Dominik Sokal a1d901294b allow for registering devices when building app for internal distribution (#340)
* allow for registering devices when building app for internal distribution

* update CHANGELOG
2021-04-19 12:39:20 +02:00
Dominik Sokal eed818cfe5 refactor setting up adhoc provisioning profile (#338)
* refactor appstore provisioning profile validation

* refactor SetupAdhocProvisioningProfile

* update CHANGELOG

* address PR feedback
2021-04-19 12:29:37 +02:00
Dominik Sokal db0b6bfcf5 remove redundant finally block 2021-04-16 15:54:44 +02:00
Dominik Sokal da81c1c6df remove incorrect usage example from secret:list command 2021-04-16 14:16:41 +02:00
Dominik Sokal 42d3296f07 update CHANGELOG 2021-04-16 14:13:09 +02:00
Dominik Sokal f9b4b1a6e8 v0.10.0 2021-04-16 13:56:25 +02:00
Will Schurman d03603a75f Use special Expo SDK runtime version for managed projects (#336) 2021-04-15 11:15:02 -07:00
Juwan Wheatley ffa9e5ef66 [eas-cli] make secrets commands follow styleguide (#315)
* [eas-cli] make CLI more styleguide-compliant

* add --json flag

* update changelog

* json flag is a new feature, not a breaking change

* remove secrets JSON flag since we still show non-result output (and same for other commands)

* remove extra import
2021-04-15 13:05:28 -04:00
Dominik Sokal e7f3cf9c74 Pass --preserveWatchOutput to tsc --watch
This makes the 'yarn start' output preserve the output for previous tsc runs.
2021-04-15 16:42:04 +02:00
Dominik Sokal e82a4a0a57 replace all iOS -> Ios (#337) 2021-04-15 12:23:14 +02:00
Bartłomiej Klocek 175f07fac9 [eas cli][submit] Use GraphQL instead of REST endpoints (#333)
* Autogenerated gql code

* Add Submission Query

* Add Submission Mutation

* Use GQL generated types

* Remove Submission types

* Replace SubmissionPlatform with AppPlatform

* Update Fix tests
2021-04-15 11:43:02 +02:00
Dominik Sokal 2869744a5d add support for adhoc and universal distribution within enterprise teams (#335)
* [eas-json] add enterpriseProvisioning prop

* implement generating enterprise universal credentials

* fix tests
2021-04-15 10:42:59 +02:00
Dominik Sokal 99f898a483 refactor IosCredentialsProvider to use graphql (#329)
* refactor IosCredentialsProvider to use graphql

* add tests for IosCredentialsProvider

* address PR feedback

* fix tests
2021-04-15 10:23:42 +02:00
Dominik Sokal 1448db96e6 don't update capabilities if there's nothing to change (#334)
* don't update capabilities if there's nothing to change

* add tests

* update CHANGELOG
2021-04-15 10:19:30 +02:00
Quinlan Jung e02036a46a [eas-cli] ilovecats deletion 2021-04-14 14:31:04 -07:00
Quinlan JungandDominik Sokal 09f33263cb [eas-cli] use new graphql api in SetupBuildCredentialsFromCredentials… (#328)
* [eas-cli] use new graphql api in SetupBuildCredentialsFromCredentialsJson

* pr feedback

* tests

* Update packages/eas-cli/src/credentials/ios/actions/new/BuildCredentialsUtils.ts

Co-authored-by: Dominik Sokal <[email protected]>

* pr feedback

Co-authored-by: Dominik Sokal <[email protected]>
2021-04-14 14:28:06 -07:00
jkh 412b1ec74c [eas-cli] add update:delete command (#332) 2021-04-14 12:04:56 -07:00
jkh 9a1538fb4d add eas update:view [ID] (#327) 2021-04-14 11:04:58 -07:00
jkh 0ad9b31ee3 [eas-cli] update channel:list to handle rollouts (#331) 2021-04-14 11:04:49 -07:00
Wojciech Kozyra 4cbb7bb2f2 [eas-cli] Support for local builds (#305)
* [eas-cli] Support for local builds

* add CHANGELOG + mark as experimental

* review feedback
2021-04-14 09:08:06 +02:00
jkh ae109cd8c6 [eas-cli] handle branch with no updates gracefully (#330) 2021-04-13 07:11:54 -07:00
Dominik Sokal fbaba774a6 enable type checking for tests and mocks (#325)
* use separate tsconfig files for development and release

* fix type errors in tests

* add typecheck script

* address PR feedback
2021-04-13 11:49:07 +02:00
jkh 38bc8062a4 [eas-cli] make branch name a positional instead of a named arg (#326) 2021-04-12 10:50:41 -07:00
Dominik Sokal d41d184b55 add start script (#323) 2021-04-12 16:36:56 +02:00
jkh d6d32a7a7d [eas-cli] clean up formatting of list/view commands (#322) 2021-04-12 07:19:37 -07:00
Dominik Sokal 15a9532fa7 refactor credentials actions (#321)
* make SetupAdhocProvisioningProfile.runAsync return result

* make SetupDistributionCertificate.runAsync return result

* print ios credentials using new display function

* generate graphql code

* update CHANGELOG

* address PR feedback

* updates changes after running generate-graphql-code

* address more PR feedback

* fix lint warning
2021-04-12 11:38:06 +02:00
Dominik Sokal 3b9fb724fe update CONTRIBUTING.md 2021-04-12 10:41:36 +02:00
Dominik Sokal 33767b5635 update CHANGELOG 2021-04-12 10:18:27 +02:00
Test User 175fc2b30e v0.9.1 2021-04-09 14:43:36 -07:00
Quinlan Jung aec9347d69 [eas-cli] fix validation check of provisioning profile (#318)
* [eas-cli] fix validation check of provisioning profile

* changelog and stuff

* quin not dominik

* move the changelog thing
2021-04-09 13:05:25 -07:00
Quinlan Jung 91500d07c0 [eas-cli] replace SetupProvisioningProfile (#317)
* [eas-cli] replace SetupProvisioningProfile

* changelog

* more changelog
2021-04-09 13:02:12 -07:00
Test User 62ca14b504 Commit README updates 2021-04-09 10:13:28 -07:00
Test User 9158a95d57 v0.9.0 2021-04-09 09:59:29 -07:00
Test User d0f3e387e7 Bump @expo/config-plugins to fix eas build:configure on Windows 2021-04-09 09:58:43 -07:00
jkh 158856f8ec [eas-cli] upgrade @urql/core and add retry (#320) 2021-04-09 07:19:04 -07:00
Dominik Sokal 1e59957753 Update CHANGELOG.md 2021-04-09 11:50:36 +02:00
Dominik Sokal f97d46040c add commands for managing webhooks (#314)
* add webhook:create command

* add webhook:list command

* add webhook:view command

* add webhook:update command

* add webhook:delete command

* generate graphql code

* update CHANGELOG

* address PR feedback
2021-04-09 11:50:07 +02:00
Dominik Sokal d7485cb5d2 fix first ios build for a project without ios.bundleIdentifier in app.json (#319)
* fix first ios build for a project without ios.bundleIdentifier in app.json

* update CHANGELOG

* address PR feedback

* address more PR feedback
2021-04-09 11:31:17 +02:00
071fdb7f4a [eas submit] Added new common error messages (#311)
* [eas submit] Added new common error messages

* Update CHANGELOG

* Apply suggestions from code review

Co-authored-by: James Ide <[email protected]>
Co-authored-by: Brent Vatne <[email protected]>

Co-authored-by: James Ide <[email protected]>
Co-authored-by: Brent Vatne <[email protected]>
2021-04-09 08:17:33 +02:00
jkh 6dd96ffb20 [eas-cli] add more info to transient errors (#316) 2021-04-08 16:26:34 -04:00
Quinlan Jung f56fb0f7de [eas-cli] setupProvisioningProfile using graphql (#301)
* [eas-cli] setupProvisioningProfile using graphql

refactor

fix lint

setup provisioning profile

* refactor stuff

* add comments for ppl

* simplify logic

* tests

* pr feedback
2021-04-08 11:19:14 -07:00
Wojciech Kozyra 7d28f6add9 add LICENSE (#313) 2021-04-08 12:35:50 +02:00
Juwan Wheatley eebef3e461 [eas-cli] use autocomplete list for secrets:delete (#309)
* [eas-cli] use autocomplete list for secrets:delete

* update changelog

* check if secret is account-wide and tell user that it could affect multiple apps
2021-04-07 13:08:00 -04:00
Dominik Sokal 8336f630ea update CHANGELOG 2021-04-06 12:55:17 +02:00
Dominik Sokal 7d6bdbb6ed v0.8.1 2021-04-06 12:30:31 +02:00
Dominik Sokal 403b5c237f don't use graphql cache for polling builds (#310)
* don't use graphql cache for polling builds

* update CHANGELOG
2021-04-06 12:29:41 +02:00
Dominik Sokal 2367ed7911 update CHANGELOG.md 2021-04-06 11:21:13 +02:00
197 changed files with 9109 additions and 6134 deletions

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
with:
node-version: ${{ matrix.node }}
- run: yarn install --frozen-lockfile --check-files
- run: yarn lerna run build
- run: yarn typecheck
- run: yarn test
if: ${{ !matrix.coverage }}
- run: yarn test --coverage
+122 -1
View File
@@ -6,15 +6,136 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
- Make eas branch:publish work with expo-cli >= 4.4.3 ([#366](https://github.com/expo/eas-cli/pull/366) by [@jkhales](https://github.com/jkhales))
- Create asset keys without an extension. ([#366](https://github.com/expo/eas-cli/pull/366) by [@jkhales](https://github.com/jkhales))
### 🎉 New features
- Release new credentials manager. ([#363](https://github.com/expo/eas-cli/pull/363) by [@quinlanj](https://github.com/quinlanj))
- Add QR code to install internal distribution build on device. ([#371](https://github.com/expo/eas-cli/pull/371) by [@axeldelafosse](https://github.com/axeldelafosse))
### 🐛 Bug fixes
### 🧹 Chores
- Cleanup unused code. ([#364](https://github.com/expo/eas-cli/pull/364), [#365](https://github.com/expo/eas-cli/pull/365) by [@quinlanj](https://github.com/quinlanj))
- Redesign UX in beta credentials manager. ([#360](https://github.com/expo/eas-cli/pull/360) by [@quinlanj](https://github.com/quinlanj))
- Port more options to the beta credentials manager. ([#352](https://github.com/expo/eas-cli/pull/352), [357](https://github.com/expo/eas-cli/pull/357), [361](https://github.com/expo/eas-cli/pull/361) by [@quinlanj](https://github.com/quinlanj))
- Add getBuildProfileNamesAsync helper to EasJsonReader. ([#351](https://github.com/expo/eas-cli/pull/351) by [@quinlanj](https://github.com/quinlanj))
- Increase build timeout to 1 hour. ([#370](https://github.com/expo/eas-cli/pull/370) by [@wkozyra95](https://github.com/wkozyra95))
- Remove check for pending builds. ([#373](https://github.com/expo/eas-cli/pull/373) by [@wkozyra95](https://github.com/wkozyra95))
## [0.13.0](https://github.com/expo/eas-cli/releases/tag/v0.13.0) - 2021-04-22
### 🎉 New features
- Implement offline distribution certificate validation when running a build in non-interactive mode. ([#344](https://github.com/expo/eas-cli/pull/344) by [@dsokal](https://github.com/dsokal))
- Add support for building internal distribution apps for Apple Enterprise Teams. ([#344](https://github.com/expo/eas-cli/pull/344) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Display descriptive error message when API for EAS Build changes. ([#359](https://github.com/expo/eas-cli/pull/359) by [@wkozyra95](https://github.com/wkozyra95))
## [0.12.0](https://github.com/expo/eas-cli/releases/tag/v0.12.0) - 2021-04-22
### 🎉 New features
- Add the initiating user's username to the build metadata. ([#354](https://github.com/expo/eas-cli/pull/354) by [@dsokal](https://github.com/dsokal))
- Add `--clear-cache` flag for `eas build`. ([#355](https://github.com/expo/eas-cli/pull/355) by [@wkozyra95](https://github.com/wkozyra95))
### 🐛 Bug fixes
- Fix the bug where the account name was used as the username. ([#354](https://github.com/expo/eas-cli/pull/354) by [@dsokal](https://github.com/dsokal))
## [0.11.1](https://github.com/expo/eas-cli/releases/tag/v0.11.1) - 2021-04-20
### 🎉 New features
- Resolve ios distribution types. ([#348](https://github.com/expo/eas-cli/pull/348) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- Bump version of `@expo/apple-utils` to fix capabilities sync immediately after initial identifier registration. ([593c4](https://github.com/expo/eas-cli/commit/593c42f601396deba1751489343ec07b0a8876da) by [@brentvatne](https://github.com/brentvatne))
## [0.11.0](https://github.com/expo/eas-cli/releases/tag/v0.11.0) - 2021-04-20
### 🛠 Breaking changes
- `"credentialsSource": "auto"` is now deprecated. The option will use `"remote"` as the default value. ([#345](https://github.com/expo/eas-cli/pull/345) by [@dsokal](https://github.com/dsokal))
### 🎉 New features
- Add validation when setting up ad-hoc provisioning profile in non-interactive mode. ([#338](https://github.com/expo/eas-cli/pull/338) by [@dsokal](https://github.com/dsokal))
- Allow for registering devices when running an ad-hoc build for the first time. ([#340](https://github.com/expo/eas-cli/pull/340) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Fix the bug where all Android managed builds produced AAB archives. The `buildType` property from `eas.json` is not ignored now. ([#349](https://github.com/expo/eas-cli/pull/349) by [@dsokal](https://github.com/dsokal))
## [0.10.0](https://github.com/expo/eas-cli/releases/tag/v0.10.0) - 2021-04-16
### 🛠 Breaking changes
- `secrets:<COMMAND>` is renamed to `secret:<COMMAND>` ([#315](https://github.com/expo/eas-cli/pull/315) by [@fiberjw](https://github.com/fiberjw))
- `secret:delete` now accepts `id` through a flag rather than an argument ([#315](https://github.com/expo/eas-cli/pull/315) by [@fiberjw](https://github.com/fiberjw))
### 🎉 New features
- Use special Expo SDK runtime version for managed projects ([#336](https://github.com/expo/eas-cli/pull/336)) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- Fix the behavior where the provisioning profile was invalidated after syncing bundle id capabilities. ([#334](https://github.com/expo/eas-cli/pull/334) by [@dsokal](https://github.com/dsokal))
### 🧹 Chores
- Change build credentials summary format. ([#321](https://github.com/expo/eas-cli/pull/321) by [@dsokal](https://github.com/dsokal))
## [0.9.1](https://github.com/expo/eas-cli/releases/tag/v0.9.1) - 2021-04-09
### 🐛 Bug fixes
- Fix provisioning profile validation to actually check status. ([#318](https://github.com/expo/eas-cli/pull/318) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- Replace Credentials REST API calls with GraphQL counterparts, also improved DX. ([#293](https://github.com/expo/eas-cli/pull/293), [#299](https://github.com/expo/eas-cli/pull/299), [#301](https://github.com/expo/eas-cli/pull/301), [#317](https://github.com/expo/eas-cli/pull/317) by [@quinlanj](https://github.com/quinlanj))
## [0.9.0](https://github.com/expo/eas-cli/releases/tag/v0.9.0) - 2021-04-09
### 🎉 New features
- Retry graphQL call on network or transient server errors. ([#320](https://github.com/expo/eas-cli/pull/320) by [@jkhales](https://github.com/jkhales))
- Display more friendly error messages when `eas submit` fails. ([#311](https://github.com/expo/eas-cli/pull/297) by [@barthap](https://github.com/barthap))
- Add support for managing webhooks (new commands: `webhook:create`, `webhook:view`, `webhook:list`, `webhook:update`, and `webhook:delete`). ([#314](https://github.com/expo/eas-cli/pull/314) by [@dsokal](https://github.com/dsokal))
- Support for local builds [experimental]. ([#305](https://github.com/expo/eas-cli/pull/305) by [@wkozyra95](https://github.com/wkozyra95))
### 🐛 Bug fixes
- Fix the issue where the first iOS build fails for a project without `ios.bundleIdentifier` set in `app.json`. ([#319](https://github.com/expo/eas-cli/pull/319) by [@dsokal](https://github.com/dsokal))
- Bump @expo/config-plugins to fix `eas build:configure` on Windows. ([d0f3e](https://github.com/expo/eas-cli/commit/d0f3e387e7f31dbce4c3b93b8d083c7d20d9e30d) by [@brentvatne](https://github.com/brentvatne))
- Replace REST API calls with GraphQL counterparts. ([#333](https://github.com/expo/eas-cli/pull/333) by [@barthap](https://github.com/barthap))
## [0.8.1](https://github.com/expo/eas-cli/releases/tag/v0.8.1) - 2021-04-06
### 🐛 Bug fixes
- Fix the issue where the build status never got updated when running `eas build`. ([#310](https://github.com/expo/eas-cli/pull/310) by [@dsokal](https://github.com/dsokal))
## [0.8.0](https://github.com/expo/eas-cli/releases/tag/v0.8.0) - 2021-04-06
### 🛠 Breaking changes
- Change the way of disabling cache - add `cache.disabled` field. ([#295](https://github.com/expo/eas-cli/pull/295) by [@dsokal](https://github.com/dsokal))
- `secrets:create` now uses flags rather than positional arguments ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
- `secrets:create`'s `target` arg is now called `scope` ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
- `secrets:list`'s `target` property is now called `scope` ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
- `secrets:delete`'s `ID` arg is now optional ([#309](https://github.com/expo/eas-cli/pull/309) by [@fiberjw](https://github.com/fiberjw))
- `secrets:delete`'s now allows users to choose secrets from a list ([#309](https://github.com/expo/eas-cli/pull/309) by [@fiberjw](https://github.com/fiberjw))
### 🎉 New features
- `build:view` and `build:list` now showing the distribution type (store / internal) and release channel. ([#284](https://github.com/expo/eas-cli/pull/284) by [@vthibault](https://github.com/vthibault))
- Add analytics to EAS Build. ([#162](https://github.com/expo/eas-cli/pull/162) by [@wkozyra95](https://github.com/wkozyra95))
- Improve tar archive support in EAS Submit. ([#297](https://github.com/expo/eas-cli/pull/297) by [@barthap](https://github.com/barthap))
+27 -6
View File
@@ -1,9 +1,30 @@
## Releasing a new version
1. Add release notes to `CHANGELOG.md`.
1. Run `yarn release` in the repository root folder. This will create a new tag
like `v1.0.0`.
1. Wait until the `release` GitHub workflow has finished.
1. Once the release ready to go, you can go to
https://github.com/expo/eas-cli/releases and mark it as not draft.
1. Run `yarn release` in the repository root folder. This will create a new tag like `v1.0.0`.
1. **Wait until the `release` GitHub workflow has finished.**
1. Once the release is ready to go, you can go to https://github.com/expo/eas-cli/releases and mark it as not draft.
- Remember to copy the changelog entries from `CHANGELOG.md`.
1. Run `yarn lerna publish from-git` in the repository root folder. This will publish the packages to npm.
- This command works only if `HEAD` is on the release commit, e.g. when `git log -1` prints:
```
commit cbb2de9e118bfbcb5a597b261d2dbef0bb5d4b0b (tag: v0.11.1)
Author: Brent Vatne <[email protected]>
Date: Tue Apr 20 15:00:15 2021 -0700
v0.11.1
```
- If you run `yarn lerna publish from-git` on any other commit, it'll print something like:
```
lerna notice from-git No tagged release found
lerna success No changed packages to publish
```
- If you ever run into the above issue, use `git checkout RELEASE_COMMIT_HASH` to checkout to the release commit. Later, when you've published the packages to npm, run `git checkout main` to go back to the latest commit.
1. Update `CHANGELOG.md`.
- Add the new section with the release version in the title.
- Leave the `main` section empty (without any changelog entries).
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2020-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.8.0"
"version": "0.14.0"
}
+3
View File
@@ -6,6 +6,9 @@
"scripts": {
"build": "lerna run build",
"build:tarballs:linux": "yarn build && yarn workspace eas-cli oclif-dev pack --targets linux-x64",
"typecheck": "lerna run typecheck",
"start": "lerna run watch --parallel",
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"lint": "eslint . --ext .ts",
"release": "lerna version",
+117 -30
View File
@@ -47,10 +47,15 @@ eas --help COMMAND
* [`eas device:list`](#eas-devicelist)
* [`eas device:view [UDID]`](#eas-deviceview-udid)
* [`eas help [COMMAND]`](#eas-help-command)
* [`eas secrets:create`](#eas-secretscreate)
* [`eas secrets:delete ID`](#eas-secretsdelete-id)
* [`eas secrets:list`](#eas-secretslist)
* [`eas secret:create`](#eas-secretcreate)
* [`eas secret:delete`](#eas-secretdelete)
* [`eas secret:list`](#eas-secretlist)
* [`eas submit --platform=(android|ios)`](#eas-submit---platformandroidios)
* [`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 account:login`
@@ -64,7 +69,7 @@ ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/account/login.ts)_
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/account/login.ts)_
## `eas account:logout`
@@ -78,7 +83,7 @@ ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/account/logout.ts)_
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/account/logout.ts)_
## `eas account:view`
@@ -92,7 +97,7 @@ ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/account/view.ts)_
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -103,7 +108,7 @@ USAGE
$ eas analytics [STATUS]
```
_See code: [build/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/analytics.ts)_
_See code: [build/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/analytics.ts)_
## `eas build`
@@ -115,6 +120,8 @@ USAGE
OPTIONS
-p, --platform=(android|ios|all)
--clear-cache Clear cache before the build
--local Run build locally [experimental]
--non-interactive Run command in --non-interactive mode
--profile=profile [default: release] Name of the build profile from eas.json
--skip-credentials-check Skip validation of build credentials
@@ -122,7 +129,7 @@ OPTIONS
--[no-]wait Wait for build(s) to complete
```
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/build/index.ts)_
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -133,7 +140,7 @@ USAGE
$ eas build:cancel [BUILD_ID]
```
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/build/cancel.ts)_
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/build/cancel.ts)_
## `eas build:configure`
@@ -148,7 +155,7 @@ OPTIONS
--allow-experimental Enable experimental configuration steps.
```
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/build/configure.ts)_
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/build/configure.ts)_
## `eas build:list`
@@ -164,7 +171,7 @@ OPTIONS
--status=(in-queue|in-progress|errored|finished|canceled)
```
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/build/list.ts)_
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/build/list.ts)_
## `eas build:view [BUILD_ID]`
@@ -175,7 +182,7 @@ USAGE
$ eas build:view [BUILD_ID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/build/view.ts)_
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/build/view.ts)_
## `eas credentials`
@@ -186,7 +193,7 @@ USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/credentials.ts)_
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/credentials.ts)_
## `eas device:create`
@@ -197,7 +204,7 @@ USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/device/create.ts)_
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/device/create.ts)_
## `eas device:list`
@@ -211,7 +218,7 @@ OPTIONS
--apple-team-id=apple-team-id
```
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/device/list.ts)_
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/device/list.ts)_
## `eas device:view [UDID]`
@@ -222,7 +229,7 @@ USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/device/view.ts)_
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -241,13 +248,13 @@ OPTIONS
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
## `eas secrets:create`
## `eas secret:create`
Create an environment secret on the current project or owner account.
```
USAGE
$ eas secrets:create
$ eas secret:create
OPTIONS
--name=name Name of the secret
@@ -255,35 +262,35 @@ OPTIONS
--value=value Value of the secret
```
_See code: [build/commands/secrets/create.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/secrets/create.ts)_
_See code: [build/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/secret/create.ts)_
## `eas secrets:delete ID`
## `eas secret:delete`
Delete an environment secret by ID.
```
USAGE
$ eas secrets:delete ID
$ eas secret:delete
ARGUMENTS
ID ID of the secret to delete
OPTIONS
--id=id ID of the secret to delete
DESCRIPTION
Unsure where to find the secret's ID? Run eas secrets:list
```
_See code: [build/commands/secrets/delete.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/secrets/delete.ts)_
_See code: [build/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/secret/delete.ts)_
## `eas secrets:list`
## `eas secret:list`
Lists environment secrets available for your current app
```
USAGE
$ eas secrets:list
$ eas secret:list
```
_See code: [build/commands/secrets/list.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/secrets/list.ts)_
_See code: [build/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/secret/list.ts)_
## `eas submit --platform=(android|ios)`
@@ -309,8 +316,7 @@ OPTIONS
iOS specific options=asc-app-id App Store Connect unique application Apple ID
number. Providing this param results in skipping
app creation step. Learn more:
https://expo.fyi/asc-app-id
app creation step. Learn more.
iOS specific options=bundle-identifier Your iOS Bundle Identifier (default:
expo.ios.bundleIdentifier from app config)
@@ -363,5 +369,86 @@ EXAMPLES
and provide its App ID
```
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.8.0/build/commands/submit.ts)_
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/submit.ts)_
## `eas webhook:create`
Create a webhook on the current project.
```
USAGE
$ eas webhook:create
OPTIONS
--event=(BUILD) [default: BUILD] Event type that triggers the webhook
--secret=secret Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature'
header.
--url=url Webhook URL
```
_See code: [build/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
Delete a webhook on the current project.
```
USAGE
$ eas webhook:delete [ID]
ARGUMENTS
ID ID of the webhook to delete
```
_See code: [build/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/webhook/delete.ts)_
## `eas webhook:list`
List webhooks on the current project.
```
USAGE
$ eas webhook:list
OPTIONS
--event=(BUILD) Event type that triggers the webhook
```
_See code: [build/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/webhook/list.ts)_
## `eas webhook:update`
Create a webhook on the current project.
```
USAGE
$ eas webhook:update
OPTIONS
--event=(BUILD) [default: BUILD] Event type that triggers the webhook
--id=id (required) Webhook ID
--secret=secret Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature'
header.
--url=url Webhook URL
```
_See code: [build/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/webhook/update.ts)_
## `eas webhook:view ID`
View a webhook on the current project.
```
USAGE
$ eas webhook:view ID
ARGUMENTS
ID ID of the webhook to view
```
_See code: [build/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v0.14.0/build/commands/webhook/view.ts)_
<!-- commandsstop -->
+1
View File
@@ -3,6 +3,7 @@ schema: 'https://staging.exp.host/--/graphql'
documents:
- 'src/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/commands/**/*.ts'
generates:
src/graphql/generated.ts:
File diff suppressed because it is too large. Load diff
+11 -7
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.8.0",
"version": "0.14.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -10,22 +10,24 @@
"dependencies": {
"@amplitude/identify": "^1.5.0",
"@amplitude/node": "^1.5.0",
"@expo/apple-utils": "0.0.0-alpha.17",
"@expo/apple-utils": "0.0.0-alpha.18",
"@expo/config": "~3.3.19",
"@expo/config-plugins": "1.0.21-alpha.0",
"@expo/eas-build-job": "0.2.22",
"@expo/eas-json": "^0.8.0",
"@expo/config-plugins": "1.0.24",
"@expo/eas-build-job": "0.2.28",
"@expo/eas-json": "^0.14.0",
"@expo/json-file": "^8.2.24",
"@expo/plist": "^0.0.11",
"@expo/plugin-warn-if-update-available": "^1.7.0",
"@expo/results": "^1.0.0",
"@expo/sdk-runtime-versions": "^1.0.0",
"@expo/spawn-async": "^1.5.0",
"@hapi/joi": "^17.1.1",
"@oclif/command": "^1",
"@oclif/config": "^1",
"@oclif/errors": "^1.3.3",
"@oclif/plugin-help": "^3",
"@urql/core": "^1.13.0",
"@urql/core": "^2.0.0",
"@urql/exchange-retry": "^0.2.0",
"chalk": "^4.1.0",
"cli-table3": "^0.6.0",
"dateformat": "^3.0.3",
@@ -162,7 +164,9 @@
"scripts": {
"postpack": "rm -f oclif.manifest.json",
"prepack": "rm -rf build && yarn build && yarn oclif-dev manifest && yarn oclif-dev readme",
"build": "tsc",
"build": "tsc --project tsconfig.build.json",
"watch": "yarn build --watch --preserveWatchOutput",
"typecheck": "tsc",
"test": "jest",
"version": "yarn oclif-dev readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml"
+2 -2
View File
@@ -21,7 +21,7 @@ describe('apiClient', () => {
],
});
let error: Error;
let error: Error | null = null;
try {
await apiClient.post('test');
} catch (e) {
@@ -41,7 +41,7 @@ describe('apiClient', () => {
it('does not convert non-APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl()).post('/--/api/v2/test').reply(500, 'Something went wrong');
let error: Error;
let error: Error | null = null;
try {
await apiClient.post('test');
} catch (e) {
@@ -1,294 +0,0 @@
import { Workflow } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import prompts from 'prompts';
import { asMock } from '../../__tests__/utils';
import { CredentialsProvider } from '../../credentials/CredentialsProvider';
import { ensureCredentialsAsync } from '../credentials';
jest.mock('prompts');
function createMockCredentialsProvider({
hasRemote,
hasLocal,
isLocalSynced,
}: any): CredentialsProvider {
return {
platform: 'android',
hasRemoteAsync: jest.fn().mockImplementation(() => hasRemote || false),
hasLocalAsync: jest.fn().mockImplementation(() => hasLocal || false),
isLocalSyncedAsync: jest.fn().mockImplementation(() => isLocalSynced || false),
} as CredentialsProvider;
}
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
});
beforeEach(() => {
asMock(prompts).mockReset();
asMock(prompts).mockImplementation(() => {
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
});
});
describe(ensureCredentialsAsync, () => {
describe('for generic builds', () => {
it("should use credentials.json if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
expect(src).toBe('local');
});
it("should use credentials from server if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
expect(src).toBe('remote');
});
it('should use credentials.json if credentials are the same in credentials.json and on server', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
it('should ask which credentials to use when local and remote crednitals have different values', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { select: 'local' };
});
const src = await ensureCredentialsAsync(
provider,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(1);
expect(src).toBe('local');
});
it('should ask when local and remote are not the same (select remote)', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { select: 'remote' };
});
const src = await ensureCredentialsAsync(
provider,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(1);
expect(src).toBe('remote');
});
it('should should throw an error when local and remote are not the same (in non interactive mode)', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
try {
await ensureCredentialsAsync(provider, Workflow.GENERIC, CredentialsSource.AUTO, true);
throw new Error('ensureCredentialsAsync should throw an Error');
} catch (e) {
expect(e.message).toMatch(
'Contents of your local credentials.json for Android are not the same as credentials on EAS servers'
);
}
expect(prompts).toHaveBeenCalledTimes(0);
});
it('should ask when no credentials are present and return remote if confirm=true', async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: false,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { value: true }; //confirm
});
const src = await ensureCredentialsAsync(
provider,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(1);
expect(src).toBe('remote');
});
it('should ask when no credentials are present and abort if confimr=false', async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: false,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { value: false }; //confirm
});
try {
await ensureCredentialsAsync(provider, Workflow.GENERIC, CredentialsSource.AUTO, false);
throw new Error('ensureCredentialsAsync should throw an Error');
} catch (e) {
expect(e.message).toMatch(
'Aborting build process, credentials are not configured for Android'
);
}
expect(prompts).toHaveBeenCalledTimes(1);
});
it('should throw an error when no credentials are present in non interactive mode', async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: false,
isLocalSynced: false,
});
asMock(prompts).mockImplementation(() => {
return { value: true }; // confirm
});
try {
await ensureCredentialsAsync(provider, Workflow.GENERIC, CredentialsSource.AUTO, true);
throw new Error('ensureCredentialsAsync should throw an Error');
} catch (e) {
expect(e.message).toMatch('Credentials for this app are not configured');
}
expect(prompts).toHaveBeenCalledTimes(0);
});
});
describe('for managed builds', () => {
it("should use credentials.json if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.MANAGED,
CredentialsSource.AUTO,
false
);
expect(src).toBe('local');
});
it("should use credentials from server if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.MANAGED,
CredentialsSource.AUTO,
false
);
expect(src).toBe('remote');
});
it('should use credentials.json if credentials are the same in credentials.json and on server', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.MANAGED,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
it('should use credentials.json even if credentials on server are different', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.MANAGED,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
});
describe('generic builds with credentialsSource set to local', () => {
it('should use credentials.json without asking even if credentials on server are different', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.GENERIC,
CredentialsSource.LOCAL,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
});
describe('generic builds with credentialsSource set to remote', () => {
it('should use credentials from server even if credentials.json exists and have different values', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.GENERIC,
CredentialsSource.REMOTE,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('remote');
});
});
});
+4 -8
View File
@@ -13,9 +13,9 @@ import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
import { CredentialsResult, prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
import { ensureCredentialsAsync } from '../credentials';
import { transformMetadata } from '../graphql';
import { Platform } from '../types';
import { logCredentialsSource } from '../utils/credentials';
import { ensureApplicationIdIsValidAsync } from './applicationId';
import { validateAndSyncProjectConfigurationAsync } from './configure';
import { transformGenericJob, transformManagedJob } from './graphql';
@@ -24,7 +24,7 @@ import { prepareJobAsync } from './prepareJob';
export async function prepareAndroidBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig
): Promise<() => Promise<string>> {
): Promise<() => Promise<string | undefined>> {
const buildCtx = createBuildContext<Platform.ANDROID>({
commandCtx,
platform: Platform.ANDROID,
@@ -125,12 +125,8 @@ async function ensureAndroidCredentialsAsync(
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
}
);
const credentialsSource = await ensureCredentialsAsync(
provider,
ctx.buildProfile.workflow,
ctx.buildProfile.credentialsSource,
ctx.commandCtx.nonInteractive
);
const { credentialsSource } = ctx.buildProfile;
logCredentialsSource(credentialsSource, Platform.ANDROID);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
source: credentialsSource,
+16 -1
View File
@@ -1,6 +1,10 @@
import { Android } from '@expo/eas-build-job';
import { AndroidGenericJobInput, AndroidManagedJobInput } from '../../graphql/generated';
import {
AndroidGenericJobInput,
AndroidManagedBuildType,
AndroidManagedJobInput,
} from '../../graphql/generated';
import { transformProjectArchive } from '../graphql';
export function transformGenericJob(job: Android.GenericJob): AndroidGenericJobInput {
@@ -25,5 +29,16 @@ export function transformManagedJob(job: Android.ManagedJob): AndroidManagedJobI
builderEnvironment: job.builderEnvironment,
cache: job.cache,
username: job.username,
buildType: transformBuildType(job.buildType),
};
}
function transformBuildType(buildType: Android.ManagedBuildType): AndroidManagedBuildType {
if (buildType === Android.ManagedBuildType.APK) {
return AndroidManagedBuildType.Apk;
} else if (buildType === Android.ManagedBuildType.APP_BUNDLE) {
return AndroidManagedBuildType.AppBundle;
} else {
return AndroidManagedBuildType.DevelopmentClient;
}
}
@@ -1,24 +1,17 @@
import {
Android,
ArchiveSource,
ArchiveSourceType,
Cache,
Job,
Workflow,
sanitizeJob,
} from '@expo/eas-build-job';
import { Android, ArchiveSource, Cache, Job, Workflow, sanitizeJob } from '@expo/eas-build-job';
import { AndroidGenericBuildProfile, AndroidManagedBuildProfile } from '@expo/eas-json';
import path from 'path';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { readEnvironmentSecretsAsync } from '../../credentials/credentialsJson/read';
import { getProjectAccountNameAsync } from '../../project/projectUtils';
import { getUsername } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { gitRootDirectoryAsync } from '../../utils/git';
import { BuildContext } from '../context';
import { Platform } from '../types';
interface JobData {
archiveBucketKey: string;
projectArchive: ArchiveSource;
credentials?: AndroidCredentials;
}
@@ -71,10 +64,7 @@ async function prepareJobCommonAsync(
return {
platform: Platform.ANDROID,
projectArchive: {
type: ArchiveSourceType.S3,
bucketKey: jobData.archiveBucketKey,
},
projectArchive: jobData.projectArchive,
builderEnvironment: {
image: ctx.buildProfile.image,
node: ctx.buildProfile.node,
@@ -82,7 +72,10 @@ async function prepareJobCommonAsync(
ndk: ctx.buildProfile.ndk,
env: ctx.buildProfile.env,
},
cache: ctx.buildProfile.cache,
cache: {
...ctx.buildProfile.cache,
clear: ctx.commandCtx.clearCache,
},
secrets: {
...(environmentSecrets ? { environmentSecrets } : {}),
...buildCredentials,
@@ -114,11 +107,11 @@ async function prepareManagedJobAsync(
): Promise<Partial<Android.ManagedJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.exp);
const username = getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync());
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.MANAGED,
username: accountName,
username,
buildType: buildProfile.buildType,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
+39 -11
View File
@@ -1,4 +1,4 @@
import { Job, Metadata } from '@expo/eas-build-job';
import { ArchiveSource, ArchiveSourceType, Job, Metadata } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import fs from 'fs-extra';
@@ -11,6 +11,7 @@ import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import { requestedPlatformDisplayNames } from './constants';
import { BuildContext } from './context';
import { runLocalBuildAsync } from './local';
import { collectMetadata } from './metadata';
import { Platform, TrackingContext } from './types';
import Analytics, { Event } from './utils/analytics';
@@ -39,9 +40,9 @@ interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration,
prepareJobAsync(
ctx: BuildContext<TPlatform>,
jobData: {
archiveBucketKey: string;
credentials?: Credentials;
projectConfiguration?: ProjectConfiguration;
projectArchive: ArchiveSource;
}
): Promise<Job>;
sendBuildRequestAsync(appId: string, job: TJob, metadata: Metadata): Promise<BuildResult>;
@@ -54,7 +55,7 @@ export async function prepareBuildRequestForPlatformAsync<
TJob extends Job
>(
builder: Builder<TPlatform, Credentials, ProjectConfiguration, TJob>
): Promise<() => Promise<string>> {
): Promise<() => Promise<string | undefined>> {
const credentialsResult = await withAnalyticsAsync(
async () => await builder.ensureCredentialsAsync(builder.ctx),
{
@@ -83,25 +84,52 @@ export async function prepareBuildRequestForPlatformAsync<
);
}
const archiveBucketKey = await uploadProjectAsync(builder.ctx);
const projectArchive = builder.ctx.commandCtx.local
? ({
type: ArchiveSourceType.PATH,
path: (await makeProjectTarballAsync()).path,
} as const)
: ({
type: ArchiveSourceType.S3,
bucketKey: await uploadProjectAsync(builder.ctx),
} as const);
const metadata = await collectMetadata(builder.ctx, {
credentialsSource: credentialsResult?.source,
});
const job = await builder.prepareJobAsync(builder.ctx, {
archiveBucketKey,
projectArchive,
credentials: credentialsResult?.credentials,
projectConfiguration: builder.projectConfiguration,
});
return async () => {
try {
return sendBuildRequestAsync(builder, job, metadata);
} catch (error) {
if (error?.expoApiV2ErrorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli');
if (builder.ctx.commandCtx.local) {
await runLocalBuildAsync(job);
return undefined;
} else {
try {
return await sendBuildRequestAsync(builder, job, metadata);
} catch (error) {
if (error?.graphQLErrors?.[0]?.extensions?.errorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli version.');
throw new Error('Build request failed.');
} else if (
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_TOO_MANY_PENDING_BUILDS'
) {
Log.error(
`You have already reached the maximum number of pending ${
requestedPlatformDisplayNames[job.platform]
} builds for your account. Try again later.`
);
throw new Error('Build request failed.');
} else if (error?.graphQLErrors) {
Log.error(
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, please report the issue.'
);
}
throw error;
}
throw error;
}
};
}
+2 -2
View File
@@ -21,7 +21,7 @@ import {
const configureCommitMessage = {
[RequestedPlatform.Android]: 'Configure EAS Build for Android',
[RequestedPlatform.iOS]: 'Configure EAS Build for iOS',
[RequestedPlatform.Ios]: 'Configure EAS Build for iOS',
[RequestedPlatform.All]: 'Configure EAS Build',
};
@@ -44,7 +44,7 @@ export async function configureAsync(options: {
shouldConfigureAndroid: [RequestedPlatform.All, RequestedPlatform.Android].includes(
options.platform
),
shouldConfigureIos: [RequestedPlatform.All, RequestedPlatform.iOS].includes(options.platform),
shouldConfigureIos: [RequestedPlatform.All, RequestedPlatform.Ios].includes(options.platform),
hasAndroidNativeProject: await fs.pathExists(path.join(options.projectDir, 'android')),
hasIosNativeProject: await fs.pathExists(path.join(options.projectDir, 'ios')),
};
+1 -1
View File
@@ -2,7 +2,7 @@ import { AppPlatform } from '../graphql/generated';
import { RequestedPlatform } from './types';
export const requestedPlatformDisplayNames: Record<RequestedPlatform, string> = {
[RequestedPlatform.iOS]: 'iOS',
[RequestedPlatform.Ios]: 'iOS',
[RequestedPlatform.Android]: 'Android',
[RequestedPlatform.All]: 'Android and iOS',
};
+10 -2
View File
@@ -1,5 +1,5 @@
import { ExpoConfig } from '@expo/config';
import { AndroidBuildProfile, EasConfig, iOSBuildProfile } from '@expo/eas-json';
import { AndroidBuildProfile, EasConfig, IosBuildProfile } from '@expo/eas-json';
import { v4 as uuidv4 } from 'uuid';
import { getProjectAccountName } from '../project/projectUtils';
@@ -19,6 +19,8 @@ export interface CommandContext {
projectName: string;
exp: ExpoConfig;
nonInteractive: boolean;
local: boolean;
clearCache: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
@@ -31,6 +33,8 @@ export async function createCommandContextAsync({
projectDir,
projectId,
nonInteractive = false,
local,
clearCache = false,
skipCredentialsCheck = false,
skipProjectConfiguration = false,
waitForBuildEnd,
@@ -41,6 +45,8 @@ export async function createCommandContextAsync({
projectId: string;
projectDir: string;
nonInteractive: boolean;
local: boolean;
clearCache: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
@@ -59,6 +65,8 @@ export async function createCommandContextAsync({
projectName,
exp,
nonInteractive,
local,
clearCache,
skipCredentialsCheck,
skipProjectConfiguration,
waitForBuildEnd,
@@ -79,7 +87,7 @@ export interface ConfigureContext {
type PlatformBuildProfile<T extends Platform> = T extends Platform.ANDROID
? AndroidBuildProfile
: iOSBuildProfile;
: IosBuildProfile;
export interface BuildContext<T extends Platform> {
commandCtx: CommandContext;
+16 -11
View File
@@ -19,6 +19,9 @@ export async function buildAsync(commandCtx: CommandContext): Promise<void> {
await ensureGitStatusIsCleanAsync(commandCtx.nonInteractive);
const scheduledBuilds = await startBuildsAsync(commandCtx);
if (commandCtx.local) {
return;
}
Log.newLine();
printLogsUrls(commandCtx.accountName, scheduledBuilds);
Log.newLine();
@@ -36,7 +39,7 @@ async function startBuildsAsync(
const shouldBuildAndroid = [RequestedPlatform.Android, RequestedPlatform.All].includes(
commandCtx.requestedPlatform
);
const shouldBuildiOS = [RequestedPlatform.iOS, RequestedPlatform.All].includes(
const shouldBuildIos = [RequestedPlatform.Ios, RequestedPlatform.All].includes(
commandCtx.requestedPlatform
);
const easConfig = await new EasJsonReader(
@@ -46,27 +49,29 @@ async function startBuildsAsync(
const builds: {
platform: Platform;
sendBuildRequestAsync: () => Promise<string>;
sendBuildRequestAsync: () => Promise<string | undefined>;
}[] = [];
if (shouldBuildAndroid) {
const sendBuildRequestAsync = await prepareAndroidBuildAsync(commandCtx, easConfig);
builds.push({ platform: Platform.ANDROID, sendBuildRequestAsync });
}
if (shouldBuildiOS) {
if (shouldBuildIos) {
const sendBuildRequestAsync = await prepareIosBuildAsync(commandCtx, easConfig);
builds.push({ platform: Platform.IOS, sendBuildRequestAsync });
}
return Promise.all(
builds.map(async ({ platform, sendBuildRequestAsync }) => ({
platform,
buildId: await sendBuildRequestAsync(),
}))
);
return (
await Promise.all(
builds.map(async ({ platform, sendBuildRequestAsync }) => ({
platform,
buildId: await sendBuildRequestAsync(),
}))
)
).filter((build): build is { platform: Platform; buildId: string } => !!build.buildId);
}
async function waitForBuildEndAsync(
buildIds: string[],
{ timeoutSec = 1800, intervalSec = 30 } = {}
{ timeoutSec = 3600, intervalSec = 30 } = {}
): Promise<(BuildFragment | null)[]> {
Log.log('Waiting for build to complete. You can press Ctrl+C to exit.');
const spinner = ora().start();
@@ -76,7 +81,7 @@ async function waitForBuildEndAsync(
const builds: (BuildFragment | null)[] = await Promise.all(
buildIds.map(async buildId => {
try {
return await BuildQuery.byIdAsync(buildId);
return await BuildQuery.byIdAsync(buildId, { useCache: false });
} catch (err) {
return null;
}
-104
View File
@@ -1,104 +0,0 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import chalk from 'chalk';
import { CredentialsProvider } from '../credentials/CredentialsProvider';
import Log from '../log';
import { confirmAsync, promptAsync } from '../prompts';
import { requestedPlatformDisplayNames } from './constants';
function logCredentials(target: 'local' | 'remote', platform: Platform) {
let message = `Using ${target} ${requestedPlatformDisplayNames[platform]} credentials`;
if (target === 'local') message += ` ${chalk.dim('(credentials.json)')}`;
if (target === 'remote') message += ` ${chalk.dim('(Expo server)')}`;
Log.succeed(message);
}
async function ensureCredentialsAutoAsync(
provider: CredentialsProvider,
workflow: Workflow,
nonInteractive: boolean
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
const platform = requestedPlatformDisplayNames[provider.platform];
switch (workflow) {
case Workflow.MANAGED:
if (await provider.hasLocalAsync()) {
return CredentialsSource.LOCAL;
} else {
return CredentialsSource.REMOTE;
}
case Workflow.GENERIC: {
const hasLocal = await provider.hasLocalAsync();
const hasRemote = await provider.hasRemoteAsync();
if (hasRemote && hasLocal) {
if (!(await provider.isLocalSyncedAsync())) {
if (nonInteractive) {
throw new Error(
`Contents of your local credentials.json for ${platform} are not the same as credentials on EAS servers. To use the desired credentials, set the "builds.${platform}.{profile}.credentialsSource" field in the credentials.json file to one of the following: "local", "remote".`
);
} else {
Log.log(
`Contents of your local credentials.json for ${platform} are not the same as credentials on EAS servers`
);
}
const { select } = await promptAsync({
type: 'select',
name: 'select',
message: 'Which credentials do you want to use for this build?',
choices: [
{ title: 'Local credentials.json', value: CredentialsSource.LOCAL },
{ title: 'Credentials stored on EAS servers.', value: CredentialsSource.REMOTE },
],
});
return select;
} else {
return CredentialsSource.LOCAL;
}
} else if (hasLocal) {
logCredentials('local', provider.platform);
return CredentialsSource.LOCAL;
} else if (hasRemote) {
logCredentials('remote', provider.platform);
return CredentialsSource.REMOTE;
} else {
if (nonInteractive) {
throw new Error(
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}. Either configure credentials.json, or launch the build without "--non-interactive" flag to get a prompt to generate credentials automatically.`
);
} else if (Log.isDebug) {
Log.warn(
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}`
);
}
const confirm = await confirmAsync({
message: `${platform} credentials not found, generate now?`,
});
if (confirm) {
return CredentialsSource.REMOTE;
} else {
throw new Error(`Aborting build process, credentials are not configured for ${platform}`);
}
}
}
}
}
export async function ensureCredentialsAsync(
provider: CredentialsProvider,
workflow: Workflow,
src: CredentialsSource,
nonInteractive: boolean
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
switch (src) {
case CredentialsSource.LOCAL:
logCredentials('local', provider.platform);
return CredentialsSource.LOCAL;
case CredentialsSource.REMOTE:
logCredentials('remote', provider.platform);
return CredentialsSource.REMOTE;
case CredentialsSource.AUTO:
return await ensureCredentialsAutoAsync(provider, workflow, nonInteractive);
}
}
@@ -1,4 +1,5 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
@@ -38,9 +39,11 @@ describe(bumpVersionAsync, () => {
});
const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = await readPlistAsync('/repo/ios/myproject/Info.plist');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios.buildNumber).toBe('2');
expect(fakeExp.ios?.buildNumber).toBe('2');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('2');
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.0');
@@ -57,9 +60,11 @@ describe(bumpVersionAsync, () => {
});
const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = await readPlistAsync('/repo/ios/myproject/Info.plist');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.1');
expect(fakeExp.ios.buildNumber).toBe('1');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.1');
expect(appJSON.expo.ios.buildNumber).toBe('1');
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.1');
@@ -76,9 +81,11 @@ describe(bumpVersionAsync, () => {
});
const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = await readPlistAsync('/repo/ios/myproject/Info.plist');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios.buildNumber).toBe('1');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('1');
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.0');
@@ -99,7 +106,7 @@ describe(bumpVersionInAppJsonAsync, () => {
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios.buildNumber).toBe('2');
expect(fakeExp.ios?.buildNumber).toBe('2');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('2');
});
@@ -115,7 +122,7 @@ describe(bumpVersionInAppJsonAsync, () => {
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.1');
expect(fakeExp.ios.buildNumber).toBe('1');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.1');
expect(appJSON.expo.ios.buildNumber).toBe('1');
});
@@ -131,7 +138,7 @@ describe(bumpVersionInAppJsonAsync, () => {
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios.buildNumber).toBe('1');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('1');
});
+1 -1
View File
@@ -22,7 +22,7 @@ import { prepareJobAsync, sanitizedTargetName } from './prepareJob';
export async function prepareIosBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig
): Promise<() => Promise<string>> {
): Promise<() => Promise<string | undefined>> {
const buildCtx = createBuildContext<Platform.IOS>({
commandCtx,
platform: Platform.IOS,
+4 -4
View File
@@ -3,9 +3,9 @@ import { IOSConfig } from '@expo/config-plugins';
import { Workflow } from '@expo/eas-build-job';
import {
EasJsonReader,
IosBuildProfile,
IosGenericBuildProfile,
VersionAutoIncrement,
iOSBuildProfile,
iOSGenericBuildProfile,
} from '@expo/eas-json';
import Log from '../../log';
@@ -44,13 +44,13 @@ export async function validateAndSyncProjectConfigurationAsync({
}: {
projectDir: string;
exp: ExpoConfig;
buildProfile: iOSBuildProfile;
buildProfile: IosBuildProfile;
}): Promise<void> {
const { workflow, autoIncrement } = buildProfile;
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement);
if (workflow === Workflow.GENERIC) {
const genericBuildProfile = buildProfile as iOSGenericBuildProfile;
const genericBuildProfile = buildProfile as IosGenericBuildProfile;
if (!genericBuildProfile.disableIosBundleIdentifierValidation) {
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(
projectDir
+18 -19
View File
@@ -1,16 +1,17 @@
import { Workflow } from '@expo/eas-build-job';
import { CredentialsSource, iOSDistributionType } from '@expo/eas-json';
import { CredentialsSource, IosDistributionType, IosEnterpriseProvisioning } from '@expo/eas-json';
import assert from 'assert';
import { createCredentialsContextAsync } from '../../credentials/context';
import IosCredentialsProvider, {
IosCredentials,
} from '../../credentials/ios/IosCredentialsProvider';
import { getAppLookupParamsFromContext } from '../../credentials/ios/actions/BuildCredentialsUtils';
import { AppLookupParams } from '../../credentials/ios/credentials';
import { CredentialsResult } from '../build';
import { BuildContext } from '../context';
import { ensureCredentialsAsync } from '../credentials';
import { Platform } from '../types';
import { logCredentialsSource } from '../utils/credentials';
export async function ensureIosCredentialsAsync(
ctx: BuildContext<Platform.IOS>
@@ -28,6 +29,7 @@ export async function ensureIosCredentialsAsync(
workflow: ctx.buildProfile.workflow,
credentialsSource: ctx.buildProfile.credentialsSource,
distribution: ctx.buildProfile.distribution ?? 'store',
enterpriseProvisioning: ctx.buildProfile.enterpriseProvisioning,
nonInteractive: ctx.commandCtx.nonInteractive,
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
});
@@ -37,7 +39,8 @@ interface ResolveCredentialsParams {
app: AppLookupParams;
workflow: Workflow;
credentialsSource: CredentialsSource;
distribution: iOSDistributionType;
distribution: IosDistributionType;
enterpriseProvisioning?: IosEnterpriseProvisioning;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
}
@@ -46,22 +49,18 @@ export async function resolveIosCredentialsAsync(
projectDir: string,
params: ResolveCredentialsParams
): Promise<CredentialsResult<IosCredentials>> {
const provider = new IosCredentialsProvider(
await createCredentialsContextAsync(projectDir, {
nonInteractive: params.nonInteractive,
}),
{
app: params.app,
distribution: params.distribution,
skipCredentialsCheck: params.skipCredentialsCheck,
}
);
const credentialsSource = await ensureCredentialsAsync(
provider,
params.workflow,
params.credentialsSource,
params.nonInteractive
);
const ctx = await createCredentialsContextAsync(projectDir, {
nonInteractive: params.nonInteractive,
});
const app = getAppLookupParamsFromContext(ctx);
const provider = new IosCredentialsProvider(ctx, {
app,
distribution: params.distribution,
enterpriseProvisioning: params.enterpriseProvisioning,
skipCredentialsCheck: params.skipCredentialsCheck,
});
const { credentialsSource } = params;
logCredentialsSource(credentialsSource, Platform.IOS);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
source: credentialsSource,
+18 -25
View File
@@ -1,13 +1,5 @@
import {
ArchiveSource,
ArchiveSourceType,
Cache,
Ios,
Job,
Workflow,
sanitizeJob,
} from '@expo/eas-build-job';
import { iOSGenericBuildProfile, iOSManagedBuildProfile } from '@expo/eas-json';
import { ArchiveSource, Cache, Ios, Job, Workflow, sanitizeJob } from '@expo/eas-build-job';
import { IosGenericBuildProfile, IosManagedBuildProfile } from '@expo/eas-json';
import assert from 'assert';
import path from 'path';
@@ -17,13 +9,14 @@ import {
isCredentialsMap,
readEnvironmentSecretsAsync,
} from '../../credentials/credentialsJson/read';
import { getProjectAccountNameAsync } from '../../project/projectUtils';
import { getUsername } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { gitRootDirectoryAsync } from '../../utils/git';
import { BuildContext } from '../context';
import { Platform } from '../types';
interface JobData {
archiveBucketKey: string;
projectArchive: ArchiveSource;
credentials?: IosCredentials;
projectConfiguration: {
iosBuildScheme?: string;
@@ -62,10 +55,10 @@ interface CommonJobProperties {
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.IOS>,
{
archiveBucketKey,
credentials,
targetName,
}: { archiveBucketKey: string; credentials?: IosCredentials; targetName?: string }
projectArchive,
}: { credentials?: IosCredentials; targetName?: string; projectArchive: ArchiveSource }
): Promise<Partial<CommonJobProperties>> {
const environmentSecrets = await readEnvironmentSecretsAsync(ctx.commandCtx.projectDir);
@@ -87,10 +80,7 @@ async function prepareJobCommonAsync(
return {
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.S3,
bucketKey: archiveBucketKey,
},
projectArchive,
distribution: ctx.buildProfile.distribution,
builderEnvironment: {
image: ctx.buildProfile.image,
@@ -101,7 +91,10 @@ async function prepareJobCommonAsync(
fastlane: ctx.buildProfile.fastlane,
env: ctx.buildProfile.env,
},
cache: ctx.buildProfile.cache,
cache: {
...ctx.buildProfile.cache,
clear: ctx.commandCtx.clearCache,
},
secrets: {
...(environmentSecrets ? { environmentSecrets } : {}),
buildCredentials,
@@ -122,15 +115,15 @@ function prepareTargetCredentials(targetCredentials: IosTargetCredentials): Ios.
async function prepareGenericJobAsync(
ctx: BuildContext<Platform.IOS>,
jobData: JobData,
buildProfile: iOSGenericBuildProfile
buildProfile: IosGenericBuildProfile
): Promise<Partial<Ios.GenericJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
return {
...(await prepareJobCommonAsync(ctx, {
archiveBucketKey: jobData.archiveBucketKey,
credentials: jobData.credentials,
targetName: jobData.projectConfiguration.iosApplicationTarget,
projectArchive: jobData.projectArchive,
})),
type: Workflow.GENERIC,
scheme: jobData.projectConfiguration.iosBuildScheme,
@@ -147,20 +140,20 @@ async function prepareGenericJobAsync(
async function prepareManagedJobAsync(
ctx: BuildContext<Platform.IOS>,
jobData: JobData,
buildProfile: iOSManagedBuildProfile
buildProfile: IosManagedBuildProfile
): Promise<Partial<Ios.ManagedJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.exp);
const username = getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync());
return {
...(await prepareJobCommonAsync(ctx, {
archiveBucketKey: jobData.archiveBucketKey,
credentials: jobData.credentials,
targetName: jobData.projectConfiguration.iosApplicationTarget,
projectArchive: jobData.projectArchive,
})),
type: Workflow.MANAGED,
buildType: buildProfile.buildType,
username: accountName,
username,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
};
+45
View File
@@ -0,0 +1,45 @@
import { Job } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import Log from '../log';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
export async function runLocalBuildAsync(job: Job): Promise<void> {
const execNameOrPath =
process.env.EAS_LOCAL_BUILD_PLUGIN_PATH ??
(await findWithNodeResolution()) ??
PLUGIN_PACKAGE_NAME;
try {
const arg = Buffer.from(JSON.stringify({ job })).toString('base64');
await spawnAsync(execNameOrPath, [arg], {
stdio: 'inherit',
});
} catch (err) {
if (err.code === 'ENOENT') {
Log.warn(`Could not resolve executable ${execNameOrPath}.`);
Log.warn(
`Install ${PLUGIN_PACKAGE_NAME} package from npm e.g. ${chalk.bold(
`npm install -g ${PLUGIN_PACKAGE_NAME}`
)} and make sure it's in PATH.`
);
throw err;
}
}
}
async function findWithNodeResolution(): Promise<string | undefined> {
try {
const resolvedPath = path.resolve(
path.dirname(require.resolve(PLUGIN_PACKAGE_NAME)),
'../bin/run'
);
if (await fs.pathExists(resolvedPath)) {
return resolvedPath;
}
} catch {}
return undefined;
}
+3 -1
View File
@@ -1,7 +1,8 @@
import { Metadata } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import { getAppIdentifierAsync } from '../project/projectUtils';
import { getAppIdentifierAsync, getUsername } from '../project/projectUtils';
import { ensureLoggedInAsync } from '../user/actions';
import { gitCommitHashAsync } from '../utils/git';
import { readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync } from './android/UpdatesModule';
import { BuildContext } from './context';
@@ -42,6 +43,7 @@ export async function collectMetadata<T extends Platform>(
})) ?? undefined,
buildProfile: ctx.commandCtx.profile,
gitCommitHash: await gitCommitHashAsync(),
username: getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync()),
};
}
+1 -1
View File
@@ -2,7 +2,7 @@ export { Platform } from '@expo/eas-build-job';
export enum RequestedPlatform {
Android = 'android',
iOS = 'ios',
Ios = 'ios',
All = 'all',
}
@@ -0,0 +1,15 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import chalk from 'chalk';
import Log from '../../log';
import { requestedPlatformDisplayNames } from '../constants';
export function logCredentialsSource(credentialsSource: CredentialsSource, platform: Platform) {
let message = `Using ${credentialsSource} ${requestedPlatformDisplayNames[platform]} credentials`;
if (credentialsSource === CredentialsSource.LOCAL)
message += ` ${chalk.dim('(credentials.json)')}`;
else if (credentialsSource === CredentialsSource.REMOTE)
message += ` ${chalk.dim('(Expo server)')}`;
Log.succeed(message);
}
@@ -1,5 +1,7 @@
import assert from 'assert';
import chalk from 'chalk';
import indentString from 'indent-string';
import qrcodeTerminal from 'qrcode-terminal';
import {
BuildError,
@@ -15,7 +17,7 @@ import {
appPlatformEmojis,
requestedPlatformDisplayNames,
} from '../constants';
import { getBuildLogsUrl } from './url';
import { getBuildLogsUrl, getInternalDistributionInstallUrl } from './url';
export function printLogsUrls(
accountName: string,
@@ -82,10 +84,12 @@ function printBuildResult(accountName: string, build: BuildFragment): void {
buildId: build.id,
account: accountName,
});
const installUrl = getInternalDistributionInstallUrl(build);
qrcodeTerminal.generate(installUrl, code => console.log(`${indentString(code, 2)}\n`));
Log.log(
`${appPlatformEmojis[build.platform]} Open this link on your ${
appPlatformDisplayNames[build.platform]
} devices to install the app:`
} devices (or scan the QR code) to install the app:`
);
Log.log(`${chalk.underline(logsUrl)}`);
} else {
+16 -1
View File
@@ -1,4 +1,7 @@
import { getExpoWebsiteBaseUrl } from '../../api';
import assert from 'assert';
import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../../api';
import { AppPlatform, BuildFragment } from '../../graphql/generated';
export function getBuildLogsUrl({
buildId,
@@ -13,3 +16,15 @@ export function getBuildLogsUrl({
export function getArtifactUrl(artifactId: string): string {
return `${getExpoWebsiteBaseUrl()}/artifacts/${artifactId}`;
}
export function getInternalDistributionInstallUrl(build: BuildFragment): string {
if (build.platform === AppPlatform.Ios) {
return `itms-services://?action=download-manifest;url=${getExpoApiBaseUrl()}/--/api/v2/projects/${
build.project.id
}/builds/${build.id}/manifest.plist`;
}
assert(build.artifacts?.buildUrl, 'buildUrl is missing');
return build.artifacts.buildUrl;
}
@@ -44,7 +44,7 @@ async function getBranchInfoAsync({
)
.toPromise()
);
const branchId = data.app?.byId.updateBranchByName.id;
const branchId = data.app?.byId.updateBranchByName?.id;
if (!branchId) {
throw new Error(`Could not find branch ${name} on ${appId}`);
}
+25 -9
View File
@@ -1,5 +1,6 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import CliTable from 'cli-table3';
import gql from 'graphql-tag';
import { format } from 'timeago.js';
@@ -17,7 +18,13 @@ import Log from '../../log';
import { findProjectRootAsync, getProjectFullNameAsync } from '../../project/projectUtils';
import { getActorDisplayName } from '../../user/User';
export type FormatUpdateParameter = Pick<Update, 'id' | 'createdAt' | 'message'> & {
actor?: Maybe<Pick<User, 'username' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
};
export const UPDATE_COLUMNS = ['update description', 'update runtime version', 'update group ID'];
const BRANCHES_LIMIT = 10_000;
export async function listBranchesAsync({ fullName }: { fullName: string }) {
const data = await withErrorHandlingAsync(
graphqlClient
@@ -43,8 +50,10 @@ export async function listBranchesAsync({ fullName }: { fullName: string }) {
firstName
}
}
updatedAt
createdAt
message
runtimeVersion
group
}
}
}
@@ -87,8 +96,19 @@ export default class BranchList extends Command {
if (flags.json) {
Log.log(JSON.stringify(branches, null, 2));
} else {
const table = new CliTable({ head: ['Branch', 'Latest update'] });
table.push(...branches.map(branch => [branch.name, formatUpdate(branch.updates[0])]));
const table = new CliTable({
head: ['branch', ...UPDATE_COLUMNS],
wordWrap: true,
});
table.push(
...branches.map(branch => [
branch.name,
formatUpdate(branch.updates[0]),
branch.updates[0]?.runtimeVersion ?? 'N/A',
branch.updates[0]?.group ?? 'N/A',
])
);
Log.log(chalk.bold('Branches with their most recent update group:'));
Log.log(table.toString());
if (branches.length >= BRANCHES_LIMIT) {
Log.warn(`Showing first ${BRANCHES_LIMIT} branches, some results might be omitted.`);
@@ -97,16 +117,12 @@ export default class BranchList extends Command {
}
}
export function formatUpdate(
update: Pick<Update, 'id' | 'updatedAt' | 'message'> & {
actor?: Maybe<Pick<User, 'username' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
}
): string {
export function formatUpdate(update: FormatUpdateParameter): string {
if (!update) {
return 'N/A';
}
const message = update.message ? `"${update.message}" ` : '';
return `${message}(${format(update.updatedAt, 'en_US')} by ${getActorDisplayName(
return `${message}(${format(update.createdAt, 'en_US')} by ${getActorDisplayName(
update.actor as any
)})`;
}
@@ -1,4 +1,5 @@
import { getConfig } from '@expo/config';
import { getConfig, getDefaultTarget } from '@expo/config';
import { getRuntimeVersionForSDKVersion } from '@expo/sdk-runtime-versions';
import { Command, flags } from '@oclif/command';
import assert from 'assert';
import chalk from 'chalk';
@@ -64,10 +65,15 @@ export default class BranchPublish extends Command {
static hidden = true;
static description = 'Publish an update group to a branch.';
static args = [
{
name: 'name',
required: true,
description: 'Name of the branch to publish on',
},
];
static flags = {
branch: flags.string({
description: 'name of the branch to publish on.',
}),
message: flags.string({
description: 'Short message describing the updates.',
required: false,
@@ -97,9 +103,9 @@ export default class BranchPublish extends Command {
async run() {
let {
args: { name },
flags: {
json: jsonFlag,
branch: name,
message,
republish,
group,
@@ -117,7 +123,17 @@ export default class BranchPublish extends Command {
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug, runtimeVersion } = exp;
let { slug, runtimeVersion, sdkVersion } = exp;
// When a SDK version is supplied instead of a runtime version and we're in the managed workflow
// construct the runtimeVersion with special meaning indicating that the runtime is an
// Expo SDK preset runtime that can be launched in Expo Go.
const isManagedProject = getDefaultTarget(projectDir) === 'managed';
if (!runtimeVersion && sdkVersion && isManagedProject) {
Log.withTick('Generating runtime version from sdk version');
runtimeVersion = getRuntimeVersionForSDKVersion(sdkVersion);
}
if (!runtimeVersion) {
throw new Error(
"Couldn't find 'runtimeVersion'. Please specify it under the 'expo' key in 'app.json'"
+4 -8
View File
@@ -19,6 +19,7 @@ import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { UPDATE_COLUMNS, formatUpdate } from './list';
const PAGE_LIMIT = 10_000;
@@ -59,7 +60,7 @@ export async function viewUpdateBranchAsync({
actor {
id
... on User {
firstName
username
}
... on Robot {
firstName
@@ -152,17 +153,12 @@ export default class BranchView extends Command {
}
const groupTable = new Table({
head: ['createdAt', 'message', 'group', 'actor'],
head: UPDATE_COLUMNS,
wordWrap: true,
});
for (const update of updates) {
groupTable.push([
new Date(update.createdAt).toLocaleString(),
update.message,
update.group,
update.actor?.firstName,
]);
groupTable.push([formatUpdate(update), update.runtimeVersion, update.group]);
}
Log.withTick(
@@ -87,7 +87,7 @@ async function promptForPlatformAsync(): Promise<RequestedPlatform> {
},
{
title: 'iOS',
value: RequestedPlatform.iOS,
value: RequestedPlatform.Ios,
},
{
title: 'Android',
+30 -74
View File
@@ -1,8 +1,7 @@
import { getConfig } from '@expo/config';
import { ExpoConfig, getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import path from 'path';
import {
@@ -13,22 +12,14 @@ import { configureAsync } from '../../build/configure';
import { createCommandContextAsync } from '../../build/context';
import { buildAsync } from '../../build/create';
import { RequestedPlatform } from '../../build/types';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import { isGitStatusCleanAsync } from '../../build/utils/repository';
import { AppPlatform } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log, { learnMore } from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../prompts';
import { Actor } from '../../user/User';
import { ensureLoggedInAsync } from '../../user/actions';
export default class Build extends Command {
@@ -52,18 +43,26 @@ export default class Build extends Command {
default: false,
description: 'Run command in --non-interactive mode',
}),
local: flags.boolean({
default: false,
description: 'Run build locally [experimental]',
}),
wait: flags.boolean({
default: true,
allowNo: true,
description: 'Wait for build(s) to complete',
}),
'clear-cache': flags.boolean({
default: false,
description: 'Clear cache before the build',
}),
};
async run(): Promise<void> {
const { flags } = this.parse(Build);
await initAnalyticsAsync(); // TODO: run in oclif hook for every command
try {
const user = await ensureLoggedInAsync();
await ensureLoggedInAsync();
const nonInteractive = flags['non-interactive'];
if (!flags.platform && nonInteractive) {
@@ -73,27 +72,21 @@ export default class Build extends Command {
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
let { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
if (!flags.local && !(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
const accountName = await getProjectAccountNameAsync(exp);
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
accountName,
platform,
projectId,
user,
});
if (accountHasPendingBuilds) {
if (flags.local && !verifyOptionsForLocalBuilds(platform)) {
process.exitCode = 1;
return;
}
await ensureProjectConfiguredAsync(projectDir);
exp = (await ensureProjectConfiguredAsync(projectDir)) ?? exp;
const commandCtx = await createCommandContextAsync({
requestedPlatform: platform,
@@ -102,7 +95,9 @@ export default class Build extends Command {
projectDir,
projectId,
nonInteractive,
clearCache: flags['clear-cache'],
skipCredentialsCheck: flags['skip-credentials-check'],
local: flags.local,
skipProjectConfiguration: flags['skip-project-configuration'],
waitForBuildEnd: flags.wait,
});
@@ -113,56 +108,15 @@ export default class Build extends Command {
}
}
async function ensureNoPendingBuildsExistAsync({
user,
platform,
accountName,
projectId,
}: {
user: Actor;
platform: RequestedPlatform;
accountName: string;
projectId: string;
}): Promise<boolean> {
// allow expo admins to run as many builds as they wish
if (user.isExpoAdmin) {
function verifyOptionsForLocalBuilds(platform: RequestedPlatform): boolean {
if (platform === RequestedPlatform.All) {
Log.error('Builds for multiple platforms are not supported with flag --local');
return false;
} else if (process.platform !== 'darwin' && platform === RequestedPlatform.Ios) {
Log.error('Unsupported platform, macOS is required to build apps for iOS');
return false;
}
const appPlatforms = toAppPlatforms(platform);
const maybePendingBuilds = await Promise.all(
appPlatforms.map(appPlatform => BuildQuery.getPendingBuildIdAsync(accountName, appPlatform))
);
const pendingBuilds = maybePendingBuilds.filter(i => i !== null);
if (pendingBuilds.length > 0) {
Log.newLine();
Log.error(
'Your other builds are still pending. Wait for them to complete before running this command again.'
);
Log.newLine();
const results = await Promise.all(
pendingBuilds.map(pendingBuild => {
return BuildQuery.byIdAsync(nullthrows(pendingBuild).id);
})
);
for (const result of results) {
Log.log(formatGraphQLBuild(result));
}
process.exitCode = 1;
return true;
}
return false;
}
function toAppPlatforms(requestedPlatform: RequestedPlatform): AppPlatform[] {
if (requestedPlatform === RequestedPlatform.All) {
return [AppPlatform.Android, AppPlatform.Ios];
} else if (requestedPlatform === RequestedPlatform.Android) {
return [AppPlatform.Android];
} else {
return [AppPlatform.Ios];
return true;
}
}
@@ -178,7 +132,7 @@ async function promptForPlatformAsync(): Promise<RequestedPlatform> {
},
{
title: 'iOS',
value: RequestedPlatform.iOS,
value: RequestedPlatform.Ios,
},
{
title: 'Android',
@@ -189,9 +143,9 @@ async function promptForPlatformAsync(): Promise<RequestedPlatform> {
return platform;
}
async function ensureProjectConfiguredAsync(projectDir: string): Promise<void> {
async function ensureProjectConfiguredAsync(projectDir: string): Promise<ExpoConfig | null> {
if (await fs.pathExists(path.join(projectDir, 'eas.json'))) {
return;
return null;
}
const confirm = await confirmAsync({
message: 'This app is not set up for building with EAS. Set it up now?',
@@ -207,6 +161,8 @@ async function ensureProjectConfiguredAsync(projectDir: string): Promise<void> {
'Build process requires clean git working tree, please commit all your changes and run `eas build` again'
);
}
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
return exp;
} else {
throw new Error(
`Aborting, please run ${chalk.bold('eas build:configure')} or create eas.json (${learnMore(
+1 -1
View File
@@ -19,7 +19,7 @@ export default class BuildList extends Command {
static flags = {
platform: flags.enum({
options: [RequestedPlatform.All, RequestedPlatform.Android, RequestedPlatform.iOS],
options: [RequestedPlatform.All, RequestedPlatform.Android, RequestedPlatform.Ios],
}),
status: flags.enum({
options: [
+8 -23
View File
@@ -1,7 +1,6 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import Table from 'cli-table3';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
@@ -12,6 +11,7 @@ import {
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { logChannelDetails } from './view';
const CHANNEL_LIMIT = 10_000;
@@ -31,18 +31,20 @@ async function getAllUpdateChannelForAppAsync({
updateChannels(offset: $offset, limit: $limit) {
id
name
updateBranches(offset: 0, limit: 1) {
branchMapping
updateBranches(offset: 0, limit: $limit) {
id
name
updates(offset: 0, limit: 1) {
id
group
message
runtimeVersion
createdAt
actor {
id
... on User {
firstName
username
}
... on Robot {
firstName
@@ -102,27 +104,10 @@ export default class ChannelList extends Command {
return;
}
const table = new Table({
head: ['channel', 'branch', 'update', 'message', 'created-at', 'actor'],
wordWrap: true,
});
Log.log(); // spacing
for (const channel of channels) {
// TODO (cedric): refactor when multiple branches per channel are available
const branch = channel.updateBranches[0];
const update = branch.updates[0];
table.push([
channel.name,
branch.name,
update?.group,
update?.message,
update?.createdAt && new Date(update.createdAt).toLocaleString(),
update?.actor?.firstName,
]);
Log.log(`Details of channel ${chalk.bold(chalk.cyan(channel.name))}:`);
logChannelDetails(channel);
}
Log.log(chalk`{bold Channels for this app:}`);
Log.log(table.toString());
}
}
@@ -44,18 +44,20 @@ function getRolloutInfo(
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
currentPercent: number;
} {
const { branchMapping } = getBranchMapping(getUpdateChannelByNameForAppResult);
const { branchMapping } = getBranchMapping(
getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.branchMapping
);
const [newBranchId, oldBranchId] = branchMapping.data.map(d => d.branchId);
const newBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.updateBranches.filter(
const newBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.updateBranches.filter(
branch => branch.id === newBranchId
)[0];
const oldBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.updateBranches.filter(
const oldBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.updateBranches.filter(
branch => branch.id === oldBranchId
)[0];
if (!newBranch || !oldBranch) {
throw new Error(
`Branch mapping rollout is missing a branch for channel "${getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.name}".`
`Branch mapping rollout is missing a branch for channel "${getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.name}".`
);
}
@@ -134,11 +136,11 @@ async function startRolloutAsync({
],
};
const newChannelInfo = await updateChannelBranchMappingAsync({
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.id!,
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.id!,
branchMapping: JSON.stringify(newBranchMapping),
});
const oldBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.updateBranches.filter(
const oldBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.updateBranches.filter(
branch => branch.id === oldBranchId
)[0];
if (!oldBranch) {
@@ -200,7 +202,7 @@ async function editRolloutAsync({
newBranchMapping.data[0].branchMappingLogic.operand = percent / 100;
const newChannelInfo = await updateChannelBranchMappingAsync({
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.id!,
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.id!,
branchMapping: JSON.stringify(newBranchMapping),
});
@@ -290,7 +292,7 @@ async function endRolloutAsync({
};
const newChannelInfo = await updateChannelBranchMappingAsync({
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.id!,
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.id!,
branchMapping: JSON.stringify(newBranchMapping),
});
const logMessage = `️Rollout on channel ${chalk.bold(
@@ -359,7 +361,7 @@ export default class ChannelRollout extends Command {
channelName: channelName!,
});
const { branchMapping: currentBranchMapping, isRollout } = getBranchMapping(
getUpdateChannelByNameForAppResult
getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.branchMapping
);
if (currentBranchMapping.data.length === 0) {
+59 -50
View File
@@ -2,6 +2,7 @@ import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import Table from 'cli-table3';
import { assert } from 'console';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
@@ -13,6 +14,7 @@ import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { FormatUpdateParameter, UPDATE_COLUMNS, formatUpdate } from '../branch/list';
export type BranchMapping = {
version: number;
@@ -31,15 +33,19 @@ export type BranchMapping = {
* Ensure that the branch mapping is properly formatted.
*/
export function getBranchMapping(
getChannelByNameForAppQuery: GetChannelByNameForAppQuery
branchMappingString?: string
): { branchMapping: BranchMapping; isRollout: boolean; rolloutPercent?: number } {
const branchMappingString =
getChannelByNameForAppQuery.app?.byId.updateChannelByName.branchMapping;
if (!branchMappingString) {
throw new Error('Missing branch mapping.');
}
let branchMapping: BranchMapping;
try {
branchMapping = JSON.parse(branchMappingString);
} catch (e) {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
assert(branchMapping, 'Branch Mapping must be defined.');
const branchMapping: BranchMapping = JSON.parse(branchMappingString);
if (branchMapping.version !== 0) {
throw new Error('Branch mapping must be version 0.');
}
@@ -100,11 +106,12 @@ export async function getUpdateChannelByNameForAppAsync({
id
group
message
runtimeVersion
createdAt
actor {
id
... on User {
firstName
username
}
... on Robot {
firstName
@@ -123,6 +130,49 @@ export async function getUpdateChannelByNameForAppAsync({
);
}
export function logChannelDetails(channel: {
branchMapping: string;
updateBranches: {
updates: (FormatUpdateParameter & { runtimeVersion?: string; group?: string })[];
name: string;
id: string;
}[];
}): void {
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
const table = new Table({
head: ['branch', ...(isRollout ? ['rollout percent'] : []), ...UPDATE_COLUMNS],
wordWrap: true,
});
for (const index in branchMapping.data) {
if (parseInt(index, 10) > 1) {
throw new Error('Branch Mapping data must have length less than or equal to 2.');
}
const { branchId } = branchMapping.data[index];
const branch = channel.updateBranches.filter(branch => branch.id === branchId)[0];
if (!branch) {
throw new Error('Branch mapping is pointing at a missing branch.');
}
const update = branch.updates[0];
table.push([
branch.name,
...(isRollout
? [
parseInt(index, 10) === 0
? `${rolloutPercent! * 100}%`
: `${(1 - rolloutPercent!) * 100}%`,
]
: []),
formatUpdate(update),
update?.runtimeVersion ?? 'N/A',
update?.group ?? 'N/A,',
]);
}
Log.log(table.toString());
}
export default class ChannelView extends Command {
static hidden = true;
static description = 'View a channel on the current project.';
@@ -187,53 +237,12 @@ export default class ChannelView extends Command {
return;
}
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(
getUpdateChannelByNameForAppresult
);
const table = new Table({
head: [
'branch',
...(isRollout ? ['rollout percent'] : []),
'active update group',
'update message',
'last publish',
'actor',
],
wordWrap: true,
});
for (const index in branchMapping.data) {
if (parseInt(index, 10) > 1) {
throw new Error('Branch Mapping data must have length less than or equal to 2.');
}
const { branchId } = branchMapping.data[index];
const branch = channel.updateBranches.filter(branch => branch.id === branchId)[0];
if (!branch) {
throw new Error('Branch mapping is pointing at a missing branch.');
}
const update = branch.updates[0];
table.push([
branch.name,
...(isRollout
? [
parseInt(index, 10) === 0
? `${rolloutPercent! * 100}%`
: `${(1 - rolloutPercent!) * 100}%`,
]
: []),
update?.group,
update?.message,
update?.createdAt && new Date(update.createdAt).toLocaleString(),
update?.actor?.firstName,
]);
}
Log.withTick(
chalk`Channel: {bold ${channel.name}} on project {bold ${accountName}/${slug}}. Channel ID: {bold ${channel.id}}`
);
Log.log(chalk`{bold Recent update groups published on this branch:}`);
Log.log(table.toString());
Log.log(
chalk`{bold Branches, pointed at by this channel, and their most recent update group:}`
);
logChannelDetails(channel);
}
}
@@ -0,0 +1,100 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import {
EnvironmentSecretWithScope,
EnvironmentSecretsQuery,
} from '../../graphql/queries/EnvironmentSecretsQuery';
import Log from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
import { EnvironmentSecretScope } from './create';
export default class EnvironmentSecretDelete extends Command {
static description = `Delete an environment secret by ID.
Unsure where to find the secret's ID? Run ${chalk.bold('eas secrets:list')}`;
static flags = {
id: flags.string({
description: 'ID of the secret to delete',
}),
};
async run() {
await ensureLoggedInAsync();
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
const projectAccountName = await getProjectAccountNameAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
let {
flags: { id },
} = this.parse(EnvironmentSecretDelete);
let secret: EnvironmentSecretWithScope | undefined;
if (!id) {
const validationMessage = 'You must select which secret to delete.';
const secrets = await EnvironmentSecretsQuery.allAsync(projectAccountName, projectFullName);
({ secret } = await promptAsync({
type: 'autocomplete',
name: 'secret',
message: 'Pick the secret to be deleted:',
choices: secrets.map(secret => ({
title: `${secret.name} (${secret.scope})`,
value: secret,
})),
}));
id = secret?.id;
if (!id) throw new Error(validationMessage);
}
Log.addNewLineIfNone();
Log.warn(
`You are about to permamently delete secret${
secret?.name ? ` "${secret?.name}"` : ''
} with id: "${id}".\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({
message: `Are you sure you wish to proceed?${
secret?.scope === EnvironmentSecretScope.ACCOUNT
? ' This secret is applied across your whole account and may affect multiple apps.'
: ''
}`,
});
if (!confirmed) {
Log.error(
`Canceled deletion of secret${secret?.name ? ` "${secret?.name}"` : ''} with id: "${id}".`
);
process.exit(1);
}
await EnvironmentSecretMutation.delete(id);
Log.withTick(`️Deleted secret${secret?.name ? ` "${secret?.name}"` : ''} with id "${id}".`);
}
}
@@ -4,7 +4,6 @@ import chalk from 'chalk';
import Table from 'cli-table3';
import dateFormat from 'dateformat';
import { EnvironmentSecretFragment } from '../../graphql/generated';
import { EnvironmentSecretsQuery } from '../../graphql/queries/EnvironmentSecretsQuery';
import Log from '../../log';
import {
@@ -18,11 +17,9 @@ import {
getProjectIdAsync,
} from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { EnvironmentSecretScope } from './create';
export default class EnvironmentSecretsList extends Command {
export default class EnvironmentSecretList extends Command {
static description = 'Lists environment secrets available for your current app';
static usage = 'secrets:list';
async run(): Promise<void> {
await ensureLoggedInAsync();
@@ -43,15 +40,7 @@ export default class EnvironmentSecretsList extends Command {
throw new Error("Please run this command inside your project's directory");
}
const [accountSecrets, appSecrets] = await Promise.all([
EnvironmentSecretsQuery.byAcccountNameAsync(projectAccountName),
EnvironmentSecretsQuery.byAppFullNameAsync(projectFullName),
]);
const secrets = [
...appSecrets.map(s => ({ ...s, scope: EnvironmentSecretScope.PROJECT })),
...accountSecrets.map(s => ({ ...s, scope: EnvironmentSecretScope.ACCOUNT })),
] as (EnvironmentSecretFragment & { scope: EnvironmentSecretScope })[];
const secrets = await EnvironmentSecretsQuery.allAsync(projectAccountName, projectFullName);
const table = new Table({
head: ['Name', 'Scope', 'ID', 'Updated at'],
@@ -1,59 +0,0 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import Log from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { toggleConfirmAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
export default class EnvironmentSecretDelete extends Command {
static description = `Delete an environment secret by ID.
Unsure where to find the secret's ID? Run ${chalk.bold('eas secrets:list')}`;
static args = [
{
name: 'id',
required: true,
description: `ID of the secret to delete`,
},
];
async run() {
await ensureLoggedInAsync();
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
const {
args: { id },
} = this.parse(EnvironmentSecretDelete);
Log.addNewLineIfNone();
Log.warn(
`You are about to permamently delete secret with id: "${id}".\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Canceled deletion of secret with id: "${id}".`);
process.exit(1);
}
await EnvironmentSecretMutation.delete(id);
Log.withTick(`️Deleted secret with id "${id}".`);
}
}
+10 -12
View File
@@ -1,17 +1,14 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import { AppPlatform } from '../graphql/generated';
import { learnMore } from '../log';
import { isEasEnabledForProjectAsync, warnEasUnavailable } from '../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectIdAsync } from '../project/projectUtils';
import { promptAsync } from '../prompts';
import AndroidSubmitCommand from '../submissions/android/AndroidSubmitCommand';
import IosSubmitCommand from '../submissions/ios/IosSubmitCommand';
import {
AndroidSubmitCommandFlags,
IosSubmitCommandFlags,
SubmissionPlatform,
} from '../submissions/types';
import { AndroidSubmitCommandFlags, IosSubmitCommandFlags } from '../submissions/types';
import { ensureLoggedInAsync } from '../user/actions';
const COMMON_FLAGS = '';
@@ -159,14 +156,15 @@ export default class BuildSubmit extends Command {
'company-name': companyName,
// common
platform: platformFromParams,
...flags
},
} = this.parse(BuildSubmit);
await ensureLoggedInAsync();
const platform = platformFromParams ?? (await promptForPlatformAsync());
const platform =
(flags.platform?.toUpperCase() as AppPlatform | undefined) ??
(await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
@@ -183,7 +181,7 @@ export default class BuildSubmit extends Command {
throw new Error("Please run this command inside your project's directory");
}
if (platform === SubmissionPlatform.Android) {
if (platform === AppPlatform.Android) {
const options: AndroidSubmitCommandFlags = {
androidPackage,
releaseStatus,
@@ -193,7 +191,7 @@ export default class BuildSubmit extends Command {
const ctx = AndroidSubmitCommand.createContext(projectDir, projectId, options);
const command = new AndroidSubmitCommand(ctx);
await command.runAsync();
} else if (platform === SubmissionPlatform.iOS) {
} else if (platform === AppPlatform.Ios) {
const options: IosSubmitCommandFlags = {
appleId,
ascAppId,
@@ -213,7 +211,7 @@ export default class BuildSubmit extends Command {
}
}
async function promptForPlatformAsync(): Promise<SubmissionPlatform> {
async function promptForPlatformAsync(): Promise<AppPlatform> {
const { platform } = await promptAsync({
type: 'select',
message: 'Submit to platform',
@@ -221,11 +219,11 @@ async function promptForPlatformAsync(): Promise<SubmissionPlatform> {
choices: [
{
title: 'iOS',
value: SubmissionPlatform.iOS,
value: AppPlatform.Ios,
},
{
title: 'Android',
value: SubmissionPlatform.Android,
value: AppPlatform.Android,
},
],
});
@@ -0,0 +1,88 @@
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateGroupMutation,
UpdateMutationDeleteUpdateGroupArgs,
} from '../../graphql/generated';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
async function deleteUpdateGroupAsync({
group,
}: {
group: string;
}): Promise<DeleteUpdateGroupMutation> {
return await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateGroupMutation, UpdateMutationDeleteUpdateGroupArgs>(
gql`
mutation DeleteUpdateGroup($group: ID!) {
update {
deleteUpdateGroup(group: $group) {
group
}
}
}
`,
{ group }
)
.toPromise()
);
}
export default class UpdateDelete extends Command {
static hidden = true;
static description = 'Delete all the updates in an update Group.';
static args = [
{
name: 'groupId',
required: true,
description: 'The ID of an update group to delete.',
},
];
static flags = {
json: flags.boolean({
description: `Return a json with the group ID of the deleted updates.`,
default: false,
}),
};
async run() {
const {
args: { groupId: group },
flags: { json: jsonFlag },
} = this.parse(UpdateDelete);
if (!jsonFlag) {
const shouldAbort = await confirmAsync({
message:
`🚨${chalk.red('CAUTION')}🚨\n\n` +
`${chalk.yellow(`This will delete all of the updates in group "${group}".`)} ${chalk.red(
'This is a permanent operation.'
)}\n\n` +
`If you want to revert to a previous publish, you should use 'branch:publish --republish' targeted at the last working update group instead.\n\n` +
`An update group should only be deleted in an emergency like an accidental publish of a secret. In this case user 'branch:publish --republish' to revert to the last working update group first and then proceed with the deletion. Deleting an update group when it is the latest publish can lead to inconsistent cacheing behavior by clients.\n\n` +
`Would you like to abort?`,
});
if (shouldAbort) {
Log.log('Aborted.');
return;
}
}
await deleteUpdateGroupAsync({ group });
if (jsonFlag) {
Log.log(JSON.stringify({ group }));
return;
}
Log.withTick(`Deleted update group ${group}`);
}
}
@@ -1,10 +0,0 @@
import { Command } from '@oclif/command';
export default class UpdateShow extends Command {
static hidden = true;
static description = 'details about a particular revision';
async run() {
throw new Error('Not implemented');
}
}
@@ -0,0 +1,99 @@
import { Command, flags } from '@oclif/command';
import Table from 'cli-table3';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdatesByGroupQuery, UpdatesByGroupQueryVariables } from '../../graphql/generated';
import Log from '../../log';
import { UPDATE_COLUMNS, formatUpdate } from '../branch/list';
export async function viewUpdateAsync({
groupId,
}: {
groupId: string;
}): Promise<UpdatesByGroupQuery> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<UpdatesByGroupQuery, UpdatesByGroupQueryVariables>(
gql`
query UpdatesByGroup($groupId: ID!) {
updatesByGroup(group: $groupId) {
id
group
runtimeVersion
platform
message
actor {
id
... on User {
username
}
... on Robot {
firstName
}
}
createdAt
}
}
`,
{
groupId,
}
)
.toPromise()
);
if (data.updatesByGroup.length === 0) {
throw new Error(`Could not find any updates with group ID: "${groupId}"`);
}
return data;
}
export default class UpdateView extends Command {
static hidden = true;
static description = 'Update group details.';
static args = [
{
name: 'groupId',
required: true,
description: 'The ID of an update group.',
},
];
static flags = {
json: flags.boolean({
description: `Return a json with the updates belonging to the group.`,
default: false,
}),
};
async run() {
const {
args: { groupId },
flags: { json: jsonFlag },
} = this.parse(UpdateView);
const { updatesByGroup } = await viewUpdateAsync({ groupId });
if (jsonFlag) {
Log.log(JSON.stringify(updatesByGroup));
return;
}
const groupTable = new Table({
head: [...UPDATE_COLUMNS, 'platforms'],
wordWrap: true,
});
// The relevant info of the updates are same except for their platform
const platforms = updatesByGroup.map(update => update.platform);
const representativeUpdate = updatesByGroup[0];
groupTable.push([
formatUpdate(representativeUpdate),
representativeUpdate.runtimeVersion,
representativeUpdate.group,
platforms.join(','),
]);
Log.log(groupTable.toString());
}
}
@@ -0,0 +1,50 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import ora from 'ora';
import { WebhookType } from '../../graphql/generated';
import { WebhookMutation } from '../../graphql/mutations/WebhookMutation';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { prepareInputParamsAsync } from '../../webhooks/input';
export default class WebhookCreate extends Command {
static description = 'Create a webhook on the current project.';
static flags = {
event: flags.enum({
description: 'Event type that triggers the webhook',
options: [WebhookType.Build],
default: WebhookType.Build,
}),
url: flags.string({
description: 'Webhook URL',
}),
secret: flags.string({
description:
"Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature' header.",
}),
};
async run() {
await ensureLoggedInAsync();
const { flags } = this.parse(WebhookCreate);
const webhookInputParams = await prepareInputParamsAsync(flags);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const spinner = ora('Creating a webhook').start();
try {
await WebhookMutation.createWebhookAsync(projectId, webhookInputParams);
spinner.succeed('Successfully created a webhook');
} catch (err) {
spinner.fail('Failed to create a webhook');
throw err;
}
}
}
@@ -0,0 +1,102 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import assert from 'assert';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import ora from 'ora';
import { WebhookFragment } from '../../graphql/generated';
import { WebhookMutation } from '../../graphql/mutations/WebhookMutation';
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
import Log from '../../log';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
import { formatWebhook } from '../../webhooks/formatWebhook';
export default class WebhookDelete extends Command {
static description = 'Delete a webhook on the current project.';
static args = [
{
name: 'ID',
required: false,
description: 'ID of the webhook to delete',
},
];
async run() {
await ensureLoggedInAsync();
let {
args: { ID: webhookId },
} = this.parse(WebhookDelete);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
let webhook: WebhookFragment | undefined =
webhookId && (await WebhookQuery.byIdAsync(webhookId));
if (!webhookId) {
const webhooks = await fetchWebhooksByAppIdAsync(projectId);
if (webhooks.length === 0) {
process.exit(1);
}
({ webhook } = await promptAsync({
type: 'autocomplete',
name: 'webhook',
message: 'Pick the webhook to be deleted:',
choices: webhooks.map(i => ({
title: `${chalk.bold(i.url)} (Event: ${i.event}, ID: ${i.id})`,
value: i,
})),
}));
webhookId = nullthrows(webhook).id;
}
assert(webhook, 'Webhook must be defined here');
Log.addNewLineIfNone();
Log.log(formatWebhook(webhook));
Log.newLine();
Log.warn(`You are about to permamently delete this webhook.\nThis action is irreversible.`);
Log.newLine();
const confirmed = await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
if (!confirmed) {
Log.error(`Canceled deletion of the webhook`);
process.exit(1);
}
const spinner = ora('Deleting the webhook').start();
try {
await WebhookMutation.deleteWebhookAsync(webhookId);
spinner.succeed('Successfully deleted the webhook');
} catch (err) {
spinner.fail('Failed to delete the webhook');
throw err;
}
}
}
async function fetchWebhooksByAppIdAsync(appId: string): Promise<WebhookFragment[]> {
const spinner = ora('Fetching webhooks').start();
try {
const webhooks = await WebhookQuery.byAppIdAsync(appId);
if (webhooks.length === 0) {
spinner.fail('There are no webhooks on the project');
return [];
} else {
spinner.succeed(`Successfully fetched ${webhooks.length} webhooks`);
return webhooks;
}
} catch (err) {
spinner.fail("Something went wrong and we couldn't fetch the webhook list");
throw err;
}
}
@@ -0,0 +1,60 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import ora from 'ora';
import { WebhookType } from '../../graphql/generated';
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
import Log from '../../log';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { formatWebhook } from '../../webhooks/formatWebhook';
export default class WebhookList extends Command {
static description = 'List webhooks on the current project.';
static flags = {
event: flags.enum({
description: 'Event type that triggers the webhook',
options: [WebhookType.Build],
}),
};
async run() {
await ensureLoggedInAsync();
const {
flags: { event },
} = this.parse(WebhookList);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
const spinner = ora(`Fetching the list of webhook on project ${projectFullName}`).start();
try {
const webhooks = await WebhookQuery.byAppIdAsync(projectId, event && { event });
if (webhooks.length === 0) {
spinner.fail(`There are no webhooks on project ${projectFullName}`);
} else {
spinner.succeed(`Found ${webhooks.length} webhooks on project ${projectFullName}`);
const list = webhooks
.map(webhook => formatWebhook(webhook))
.join(`\n\n${chalk.dim('———')}\n\n`);
Log.log(`\n${list}`);
}
} catch (err) {
spinner.fail(
`Something went wrong and we couldn't fetch the webhook list for the project ${projectFullName}`
);
throw err;
}
}
}
@@ -0,0 +1,54 @@
import { Command, flags } from '@oclif/command';
import pick from 'lodash/pick';
import ora from 'ora';
import { WebhookType } from '../../graphql/generated';
import { WebhookMutation } from '../../graphql/mutations/WebhookMutation';
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
import { ensureLoggedInAsync } from '../../user/actions';
import { prepareInputParamsAsync } from '../../webhooks/input';
export default class WebhookUpdate extends Command {
static description = 'Create a webhook on the current project.';
static flags = {
id: flags.string({
description: 'Webhook ID',
required: true,
}),
event: flags.enum({
description: 'Event type that triggers the webhook',
options: [WebhookType.Build],
default: WebhookType.Build,
}),
url: flags.string({
description: 'Webhook URL',
}),
secret: flags.string({
description:
"Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature' header.",
}),
};
async run() {
await ensureLoggedInAsync();
const { flags } = this.parse(WebhookUpdate);
const webhookId = flags.id;
const webhook = await WebhookQuery.byIdAsync(webhookId);
const webhookInputParams = await prepareInputParamsAsync(
pick(flags, ['event', 'url', 'secret']),
webhook
);
const spinner = ora('Updating a webhook').start();
try {
await WebhookMutation.updateWebhookAsync(webhookId, webhookInputParams);
spinner.succeed('Successfully updated a webhook');
} catch (err) {
spinner.fail('Failed to update a webhook');
throw err;
}
}
}
@@ -0,0 +1,36 @@
import { Command } from '@oclif/command';
import ora from 'ora';
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
import Log from '../../log';
import { ensureLoggedInAsync } from '../../user/actions';
import { formatWebhook } from '../../webhooks/formatWebhook';
export default class WebhookView extends Command {
static description = 'View a webhook on the current project.';
static args = [
{
name: 'ID',
required: true,
description: 'ID of the webhook to view',
},
];
async run() {
await ensureLoggedInAsync();
const {
args: { ID: webhookId },
} = this.parse(WebhookView);
const spinner = ora(`Fetching the webhook details for ID ${webhookId}`).start();
try {
const webhook = await WebhookQuery.byIdAsync(webhookId);
spinner.succeed(`Found the webhook details`);
Log.log(`\n${formatWebhook(webhook)}`);
} catch (err) {
spinner.fail(`Couldn't find the webhook with ID ${webhookId}`);
throw err;
}
}
}
@@ -1,8 +0,0 @@
import { Platform } from '@expo/eas-build-job';
export interface CredentialsProvider {
platform: Platform;
hasRemoteAsync(): Promise<boolean>;
hasLocalAsync(): Promise<boolean>;
isLocalSyncedAsync(): Promise<boolean>;
}
@@ -0,0 +1,61 @@
import {
AndroidAppBuildCredentialsFragment,
AndroidKeystoreFragment,
AndroidKeystoreType,
AppFragment,
CommonAndroidAppCredentialsFragment,
} from '../../graphql/generated';
import { testKeystore } from './fixtures-android';
import { testKeystoreBase64 } from './fixtures-base64-data';
export const testAppFragment: AppFragment = {
id: 'test-app-id',
fullName: '@testuser/testapp',
slug: 'testapp',
};
export const testJksAndroidKeystoreFragment: AndroidKeystoreFragment = {
id: 'test-id',
type: AndroidKeystoreType.Jks,
keystore: testKeystoreBase64,
keystorePassword: testKeystore.keystorePassword,
keyAlias: testKeystore.keyAlias,
keyPassword: testKeystore.keyPassword,
md5CertificateFingerprint: 'test-md5',
sha1CertificateFingerprint: 'test-sha1',
sha256CertificateFingerprint: 'test-sha256',
createdAt: new Date(),
updatedAt: new Date(),
};
export const testLegacyAndroidBuildCredentialsFragment: AndroidAppBuildCredentialsFragment = {
id: 'test-id',
isDefault: true,
isLegacy: true,
name: 'legacy',
androidKeystore: testJksAndroidKeystoreFragment,
};
export const testLegacyAndroidAppCredentialsFragment: CommonAndroidAppCredentialsFragment = {
id: 'test-id',
applicationIdentifier: null,
isLegacy: true,
app: testAppFragment,
androidAppBuildCredentialsList: [testLegacyAndroidBuildCredentialsFragment],
};
export const testAndroidBuildCredentialsFragment: AndroidAppBuildCredentialsFragment = {
id: 'test-id',
isDefault: true,
isLegacy: false,
name: 'Google App Store Build Credentials',
androidKeystore: testJksAndroidKeystoreFragment,
};
export const testAndroidAppCredentialsFragment: CommonAndroidAppCredentialsFragment = {
id: 'test-id',
applicationIdentifier: null,
isLegacy: false,
app: testAppFragment,
androidAppBuildCredentialsList: [testLegacyAndroidBuildCredentialsFragment],
};
@@ -5,6 +5,7 @@ export const jester: Actor = {
id: 'jester-id',
username: 'jester',
accounts: [{ id: 'jester-account-id', name: 'jester' }],
isExpoAdmin: false,
};
export const jester2: Actor = {
@@ -12,6 +13,7 @@ export const jester2: Actor = {
id: 'jester2-id',
username: 'jester2',
accounts: [{ id: 'jester2-account-id', name: 'jester2' }],
isExpoAdmin: false,
};
export const testUsername = jester.username;
@@ -32,6 +34,7 @@ export const testAppJson = {
slug: testSlug,
sdkVersion: '38.0.0',
ios: { bundleIdentifier: testBundleIdentifier },
android: { package: testPackageName },
};
export const testAppJsonWithDifferentOwner = {
@@ -5,15 +5,14 @@ import { Context } from '../context';
import { getAndroidApiMockWithoutCredentials } from './fixtures-android';
import { getAppstoreMock } from './fixtures-appstore';
import { testAppJson, testUsername } from './fixtures-constants';
import { getIosApiMockWithoutCredentials } from './fixtures-ios';
import { getNewIosApiMockWithoutCredentials } from './fixtures-new-ios';
import { getNewIosApiMockWithoutCredentials } from './fixtures-ios';
export function createCtxMock(mockOverride: Record<string, any> = {}): Context {
const defaultMock = {
ios: getIosApiMockWithoutCredentials(),
newIos: getNewIosApiMockWithoutCredentials(),
ios: getNewIosApiMockWithoutCredentials(),
android: getAndroidApiMockWithoutCredentials(),
appStore: getAppstoreMock(),
bestEffortAppStoreAuthenticateAsync: jest.fn(),
ensureAppleCtx: jest.fn(),
ensureProjectContext: jest.fn(),
user: {
@@ -37,5 +36,5 @@ export function createManagerMock(mockOverride: Record<string, any> = {}): Crede
}),
},
mockOverride
);
) as any;
}
@@ -4,42 +4,25 @@ import {
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
AppleTeamFragment,
CommonIosAppCredentialsFragment,
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../graphql/generated';
import { IosAppCredentialsWithBuildCredentialsQueryResult } from '../ios/api/graphql/queries/IosAppCredentialsQuery';
import {
DistributionCertificate,
DistributionCertificateStoreInfo,
ProvisioningProfile,
ProvisioningProfileStoreInfo,
PushKey,
PushKeyStoreInfo,
} from '../ios/appstore/Credentials.types';
import { IosDistCredentials, IosPushCredentials } from '../ios/credentials';
import { testProvisioningProfileBase64 } from './fixtures-base64-data';
import { testBundleIdentifier, testExperienceName } from './fixtures-constants';
const today = new Date();
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
export const testAppleTeam = {
id: 'test-team-id',
};
export const testProvisioningProfile: ProvisioningProfile = {
provisioningProfileId: 'test-id',
provisioningProfile: testProvisioningProfileBase64,
teamId: 'id',
};
export const testProvisioningProfiles = [testProvisioningProfile];
export const testProvisioningProfileFromApple: ProvisioningProfileStoreInfo = {
name: 'test-name',
status: 'Active',
expires: tomorrow.getTime(),
distributionMethod: 'test',
certificates: [],
provisioningProfileId: testProvisioningProfile.provisioningProfileId,
provisioningProfile: testProvisioningProfile.provisioningProfile,
teamId: 'id',
};
export const testProvisioningProfilesFromApple = [testProvisioningProfileFromApple];
export const testAppleTeamFragment: AppleTeamFragment = {
id: 'test-apple-team-id-1',
@@ -61,7 +44,7 @@ export const testProvisioningProfileFragment: AppleProvisioningProfileFragment =
id: 'test-prov-prof-id-1',
expiration: new Date(),
developerPortalIdentifier: 'test-developer-identifier',
provisioningProfile: 'test-provisioning-profile',
provisioningProfile: testProvisioningProfileBase64,
updatedAt: new Date(),
status: 'Active',
appleTeam: { ...testAppleTeamFragment },
@@ -107,6 +90,68 @@ export const testDistCertFragmentOneDependency: AppleDistributionCertificateFrag
],
};
export const testIosAppBuildCredentialsFragment: IosAppBuildCredentialsFragment = {
id: 'test-ios-app-build-credentials-id',
iosDistributionType: IosDistributionType.AppStore,
distributionCertificate: testDistCertFragmentNoDependencies,
provisioningProfile: testProvisioningProfileFragment,
};
export const testCommonIosAppCredentialsFragment: CommonIosAppCredentialsFragment = {
id: 'test-common-ios-app-credentials-id',
app: testAppFragment,
appleTeam: testAppleTeamFragment,
appleAppIdentifier: testAppleAppIdentifierFragment,
iosAppBuildCredentialsList: [testIosAppBuildCredentialsFragment],
};
export const testIosAppCredentialsWithBuildCredentialsQueryResult: IosAppCredentialsWithBuildCredentialsQueryResult = {
id: 'test-app-credential-id',
iosAppBuildCredentialsList: [testIosAppBuildCredentialsFragment],
};
export const testAppleTeam = {
id: 'test-team-id',
};
export function getNewIosApiMockWithoutCredentials() {
return {
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: () => testAppleTeam,
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
getDevicesForAppleTeamAsync: jest.fn(),
createProvisioningProfileAsync: jest.fn(),
getProvisioningProfileAsync: jest.fn(),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfilesAsync: jest.fn(),
getDistributionCertificateForAppAsync: jest.fn(),
getDistributionCertificatesForAccountAsync: jest.fn(),
createDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
};
}
export function getNewIosApiMockWithCredentials() {
return {
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: jest.fn(),
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
getDevicesForAppleTeamAsync: jest.fn(),
createProvisioningProfileAsync: jest.fn(),
getProvisioningProfileAsync: jest.fn(),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfilesAsync: jest.fn(),
getDistributionCertificateForAppAsync: jest.fn(),
getDistributionCertificatesForAccountAsync: jest.fn(),
createDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
};
}
export const testDistCert: DistributionCertificate = {
certP12: 'Y2VydHAxMg==',
certPassword: 'test-password',
@@ -118,18 +163,6 @@ export const testIosDistCredential: IosDistCredentials = {
type: 'dist-cert',
...testDistCert,
};
export const testIosDistCredentials = [testIosDistCredential];
export const testDistCertFromApple: DistributionCertificateStoreInfo = {
id: 'test-id',
name: 'test-name',
status: 'Active',
created: today.getTime(),
expires: tomorrow.getTime(),
ownerName: 'test-owner',
ownerId: 'test-id',
serialNumber: testIosDistCredential.distCertSerialNumber as string,
};
export const testDistCertsFromApple = [testDistCertFromApple];
export const testPushKey: PushKey = {
apnsKeyP8: 'test-p8',
@@ -137,22 +170,16 @@ export const testPushKey: PushKey = {
teamId: 'test-team-id',
};
/**
* Legacy format fixtures
*/
export const testIosPushCredential: IosPushCredentials = {
id: 2,
type: 'push-key',
...testPushKey,
};
export const testIosPushCredentials = [testIosPushCredential];
export const testPushKeyFromApple: PushKeyStoreInfo = {
id: testIosPushCredential.apnsKeyId,
name: 'test-name',
};
export const testPushKeysFromApple = [testPushKeyFromApple];
export const testLegacyPushCert = {
pushId: 'test-push-id',
pushP12: 'test-push-p12',
pushPassword: 'test-push-password',
};
export const testAppCredential = {
experienceName: testExperienceName,
bundleIdentifier: testBundleIdentifier,
@@ -167,62 +194,3 @@ export const testAllCredentialsForApp = {
pushCredentials: testPushKey,
distCredentials: testDistCert,
};
export const testAppCredentials = [testAppCredential];
export const testAllCredentials = {
userCredentials: [...testIosDistCredentials, ...testIosPushCredentials],
appCredentials: testAppCredentials,
};
export function getIosApiMock() {
return {
getAllCredentials: jest.fn(() => testAllCredentials),
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
getDistributionCertificateByIdAsync: jest.fn(id =>
id === testIosDistCredential.id ? testIosDistCredential : null
),
createDistributionCertificateAsync: jest.fn((owner, cert) => ({
id: 123,
type: 'dist-cert',
...cert,
})),
updateDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
useDistributionCertificateAsync: jest.fn(),
getPushKey: jest.fn(() => testIosPushCredential),
createPushKeyAsync: jest.fn((owner, key) => ({ id: 124, type: 'push-key', ...key })),
updatePushKeyAsync: jest.fn(),
deletePushKeyAsync: jest.fn(),
usePushKeyAsync: jest.fn(),
getAppCredentialsAsync: jest.fn(() => testAppCredentials),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfileAsync: jest.fn(),
};
}
export function getIosApiMockWithoutCredentials() {
return {
getAllCredentials: jest.fn(() => ({ appCredentials: [], userCredentials: [] })),
getDistributionCertificateAsync: jest.fn(() => null),
getDistributionCertificateByIdAsync: jest.fn(() => {
throw new Error('not found');
}),
createDistributionCertificateAsync: jest.fn(),
updateDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
useDistributionCertificateAsync: jest.fn(),
getPushKeyAsync: jest.fn(() => null),
createPushKeyAsync: jest.fn(),
updatePushKeyAsync: jest.fn(),
deletePushKeyAsync: jest.fn(),
usePushKeyAsync: jest.fn(),
getAppCredentialsAsync: jest.fn(app => ({
experienceName: `${app.accountName}/${app.projectName}`,
bundleIdentifier: app.bundleIdentifier,
})),
getProvisioningProfileAsync: jest.fn(() => null),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfileAsync: jest.fn(),
};
}
@@ -1,141 +0,0 @@
import {
DistributionCertificate,
DistributionCertificateStoreInfo,
ProvisioningProfile,
ProvisioningProfileStoreInfo,
PushKey,
PushKeyStoreInfo,
} from '../ios/appstore/Credentials.types';
import { IosDistCredentials, IosPushCredentials } from '../ios/credentials';
import { testProvisioningProfileBase64 } from './fixtures-base64-data';
import { testBundleIdentifier, testExperienceName } from './fixtures-constants';
const today = new Date();
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
export const testAppleTeam = {
id: 'test-team-id',
};
export const testProvisioningProfile: ProvisioningProfile = {
provisioningProfileId: 'test-id',
provisioningProfile: testProvisioningProfileBase64,
teamId: 'id',
};
export const testProvisioningProfiles = [testProvisioningProfile];
export const testProvisioningProfileFromApple: ProvisioningProfileStoreInfo = {
name: 'test-name',
status: 'Active',
expires: tomorrow.getTime(),
distributionMethod: 'test',
certificates: [],
provisioningProfileId: testProvisioningProfile.provisioningProfileId,
provisioningProfile: testProvisioningProfile.provisioningProfile,
teamId: 'id',
};
export const testProvisioningProfilesFromApple = [testProvisioningProfileFromApple];
export const testDistCert: DistributionCertificate = {
certP12: 'Y2VydHAxMg==',
certPassword: 'test-password',
distCertSerialNumber: 'test-serial',
teamId: 'test-team-id',
};
export const testIosDistCredential: IosDistCredentials = {
id: 1,
type: 'dist-cert',
...testDistCert,
};
export const testIosDistCredentials = [testIosDistCredential];
export const testDistCertFromApple: DistributionCertificateStoreInfo = {
id: 'test-id',
name: 'test-name',
status: 'Active',
created: today.getTime(),
expires: tomorrow.getTime(),
ownerName: 'test-owner',
ownerId: 'test-id',
serialNumber: testIosDistCredential.distCertSerialNumber as string,
};
export const testDistCertsFromApple = [testDistCertFromApple];
export const testPushKey: PushKey = {
apnsKeyP8: 'test-p8',
apnsKeyId: 'test-key-id',
teamId: 'test-team-id',
};
export const testIosPushCredential: IosPushCredentials = {
id: 2,
type: 'push-key',
...testPushKey,
};
export const testIosPushCredentials = [testIosPushCredential];
export const testPushKeyFromApple: PushKeyStoreInfo = {
id: testIosPushCredential.apnsKeyId,
name: 'test-name',
};
export const testPushKeysFromApple = [testPushKeyFromApple];
export const testLegacyPushCert = {
pushId: 'test-push-id',
pushP12: 'test-push-p12',
pushPassword: 'test-push-password',
};
export const testAppCredential = {
experienceName: testExperienceName,
bundleIdentifier: testBundleIdentifier,
distCredentialsId: testIosDistCredential.id,
pushCredentialsId: testIosPushCredential.id,
credentials: {
...testProvisioningProfile,
},
};
export const testAllCredentialsForApp = {
...testAppCredential,
pushCredentials: testPushKey,
distCredentials: testDistCert,
};
export const testAppCredentials = [testAppCredential];
export const testAllCredentials = {
userCredentials: [...testIosDistCredentials, ...testIosPushCredentials],
appCredentials: testAppCredentials,
};
export function getNewIosApiMockWithoutCredentials() {
return {
getAppAsync: jest.fn(),
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: () => testAppleTeam,
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
getDevicesForAppleTeamAsync: jest.fn(),
createProvisioningProfileAsync: jest.fn(),
getProvisioningProfileAsync: jest.fn(),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfilesAsync: jest.fn(),
getDistributionCertificateForAppAsync: jest.fn(),
getDistributionCertificatesForAccountAsync: jest.fn(),
createDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
};
}
export function getNewIosApiMockWithCredentials() {
return {
getAppAsync: jest.fn(),
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: jest.fn(),
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
getDevicesForAppleTeamAsync: jest.fn(),
createProvisioningProfileAsync: jest.fn(),
getProvisioningProfileAsync: jest.fn(),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfilesAsync: jest.fn(),
getDistributionCertificateForAppAsync: jest.fn(),
getDistributionCertificatesForAccountAsync: jest.fn(),
createDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
};
}
@@ -1,9 +1,7 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import Log from '../../log';
import { CredentialsManager } from '../CredentialsManager';
import { CredentialsProvider } from '../CredentialsProvider';
import { Context } from '../context';
import * as credentialsJsonReader from '../credentialsJson/read';
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
@@ -23,7 +21,7 @@ interface Options {
skipCredentialsCheck?: boolean;
}
export default class AndroidCredentialsProvider implements CredentialsProvider {
export default class AndroidCredentialsProvider {
public readonly platform = Platform.ANDROID;
constructor(private ctx: Context, private options: Options) {}
@@ -33,45 +31,6 @@ export default class AndroidCredentialsProvider implements CredentialsProvider {
return `@${accountName}/${projectName}`;
}
public async hasRemoteAsync(): Promise<boolean> {
const keystore = await this.ctx.android.fetchKeystoreAsync(this.projectFullName);
return !!keystore;
}
public async hasLocalAsync(): Promise<boolean> {
if (!(await credentialsJsonReader.fileExistsAsync(this.ctx.projectDir))) {
return false;
}
try {
const rawCredentialsJson = await credentialsJsonReader.readRawAsync(this.ctx.projectDir);
return !!rawCredentialsJson?.android;
} catch (err) {
Log.error(err); // malformed json
return false;
}
}
public async isLocalSyncedAsync(): Promise<boolean> {
try {
const [remote, local] = await Promise.all([
this.ctx.android.fetchKeystoreAsync(this.projectFullName),
credentialsJsonReader.readAndroidCredentialsAsync(this.ctx.projectDir),
]);
const r = remote;
const l = local?.keystore;
return !!(
r &&
l &&
r.keystore === l.keystore &&
r.keystorePassword === l.keystorePassword &&
r.keyAlias === l.keyAlias &&
r.keyPassword === l.keyPassword
);
} catch (_) {
return false;
}
}
public async getCredentialsAsync(
src: CredentialsSource.LOCAL | CredentialsSource.REMOTE
): Promise<AndroidCredentials> {
@@ -81,7 +40,6 @@ export default class AndroidCredentialsProvider implements CredentialsProvider {
case CredentialsSource.REMOTE:
return await this.getRemoteAsync();
}
throw new Error('Unknown');
}
private async getRemoteAsync(): Promise<AndroidCredentials> {
@@ -0,0 +1,25 @@
import { getProjectAccountName, getProjectConfigDescription } from '../../../project/projectUtils';
import { findAccountByName } from '../../../user/Account';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
export function getAppLookupParamsFromContext(ctx: Context): AppLookupParams {
ctx.ensureProjectContext();
const projectName = ctx.exp.slug;
const accountName = getProjectAccountName(ctx.exp, ctx.user);
const account = findAccountByName(ctx.user.accounts, accountName);
if (!account) {
throw new Error(`You do not have access to account: ${accountName}`);
}
const androidApplicationIdentifier = ctx.exp.android?.package;
if (!androidApplicationIdentifier) {
throw new Error(
`android.package needs to be defined in your ${getProjectConfigDescription(
ctx.projectDir
)} file`
);
}
return { account, projectName, androidApplicationIdentifier };
}
@@ -0,0 +1,40 @@
import { CommonAndroidAppCredentialsFragment } from '../../../graphql/generated';
import { Account } from '../../../user/Account';
import { AndroidAppCredentialsQuery } from './graphql/queries/AndroidAppCredentialsQuery';
export interface AppLookupParams {
account: Account;
projectName: string;
androidApplicationIdentifier: string; // 'android.package' field in app.json
}
export async function getAndroidAppCredentialsWithCommonFieldsAsync(
appLookupParams: AppLookupParams
): Promise<CommonAndroidAppCredentialsFragment | null> {
const { androidApplicationIdentifier } = appLookupParams;
const projectFullName = formatProjectFullName(appLookupParams);
return await AndroidAppCredentialsQuery.withCommonFieldsByApplicationIdentifierAsync(
projectFullName,
{
androidApplicationIdentifier,
legacyOnly: false,
}
);
}
/* There is at most one set of legacy android app credentials associated with an Expo App */
export async function getLegacyAndroidAppCredentialsWithCommonFieldsAsync(
appLookupParams: AppLookupParams
): Promise<CommonAndroidAppCredentialsFragment | null> {
const projectFullName = formatProjectFullName(appLookupParams);
return await AndroidAppCredentialsQuery.withCommonFieldsByApplicationIdentifierAsync(
projectFullName,
{
legacyOnly: true,
}
);
}
// TODO; refactor
const formatProjectFullName = ({ account, projectName }: AppLookupParams): string =>
`@${account.name}/${projectName}`;
@@ -0,0 +1,65 @@
import assert from 'assert';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../../../../graphql/client';
import {
CommonAndroidAppCredentialsFragment,
CommonAndroidAppCredentialsWithBuildCredentialsByApplicationIdentifierQuery,
} from '../../../../../graphql/generated';
import { CommonAndroidAppCredentialsFragmentNode } from '../../../../../graphql/types/credentials/AndroidAppCredentials';
const AndroidAppCredentialsQuery = {
async withCommonFieldsByApplicationIdentifierAsync(
projectFullName: string,
{
androidApplicationIdentifier,
legacyOnly,
}: {
androidApplicationIdentifier?: string;
legacyOnly?: boolean;
}
): Promise<CommonAndroidAppCredentialsFragment | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<CommonAndroidAppCredentialsWithBuildCredentialsByApplicationIdentifierQuery>(
gql`
query CommonAndroidAppCredentialsWithBuildCredentialsByApplicationIdentifierQuery(
$projectFullName: String!
$applicationIdentifier: String
$legacyOnly: Boolean
) {
app {
byFullName(fullName: $projectFullName) {
id
androidAppCredentials(
filter: {
applicationIdentifier: $applicationIdentifier
legacyOnly: $legacyOnly
}
) {
id
...CommonAndroidAppCredentialsFragment
}
}
}
}
${print(CommonAndroidAppCredentialsFragmentNode)}
`,
{
projectFullName,
applicationIdentifier: androidApplicationIdentifier,
legacyOnly,
},
{
additionalTypenames: ['AndroidAppCredentials', 'AndroidAppBuildCredentials', 'App'],
}
)
.toPromise()
);
assert(data.app, 'GraphQL: `app` not defined in server response');
return data.app.byFullName.androidAppCredentials[0] ?? null;
},
};
export { AndroidAppCredentialsQuery };
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`print credentials prints the AndroidAppCredentials fragment 1`] = `"Android Credentials Project: testApp Application Identifier: test.com.app EAS Credentials: These will take precedence over Expo Classic credentials Configuration: legacy (Default) Keystore: Type: JKS Key Alias: QHdrb3p5cmEvY3JlZGVudGlhbHMtdGVzdA== MD5 Fingerprint: TE:ST:-M:D5 SHA1 Fingerprint: TE:ST:-S:HA:1 SHA256 Fingerprint: TE:ST:-S:HA:25:6 Updated 0 second ago Credentials ported from Expo Classic (expo-cli): Keystore: Type: JKS Key Alias: QHdrb3p5cmEvY3JlZGVudGlhbHMtdGVzdA== MD5 Fingerprint: TE:ST:-M:D5 SHA1 Fingerprint: TE:ST:-S:HA:1 SHA256 Fingerprint: TE:ST:-S:HA:25:6 Updated 0 second ago"`;
@@ -0,0 +1,31 @@
import mockdate from 'mockdate';
import Log from '../../../../log';
import {
testAndroidAppCredentialsFragment,
testLegacyAndroidAppCredentialsFragment,
} from '../../../__tests__/fixtures-android-new';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContext } from '../../actions/BuildCredentialsUtils';
import { displayAndroidAppCredentials } from '../printCredentialsBeta';
jest.mock('../../../../log');
jest.mock('chalk', () => ({ bold: jest.fn(log => log) }));
mockdate.set(new Date('4/20/2021'));
describe('print credentials', () => {
it('prints the AndroidAppCredentials fragment', async () => {
const ctx = createCtxMock();
const appLookupParams = getAppLookupParamsFromContext(ctx);
displayAndroidAppCredentials({
appLookupParams,
legacyAppCredentials: testLegacyAndroidAppCredentialsFragment,
appCredentials: testAndroidAppCredentialsFragment,
});
const loggedSoFar = (Log.log as jest.Mock).mock.calls.reduce(
(acc, mockValue) => acc + mockValue
);
expect(loggedSoFar).toMatchSnapshot();
});
});
@@ -0,0 +1,143 @@
import chalk from 'chalk';
import {
AndroidAppBuildCredentialsFragment,
CommonAndroidAppCredentialsFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { fromNow } from '../../../utils/date';
import { AppLookupParams } from '../api/GraphqlClient';
export function displayEmptyAndroidCredentials(appLookupParams: AppLookupParams): void {
const { projectName, androidApplicationIdentifier } = appLookupParams;
Log.log(chalk.bold(`Android Credentials`));
Log.log(` Project: ${projectName}`);
Log.log(` Application Identifier: ${androidApplicationIdentifier}`);
Log.log(` No credentials set up yet!`);
}
/**
* sort a build credentials array in descending order of preference
* prefer default credentials, then prefer names that come first lexicographically
*/
function sortBuildCredentials(
androidAppBuildCredentialsList: AndroidAppBuildCredentialsFragment[]
): AndroidAppBuildCredentialsFragment[] {
return androidAppBuildCredentialsList.sort((buildCredentialsA, buildCredentialsB) => {
if (buildCredentialsA.isDefault) {
return -1;
} else if (buildCredentialsB.isDefault) {
return 1;
}
return buildCredentialsA.name.localeCompare(buildCredentialsB.name);
});
}
function displayLegacyAndroidAppCredentials(
legacyAppCredentials: CommonAndroidAppCredentialsFragment,
appCredentials: CommonAndroidAppCredentialsFragment | null
): void {
if (appCredentials) {
// differentiate between old and new if there's both
Log.log(chalk.bold(` Credentials ported from Expo Classic (expo-cli):`));
}
// There can only be one set of build credentials in legacy app credentials
const [legacyBuildCredentials] = legacyAppCredentials.androidAppBuildCredentialsList;
if (legacyBuildCredentials) {
displayAndroidBuildCredentials(
legacyBuildCredentials,
/* only one set to show, skip config display */ true
);
}
}
function displayEASAndroidAppCredentials(
legacyAppCredentials: CommonAndroidAppCredentialsFragment | null,
appCredentials: CommonAndroidAppCredentialsFragment
): void {
if (legacyAppCredentials) {
// differentiate between old and new if there's both
Log.log(chalk.bold(` EAS Credentials:`));
Log.log(` These will take precedence over Expo Classic credentials`);
}
const sortedBuildCredentialsList = sortBuildCredentials(
appCredentials.androidAppBuildCredentialsList
);
for (const buildCredentials of sortedBuildCredentialsList) {
displayAndroidBuildCredentials(buildCredentials);
}
}
function formatFingerprint(fingerprint: string | null): string {
if (!fingerprint) {
return 'unavailable';
}
const uppercaseFingerprint = fingerprint.toUpperCase();
const bytes = [];
for (let i = 0; i < uppercaseFingerprint.length; i++) {
const halfByte = uppercaseFingerprint.charAt(i);
if (i % 2 === 0) {
bytes.push(halfByte); // first half of the byte
} else {
bytes[bytes.length - 1] += halfByte; // second half of the byte
}
}
return bytes.join(':');
}
function displayAndroidBuildCredentials(
buildCredentials: AndroidAppBuildCredentialsFragment,
skipConfigurationDisplay?: boolean
): void {
if (!skipConfigurationDisplay) {
const { isDefault, name } = buildCredentials;
Log.log(chalk.bold(` Configuration: ${name}${isDefault ? ' (Default)' : ''}`));
Log.newLine();
}
const maybeKeystore = buildCredentials.androidKeystore;
Log.log(` Keystore:`);
if (maybeKeystore) {
const {
keyAlias,
type,
md5CertificateFingerprint,
sha1CertificateFingerprint,
sha256CertificateFingerprint,
updatedAt,
} = maybeKeystore;
Log.log(` Type: ${type}`);
Log.log(` Key Alias: ${keyAlias}`);
Log.log(` MD5 Fingerprint: ${formatFingerprint(md5CertificateFingerprint ?? null)}`);
Log.log(` SHA1 Fingerprint: ${formatFingerprint(sha1CertificateFingerprint ?? null)}`);
Log.log(` SHA256 Fingerprint: ${formatFingerprint(sha256CertificateFingerprint ?? null)}`);
Log.log(` Updated ${fromNow(new Date(updatedAt))} ago`);
} else {
Log.log(` None assigned yet`);
}
Log.newLine();
}
export function displayAndroidAppCredentials({
appLookupParams,
legacyAppCredentials,
appCredentials,
}: {
appLookupParams: AppLookupParams;
legacyAppCredentials: CommonAndroidAppCredentialsFragment | null;
appCredentials: CommonAndroidAppCredentialsFragment | null;
}): void {
const { projectName, androidApplicationIdentifier } = appLookupParams;
Log.log(chalk.bold(`Android Credentials`));
Log.log(` Project: ${projectName}`);
Log.log(` Application Identifier: ${androidApplicationIdentifier}`);
Log.newLine();
if (appCredentials) {
displayEASAndroidAppCredentials(legacyAppCredentials, appCredentials);
}
if (legacyAppCredentials) {
displayLegacyAndroidAppCredentials(legacyAppCredentials, appCredentials);
}
}
+5 -5
View File
@@ -8,7 +8,7 @@ import { confirmAsync } from '../prompts';
import { Actor, getActorDisplayName } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import AndroidApi from './android/api/Client';
import iOSApi from './ios/api/Client';
import * as AndroidGraphqlClient from './android/api/GraphqlClient';
import * as IosGraphqlClient from './ios/api/GraphqlClient';
import AppStoreApi from './ios/appstore/AppStoreApi';
@@ -27,8 +27,8 @@ export interface Context {
readonly user: Actor;
readonly nonInteractive: boolean;
readonly android: AndroidApi;
readonly ios: iOSApi;
readonly newIos: typeof IosGraphqlClient;
readonly newAndroid: typeof AndroidGraphqlClient;
readonly ios: typeof IosGraphqlClient;
readonly appStore: AppStoreApi;
readonly hasProjectContext: boolean;
readonly exp: ExpoConfig;
@@ -56,8 +56,8 @@ export async function createCredentialsContextAsync(
class CredentialsContext implements Context {
public readonly android = new AndroidApi();
public readonly ios = new iOSApi();
public readonly newIos = IosGraphqlClient;
public readonly newAndroid = AndroidGraphqlClient;
public readonly ios = IosGraphqlClient;
public readonly appStore: AppStoreApi;
public readonly nonInteractive: boolean;
private shouldAskAuthenticateAppStore: boolean = true;
@@ -3,13 +3,15 @@ import { vol } from 'memfs';
import prompts from 'prompts';
import { asMock } from '../../../__tests__/utils';
import { IosDistributionType } from '../../../graphql/generated';
import { testKeystore } from '../../__tests__/fixtures-android';
import { createCtxMock } from '../../__tests__/fixtures-context';
import {
getNewIosApiMockWithoutCredentials,
testAllCredentialsForApp,
testIosDistCredential,
testProvisioningProfile,
testCommonIosAppCredentialsFragment,
} from '../../__tests__/fixtures-ios';
import { getAppLookupParamsFromContext } from '../../ios/actions/BuildCredentialsUtils';
import { updateAndroidCredentialsAsync, updateIosCredentialsAsync } from '../update';
jest.mock('fs');
@@ -195,9 +197,10 @@ describe('update credentials.json', () => {
it('should update ios credentials in credentials.json if www returns valid credentials', async () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
vol.fromJSON({
@@ -213,12 +216,19 @@ describe('update credentials.json', () => {
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
const appLookupParams = getAppLookupParamsFromContext(ctx);
await updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore);
const certP12 = await fs.readFile('./cert.p12', 'base64');
const pprofile = await fs.readFile('./pprofile', 'base64');
const credJson = await fs.readJson('./credentials.json');
expect(certP12).toEqual(testAllCredentialsForApp.distCredentials.certP12);
expect(pprofile).toEqual(testAllCredentialsForApp.credentials.provisioningProfile);
expect(certP12).toEqual(
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0].distributionCertificate
?.certificateP12
);
expect(pprofile).toEqual(
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0].provisioningProfile
?.provisioningProfile
);
expect(credJson).toEqual({
ios: {
provisioningProfilePath: 'pprofile',
@@ -232,12 +242,14 @@ describe('update credentials.json', () => {
it('should create credentials.json provisioning profile and distribution certificate if credentials.json does not exist', async () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
const appLookupParams = getAppLookupParamsFromContext(ctx);
await updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore);
const certP12 = await fs.readFile('./ios/certs/dist-cert.p12', 'base64');
const pprofile = await fs.readFile('./ios/certs/profile.mobileprovision', 'base64');
const credJson = await fs.readJson('./credentials.json');
@@ -256,9 +268,8 @@ describe('update credentials.json', () => {
it('should not do anything if no credentials are returned from www', async () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => null),
getDistributionCertificateAsync: jest.fn(() => null),
getProvisioningProfileAsync: jest.fn(() => null),
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(() => null),
},
});
const credJson = {
@@ -276,11 +287,12 @@ describe('update credentials.json', () => {
'./cert.p12': 'somebinarycontent2',
});
try {
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
const appLookupParams = getAppLookupParamsFromContext(ctx);
await updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore);
throw new Error('updateIosCredentialsAsync should throw na error');
} catch (e) {
expect(e.message).toMatch(
'There are no credentials configured for this project on EAS servers'
'There are no credentials configured for the APP_STORE distribution of this project on EAS servers'
);
}
const certP12 = await fs.readFile('./cert.p12', 'base64');
@@ -291,15 +303,15 @@ describe('update credentials.json', () => {
expect(newCredJson).toEqual(credJson);
});
it('should display confirm prompt if some credentials are missing in www (confirm: true)', async () => {
// create deep clone the quick and dirty way
const testIosAppCredentialsNoDistCert = JSON.parse(
JSON.stringify(testCommonIosAppCredentialsFragment)
);
testIosAppCredentialsNoDistCert.iosAppBuildCredentialsList[0].distributionCertificate = null;
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => ({
...testAllCredentialsForApp,
distCredentialsId: undefined,
distCredentials: undefined,
})),
getDistributionCertificateAsync: jest.fn(() => null),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(() => testIosAppCredentialsNoDistCert),
},
});
@@ -322,7 +334,8 @@ describe('update credentials.json', () => {
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
const appLookupParams = getAppLookupParamsFromContext(ctx);
await updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore);
const certP12Exists = await fs.pathExists('./cert.p12');
const pprofile = await fs.readFile('./pprofile', 'base64');
const credJson = await fs.readJson('./credentials.json');
@@ -337,9 +350,10 @@ describe('update credentials.json', () => {
it('should throw an error if credentials.json contains credentials for multi-target projects', async () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
vol.fromJSON({
@@ -366,16 +380,18 @@ describe('update credentials.json', () => {
'./dist-cert-1.p12': 'cert-1-somebinarycontent',
'./dist-cert-2.p12': 'cert-2-somebinarycontent',
});
await expect(() => updateIosCredentialsAsync(ctx, 'bundleIdentifier')).rejects.toThrow(
/Updating credentials.json failed/
);
const appLookupParams = getAppLookupParamsFromContext(ctx);
await expect(() =>
updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore)
).rejects.toThrow(/Updating credentials.json failed/);
});
it(`should throw an error if there is a typo in credentials.json (because we can't tell if this is a target name or not)`, async () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
vol.fromJSON({
@@ -392,9 +408,10 @@ describe('update credentials.json', () => {
'./pprofile-1.mobileprovision': 'pprofile-1-somebinarycontent',
'./dist-cert-1.p12': 'cert-1-somebinarycontent',
});
await expect(() => updateIosCredentialsAsync(ctx, 'bundleIdentifier')).rejects.toThrow(
/Updating credentials.json failed/
);
const appLookupParams = getAppLookupParamsFromContext(ctx);
await expect(() =>
updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore)
).rejects.toThrow(/Updating credentials.json failed/);
});
});
});
@@ -1,11 +1,13 @@
import fs from 'fs-extra';
import path from 'path';
import { IosDistributionType as IosDistributionTypeGraphql } from '../../graphql/generated';
import Log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { confirmAsync } from '../../prompts';
import { gitStatusAsync } from '../../utils/git';
import { Context } from '../context';
import { AppLookupParams } from '../ios/api/GraphqlClient';
import { CredentialsJson } from './read';
/**
@@ -84,7 +86,8 @@ export async function updateAndroidCredentialsAsync(ctx: Context): Promise<void>
*/
export async function updateIosCredentialsAsync(
ctx: Context,
bundleIdentifier: string
appLookupParams: AppLookupParams,
iosDistributionTypeGraphql: IosDistributionTypeGraphql
): Promise<void> {
const credentialsJsonFilePath = path.join(ctx.projectDir, 'credentials.json');
let rawCredentialsJsonObject: any = {};
@@ -118,26 +121,27 @@ export async function updateIosCredentialsAsync(
}
}
const accountName = getProjectAccountName(ctx.exp, ctx.user);
const appLookupParams = {
accountName,
projectName: ctx.exp.slug,
bundleIdentifier,
};
const profilePath =
rawCredentialsJsonObject?.ios?.provisioningProfilePath ?? 'ios/certs/profile.mobileprovision';
const distCertPath =
rawCredentialsJsonObject?.ios?.distributionCertificate?.path ?? 'ios/certs/dist-cert.p12';
const appCredentials = await ctx.ios.getAppCredentialsAsync(appLookupParams);
const distCredentials = await ctx.ios.getDistributionCertificateAsync(appLookupParams);
if (!appCredentials?.credentials?.provisioningProfile && !distCredentials) {
throw new Error('There are no credentials configured for this project on EAS servers');
const iosAppCredentials = await ctx.ios.getIosAppCredentialsWithCommonFieldsAsync(
appLookupParams
);
const buildCredentials =
iosAppCredentials?.iosAppBuildCredentialsList.find(
buildCredentials => buildCredentials.iosDistributionType === iosDistributionTypeGraphql
) ?? null;
if (!buildCredentials) {
throw new Error(
`There are no credentials configured for the ${iosDistributionTypeGraphql} distribution of this project on EAS servers`
);
}
const areCredentialsComplete =
appCredentials?.credentials?.provisioningProfile &&
distCredentials?.certP12 &&
distCredentials?.certPassword;
buildCredentials.provisioningProfile?.provisioningProfile &&
buildCredentials.distributionCertificate?.certificateP12 &&
buildCredentials.distributionCertificate.certificatePassword;
if (!areCredentialsComplete) {
const confirm = await confirmAsync({
@@ -149,28 +153,27 @@ export async function updateIosCredentialsAsync(
return;
}
}
const provisioningProfile =
buildCredentials.provisioningProfile?.provisioningProfile ?? undefined;
const distributionCertificateP12 =
buildCredentials.distributionCertificate?.certificateP12 ?? undefined;
const distributionCertificatePassword =
buildCredentials.distributionCertificate?.certificatePassword ?? undefined;
Log.log(`Writing Provisioning Profile to ${profilePath}`);
await updateFileAsync(
ctx.projectDir,
profilePath,
appCredentials?.credentials?.provisioningProfile
);
await updateFileAsync(ctx.projectDir, profilePath, provisioningProfile);
const shouldWarnPProfile = await isFileUntrackedAsync(profilePath);
Log.log(`Writing Distribution Certificate to ${distCertPath}`);
await updateFileAsync(ctx.projectDir, distCertPath, distCredentials?.certP12);
await updateFileAsync(ctx.projectDir, distCertPath, distributionCertificateP12);
const shouldWarnDistCert = await isFileUntrackedAsync(distCertPath);
const iosCredentials: Partial<CredentialsJson['ios']> = {
...(appCredentials?.credentials?.provisioningProfile
? { provisioningProfilePath: profilePath }
: {}),
...(distCredentials?.certP12 && distCredentials?.certPassword
...(provisioningProfile ? { provisioningProfilePath: profilePath } : {}),
...(distributionCertificateP12 && distributionCertificatePassword
? {
distributionCertificate: {
path: distCertPath,
password: distCredentials?.certPassword,
password: distributionCertificatePassword,
},
}
: {}),
@@ -1,107 +1,35 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource, iOSDistributionType } from '@expo/eas-json';
import { CredentialsSource, IosDistributionType, IosEnterpriseProvisioning } from '@expo/eas-json';
import { AppleTeamFragment, IosDistributionType } from '../../graphql/generated';
import Log from '../../log';
import { findAccountByName } from '../../user/Account';
import { CredentialsManager } from '../CredentialsManager';
import { CredentialsProvider } from '../CredentialsProvider';
import { Context } from '../context';
import * as credentialsJsonReader from '../credentialsJson/read';
import type { IosCredentials } from '../credentialsJson/read';
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
import { resolveAppleTeamIfAuthenticatedAsync } from './actions/new/AppleTeamUtils';
import { AppleTeamMissingError, MissingCredentialsNonInteractiveError } from './errors';
import { AppLookupParams } from './api/GraphqlClient';
import { isAdHocProfile } from './utils/provisioningProfile';
export { IosCredentials };
interface PartialIosCredentials {
provisioningProfile?: string;
distributionCertificate?: {
certP12?: string;
certPassword?: string;
};
}
export type { IosCredentials };
interface Options {
app: AppLookupParams;
distribution: iOSDistributionType;
distribution: IosDistributionType;
enterpriseProvisioning?: IosEnterpriseProvisioning;
skipCredentialsCheck?: boolean;
}
interface AppLookupParams {
projectName: string;
accountName: string;
bundleIdentifier: string;
}
export default class IosCredentialsProvider implements CredentialsProvider {
export default class IosCredentialsProvider {
public readonly platform = Platform.IOS;
constructor(private ctx: Context, private options: Options) {}
public async hasRemoteAsync(): Promise<boolean> {
// TODO: this is temporary
// remove this check when we implement syncing local credentials for internal distribution
if (this.options.distribution === 'internal' && (await this.hasLocalAsync())) {
return false;
}
const { distributionCertificate, provisioningProfile } = await this.fetchRemoteAsync();
return !!(
distributionCertificate?.certP12 ||
distributionCertificate?.certPassword ||
provisioningProfile
);
}
public async hasLocalAsync(): Promise<boolean> {
if (!(await credentialsJsonReader.fileExistsAsync(this.ctx.projectDir))) {
return false;
}
try {
const rawCredentialsJson = await credentialsJsonReader.readRawAsync(this.ctx.projectDir);
return !!rawCredentialsJson?.ios;
} catch (err) {
Log.error(err); // malformed json
return false;
}
}
public async isLocalSyncedAsync(): Promise<boolean> {
try {
const [remote, local] = await Promise.all([this.fetchRemoteAsync(), this.getLocalAsync()]);
const r = remote;
const l = local;
// TODO
// For now, when credentials.json contains credentials for multi-target project
// assume they are synced with the Expo servers.
// Change this behavior when we figure out how to store multi-target project credentials in the db.
if (credentialsJsonReader.isCredentialsMap(l)) {
return true;
}
return !!(
r &&
l &&
r.provisioningProfile === l.provisioningProfile &&
r.distributionCertificate?.certP12 === l.distributionCertificate.certP12 &&
r.distributionCertificate?.certPassword === l.distributionCertificate.certPassword
);
} catch (_) {
return false;
}
}
public async getCredentialsAsync(
src: CredentialsSource.LOCAL | CredentialsSource.REMOTE
): Promise<IosCredentials> {
switch (src) {
case CredentialsSource.LOCAL:
return await this.getLocalAsync();
case CredentialsSource.REMOTE:
return await this.getRemoteAsync();
if (src === CredentialsSource.LOCAL) {
return await this.getLocalAsync();
} else {
return await this.getRemoteAsync();
}
}
@@ -135,98 +63,18 @@ export default class IosCredentialsProvider implements CredentialsProvider {
}
private async getRemoteAsync(): Promise<IosCredentials> {
if (this.options.skipCredentialsCheck) {
Log.log('Skipping credentials check');
} else {
await new CredentialsManager(this.ctx).runActionAsync(
new SetupBuildCredentials(this.options.app, this.options.distribution)
);
}
const { distributionCertificate, provisioningProfile } = await this.fetchRemoteAsync();
if (!distributionCertificate?.certP12 || !distributionCertificate?.certPassword) {
if (this.options.skipCredentialsCheck) {
throw new Error(
'Distribution certificate is missing and credentials check was skipped. Run without --skip-credentials-check to set it up.'
);
} else {
throw new Error('Distribution certificate is missing');
}
}
if (!provisioningProfile) {
if (this.options.skipCredentialsCheck) {
throw new Error(
'Provisioning profile is missing and credentials check was skipped. Run without --skip-credentials-check to set it up.'
);
} else {
throw new Error('Provisioning profile is missing');
}
}
const manager = new CredentialsManager(this.ctx);
const { provisioningProfile, distributionCertificate } = await new SetupBuildCredentials({
app: this.options.app,
distribution: this.options.distribution,
enterpriseProvisioning: this.options.enterpriseProvisioning,
}).runAsync(manager, this.ctx);
return {
provisioningProfile,
distributionCertificate: {
certP12: distributionCertificate.certP12,
certPassword: distributionCertificate.certPassword,
certP12: distributionCertificate.certificateP12,
certPassword: distributionCertificate.certificatePassword,
},
};
}
private async fetchRemoteAsync(): Promise<PartialIosCredentials> {
if (this.options.distribution === 'internal') {
const { app } = this.options;
const account = findAccountByName(this.ctx.user.accounts, app.accountName);
if (!account) {
throw new Error(`You do not have access to the ${app.accountName} account`);
}
const appLookupParams = {
account,
bundleIdentifier: this.options.app.bundleIdentifier,
projectName: this.options.app.projectName,
};
let appleTeam: AppleTeamFragment | null = null;
if (!this.ctx.nonInteractive) {
await this.ctx.appStore.ensureAuthenticatedAsync();
appleTeam = await resolveAppleTeamIfAuthenticatedAsync(this.ctx, appLookupParams);
}
try {
const [distCert, provisioningProfile] = await Promise.all([
this.ctx.newIos.getDistributionCertificateForAppAsync(
appLookupParams,
IosDistributionType.AdHoc,
{ appleTeam }
),
this.ctx.newIos.getProvisioningProfileAsync(appLookupParams, IosDistributionType.AdHoc, {
appleTeam,
}),
]);
return {
provisioningProfile: provisioningProfile?.provisioningProfile ?? undefined,
distributionCertificate: {
certP12: distCert?.certificateP12 ?? undefined,
certPassword: distCert?.certificatePassword ?? undefined,
},
};
} catch (err) {
if (err instanceof AppleTeamMissingError && this.ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError();
}
throw err;
}
} else {
const [distCert, provisioningProfile] = await Promise.all([
this.ctx.ios.getDistributionCertificateAsync(this.options.app),
this.ctx.ios.getProvisioningProfileAsync(this.options.app),
]);
return {
provisioningProfile: provisioningProfile?.provisioningProfile,
distributionCertificate: {
certP12: distCert?.certP12,
certPassword: distCert?.certPassword,
},
};
}
}
}
@@ -0,0 +1,122 @@
import { CredentialsSource } from '@expo/eas-json';
import { vol } from 'memfs';
import { IosAppBuildCredentialsFragment } from '../../../graphql/generated';
import { getAppstoreMock } from '../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../__tests__/fixtures-context';
import {
getNewIosApiMockWithoutCredentials,
testIosAppCredentialsWithBuildCredentialsQueryResult,
} from '../../__tests__/fixtures-ios';
import IosCredentialsProvider from '../IosCredentialsProvider';
import { getAppLookupParamsFromContext } from '../actions/BuildCredentialsUtils';
jest.mock('fs');
jest.mock('../validators/validateProvisioningProfile', () => ({
validateProvisioningProfileAsync: async (
_ctx: any,
_app: any,
buildCredentials: Partial<IosAppBuildCredentialsFragment> | null
): Promise<boolean> => {
return !!(
buildCredentials &&
buildCredentials.distributionCertificate &&
buildCredentials.provisioningProfile
);
},
}));
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.log = jest.fn();
console.error = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
});
beforeEach(() => {
vol.reset();
});
describe(IosCredentialsProvider, () => {
describe('#getCredentialsAsync', () => {
describe('remote credentials', () => {
it('throws an error is credentials do not exist', async () => {
const ctx = createCtxMock({
nonInteractive: true,
appStore: getAppstoreMock(),
projectDir: '/app',
ios: {
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(() => null),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const provider = new IosCredentialsProvider(ctx, {
app: {
account: {
id: `id-${appLookupParams.account.name}`,
name: appLookupParams.account.name,
},
bundleIdentifier: appLookupParams.bundleIdentifier,
projectName: appLookupParams.projectName,
},
distribution: 'store',
});
await expect(provider.getCredentialsAsync(CredentialsSource.REMOTE)).rejects.toThrowError(
/Credentials are not set up/
);
});
it('returns credentials if they exist', async () => {
const ctx = createCtxMock({
nonInteractive: true,
appStore: getAppstoreMock(),
projectDir: '/app',
ios: {
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
() => testIosAppCredentialsWithBuildCredentialsQueryResult
),
getDistributionCertificateForAppAsync: jest.fn(
() =>
testIosAppCredentialsWithBuildCredentialsQueryResult.iosAppBuildCredentialsList[0]
.distributionCertificate
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const provider = new IosCredentialsProvider(ctx, {
app: {
account: {
id: `id-${appLookupParams.account.name}`,
name: appLookupParams.account.name,
},
bundleIdentifier: appLookupParams.bundleIdentifier,
projectName: appLookupParams.projectName,
},
distribution: 'store',
});
const buildCredentials =
testIosAppCredentialsWithBuildCredentialsQueryResult.iosAppBuildCredentialsList[0];
await expect(provider.getCredentialsAsync(CredentialsSource.REMOTE)).resolves.toMatchObject(
{
distributionCertificate: {
certP12: buildCredentials.distributionCertificate?.certificateP12,
certPassword: buildCredentials.distributionCertificate?.certificatePassword,
},
provisioningProfile: buildCredentials.provisioningProfile?.provisioningProfile,
}
);
});
});
});
});
@@ -1,6 +1,6 @@
import { AppleTeamFragment } from '../../../../graphql/generated';
import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
import { AppleTeamFragment } from '../../../graphql/generated';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
export async function resolveAppleTeamIfAuthenticatedAsync(
ctx: Context,
@@ -9,7 +9,7 @@ export async function resolveAppleTeamIfAuthenticatedAsync(
if (!ctx.appStore.authCtx) {
return null;
}
return await ctx.newIos.createOrGetExistingAppleTeamAsync(app, {
return await ctx.ios.createOrGetExistingAppleTeamAsync(app.account, {
appleTeamIdentifier: ctx.appStore.authCtx.team.id,
appleTeamName: ctx.appStore.authCtx.team.name,
});
@@ -0,0 +1,103 @@
import nullthrows from 'nullthrows';
import {
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
AppleTeamFragment,
IosDistributionType as GraphQLIosDistributionType,
IosAppBuildCredentialsFragment,
} from '../../../graphql/generated';
import { getProjectAccountName, getProjectConfigDescription } from '../../../project/projectUtils';
import { findAccountByName } from '../../../user/Account';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
export async function getAllBuildCredentialsAsync(
ctx: Context,
app: AppLookupParams
): Promise<IosAppBuildCredentialsFragment[]> {
const appCredentials = await ctx.ios.getIosAppCredentialsWithBuildCredentialsAsync(app, {});
if (!appCredentials) {
return [];
}
return appCredentials.iosAppBuildCredentialsList;
}
export async function getBuildCredentialsAsync(
ctx: Context,
app: AppLookupParams,
iosDistributionType: GraphQLIosDistributionType
): Promise<IosAppBuildCredentialsFragment | null> {
const appCredentials = await ctx.ios.getIosAppCredentialsWithBuildCredentialsAsync(app, {
iosDistributionType,
});
if (!appCredentials || appCredentials.iosAppBuildCredentialsList.length === 0) {
return null;
}
const [buildCredentials] = appCredentials.iosAppBuildCredentialsList;
return buildCredentials;
}
export async function getProvisioningProfileAsync(
ctx: Context,
app: AppLookupParams,
iosDistributionType: GraphQLIosDistributionType
): Promise<AppleProvisioningProfileFragment | null> {
const buildCredentials = await getBuildCredentialsAsync(ctx, app, iosDistributionType);
return buildCredentials?.provisioningProfile ?? null;
}
export async function getDistributionCertificateAsync(
ctx: Context,
app: AppLookupParams,
iosDistributionType: GraphQLIosDistributionType
): Promise<AppleDistributionCertificateFragment | null> {
const buildCredentials = await getBuildCredentialsAsync(ctx, app, iosDistributionType);
return buildCredentials?.distributionCertificate ?? null;
}
export async function assignBuildCredentialsAsync(
ctx: Context,
app: AppLookupParams,
iosDistributionType: GraphQLIosDistributionType,
distCert: AppleDistributionCertificateFragment,
provisioningProfile: AppleProvisioningProfileFragment,
appleTeam?: AppleTeamFragment
): Promise<IosAppBuildCredentialsFragment> {
const resolvedAppleTeam = nullthrows(
appleTeam ?? (await resolveAppleTeamIfAuthenticatedAsync(ctx, app))
);
const appleAppIdentifier = await ctx.ios.createOrGetExistingAppleAppIdentifierAsync(
app,
resolvedAppleTeam
);
return await ctx.ios.createOrUpdateIosAppBuildCredentialsAsync(app, {
appleTeam: resolvedAppleTeam,
appleAppIdentifierId: appleAppIdentifier.id,
appleDistributionCertificateId: distCert.id,
appleProvisioningProfileId: provisioningProfile.id,
iosDistributionType,
});
}
export function getAppLookupParamsFromContext(ctx: Context): AppLookupParams {
ctx.ensureProjectContext();
const projectName = ctx.exp.slug;
const accountName = getProjectAccountName(ctx.exp, ctx.user);
const account = findAccountByName(ctx.user.accounts, accountName);
if (!account) {
throw new Error(`You do not have access to account: ${accountName}`);
}
const bundleIdentifier = ctx.exp.ios?.bundleIdentifier;
if (!bundleIdentifier) {
throw new Error(
`ios.bundleIdentifier needs to be defined in your ${getProjectConfigDescription(
ctx.projectDir
)} file`
);
}
return { account, projectName, bundleIdentifier };
}
@@ -2,91 +2,109 @@ import assert from 'assert';
import chalk from 'chalk';
import ora from 'ora';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import {
DistributionCertificate,
ProvisioningProfileStoreInfo,
} from '../appstore/Credentials.types';
import { AppLookupParams } from '../credentials';
import { selectProvisioningProfileFromAppleAsync } from './ProvisioningProfileUtils';
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
import { AuthCtx } from '../appstore/authenticate';
import { MissingCredentialsNonInteractiveError } from '../errors';
export class UseExistingProvisioningProfile implements Action {
constructor(private app: AppLookupParams) {}
export class ConfigureProvisioningProfile {
constructor(
private app: AppLookupParams,
private distributionCertificate: AppleDistributionCertificateFragment,
private originalProvisioningProfile: AppleProvisioningProfileFragment
) {}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const selected = await selectProvisioningProfileFromAppleAsync(ctx, this.app.bundleIdentifier);
if (selected) {
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
assert(distCert, 'missing distribution certificate');
await manager.runActionAsync(new ConfigureProvisioningProfile(this.app));
}
}
}
export class ConfigureProvisioningProfile implements Action {
constructor(private app: AppLookupParams) {}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const profile = await ctx.ios.getProvisioningProfileAsync(this.app);
if (!profile) {
throw new Error('No provisioning profile to configure');
}
if (!profile.provisioningProfileId) {
Log.warn("The provisioning profile we have on file cannot be validated on Apple's servers.");
return;
}
if (ctx.appStore.authCtx) {
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
if (!distCert) {
Log.warn('There is no distribution certificate for this app.');
return;
}
const profilesFromApple = await ctx.appStore.listProvisioningProfilesAsync(
this.app.bundleIdentifier
public async runAsync(ctx: Context): Promise<AppleProvisioningProfileMutationResult | null> {
if (ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError(
'Configuring Provisioning Profiles is only supported in interactive mode.'
);
const profilesWithMatchingId = profilesFromApple.filter(
appleInfo => appleInfo.provisioningProfileId === profile.provisioningProfileId
);
if (!profilesWithMatchingId || profilesWithMatchingId.length < 1) {
Log.warn('This profile is no longer valid on Apple Developer Portal.');
}
}
const { developerPortalIdentifier, provisioningProfile } = this.originalProvisioningProfile;
if (!developerPortalIdentifier && !provisioningProfile) {
Log.warn("The provisioning profile we have on file cannot be configured on Apple's servers.");
return null;
}
await this.configureAndUpdateAsync(ctx, this.app, distCert, profilesWithMatchingId[0]);
} else {
if (!ctx.appStore.authCtx) {
Log.warn(
"Without access to your Apple account we can't configure provisioning profiles for you."
);
Log.warn('Make sure to recreate the profile if you selected a new distribution certificate.');
return null;
}
const profilesFromApple = await ctx.appStore.listProvisioningProfilesAsync(
this.app.bundleIdentifier
);
const [matchingProfile] = profilesFromApple.filter(appleInfo =>
developerPortalIdentifier
? appleInfo.provisioningProfileId === developerPortalIdentifier
: appleInfo.provisioningProfile === provisioningProfile
);
if (!matchingProfile) {
Log.warn(
`Profile ${
developerPortalIdentifier ? `${developerPortalIdentifier} ` : ''
}not found on Apple Developer Portal.`
);
return null;
}
return await this.configureAndUpdateAsync(ctx, ctx.appStore.authCtx, this.app, matchingProfile);
}
private async configureAndUpdateAsync(
ctx: Context,
authCtx: AuthCtx,
app: AppLookupParams,
distCert: DistributionCertificate,
profileFromApple: ProvisioningProfileStoreInfo
) {
): Promise<AppleProvisioningProfileMutationResult> {
const {
developerPortalIdentifier,
certificateP12,
certificatePassword,
serialNumber,
} = this.distributionCertificate;
assert(
certificateP12 && certificatePassword,
'Distribution Certificate P12 and Password is required'
);
// configure profile on Apple's Server to use our distCert
const updatedProfile = await ctx.appStore.useExistingProvisioningProfileAsync(
app.bundleIdentifier,
profileFromApple,
distCert
{
certId: developerPortalIdentifier ?? undefined,
certP12: certificateP12,
certPassword: certificatePassword,
distCertSerialNumber: serialNumber,
teamId: authCtx.appleId,
}
);
const bundleIdTag = `(${app.bundleIdentifier})`;
const slugTag = `@${app.accountName}/${app.projectName}`;
const slugTag = `@${app.account.name}/${app.projectName}`;
const projectTag = `${chalk.bold(slugTag)} ${chalk.dim(bundleIdTag)}`;
const spinner = ora(`Updating Expo profile for ${projectTag}`).start();
try {
// Update profile on EAS servers
await ctx.ios.updateProvisioningProfileAsync(app, updatedProfile);
const configuredProvisioningProfile = await ctx.ios.updateProvisioningProfileAsync(
this.originalProvisioningProfile.id,
{
appleProvisioningProfile: updatedProfile.provisioningProfile,
developerPortalIdentifier: updatedProfile.provisioningProfileId,
}
);
spinner.succeed(`Updated Expo profile for ${projectTag}`);
return configuredProvisioningProfile;
} catch (error) {
spinner.fail();
throw error;
@@ -1,45 +1,16 @@
import assert from 'assert';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Account } from '../../../user/Account';
import { Context } from '../../context';
import { IosDistCredentials } from '../credentials';
import { displayIosUserCredentials } from '../utils/printCredentials';
import { AppleDistributionCertificateMutationResult } from '../api/graphql/mutations/AppleDistributionCertificateMutation';
import { provideOrGenerateDistributionCertificateAsync } from './DistributionCertificateUtils';
export class CreateDistributionCertificateStandaloneManager implements Action {
constructor(private accountName: string) {}
export class CreateDistributionCertificate {
constructor(private account: Account) {}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const action = new CreateDistributionCertificate(this.accountName);
await manager.runActionAsync(action);
Log.newLine();
displayIosUserCredentials(action.distCredentials);
Log.newLine();
}
}
export class CreateDistributionCertificate implements Action {
private _distCredentials?: IosDistCredentials;
constructor(private accountName: string) {}
public get distCredentials(): IosDistCredentials {
assert(this._distCredentials, 'distCredentials can be accessed only after runAsync');
return this._distCredentials;
}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const distCert = await provideOrGenerateDistributionCertificateAsync(
manager,
ctx,
this.accountName
);
this._distCredentials = await ctx.ios.createDistributionCertificateAsync(
this.accountName,
distCert
);
public async runAsync(ctx: Context): Promise<AppleDistributionCertificateMutationResult> {
const distCert = await provideOrGenerateDistributionCertificateAsync(ctx, this.account.name);
const result = await ctx.ios.createDistributionCertificateAsync(this.account, distCert);
Log.succeed('Created distribution certificate');
return result;
}
}
@@ -1,45 +1,74 @@
import assert from 'assert';
import nullthrows from 'nullthrows';
import { AppleDistributionCertificateFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
import { AppLookupParams } from '../api/GraphqlClient';
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
import { ProvisioningProfile } from '../appstore/Credentials.types';
import { AppLookupParams, provisioningProfileSchema } from '../credentials';
import { displayIosAppCredentials } from '../utils/printCredentials';
import { AuthCtx } from '../appstore/authenticate';
import { provisioningProfileSchema } from '../credentials';
import { MissingCredentialsNonInteractiveError } from '../errors';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
import { generateProvisioningProfileAsync } from './ProvisioningProfileUtils';
export class CreateProvisioningProfileStandaloneManager implements Action {
constructor(private app: AppLookupParams) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
await manager.runActionAsync(new CreateProvisioningProfile(this.app));
const appCredentials = await ctx.ios.getAppCredentialsAsync(this.app);
displayIosAppCredentials(appCredentials);
Log.newLine();
}
}
export class CreateProvisioningProfile implements Action {
constructor(private app: AppLookupParams) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const provisioningProfile = await this.provideOrGenerateAsync(ctx);
await ctx.ios.updateProvisioningProfileAsync(this.app, provisioningProfile);
export class CreateProvisioningProfile {
constructor(
private app: AppLookupParams,
private distributionCertificate: AppleDistributionCertificateFragment
) {}
async runAsync(ctx: Context): Promise<AppleProvisioningProfileMutationResult> {
if (ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError(
'Creating Provisioning Profiles is only supported in interactive mode.'
);
}
const appleAuthCtx = await ctx.appStore.ensureAuthenticatedAsync();
const provisioningProfile = await this.provideOrGenerateAsync(ctx, appleAuthCtx);
const appleTeam = nullthrows(await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app));
const appleAppIdentifier = await ctx.ios.createOrGetExistingAppleAppIdentifierAsync(
this.app,
appleTeam
);
const provisioningProfileMutationResult = await ctx.ios.createProvisioningProfileAsync(
this.app,
appleAppIdentifier,
{
appleProvisioningProfile: provisioningProfile.provisioningProfile,
developerPortalIdentifier: provisioningProfile.provisioningProfileId,
}
);
Log.succeed('Created provisioning profile');
return provisioningProfileMutationResult;
}
private async provideOrGenerateAsync(ctx: Context): Promise<ProvisioningProfile> {
private async provideOrGenerateAsync(
ctx: Context,
appleAuthCtx: AuthCtx
): Promise<ProvisioningProfile> {
const userProvided = await askForUserProvidedAsync(provisioningProfileSchema);
if (userProvided) {
// userProvided profiles don't come with ProvisioningProfileId's (only accessible from Apple Portal API)
Log.warn('Provisioning profile: Unable to validate specified profile.');
return userProvided;
}
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
assert(distCert, 'missing distribution certificate');
return await generateProvisioningProfileAsync(ctx, this.app.bundleIdentifier, distCert);
assert(
this.distributionCertificate.certificateP12,
'Distribution Certificate must have p12 certificate'
);
assert(
this.distributionCertificate.certificatePassword,
'Distribution Certificate must have certificate password'
);
return await generateProvisioningProfileAsync(ctx, this.app.bundleIdentifier, {
certId: this.distributionCertificate.developerPortalIdentifier ?? undefined,
certP12: this.distributionCertificate.certificateP12,
certPassword: this.distributionCertificate.certificatePassword,
distCertSerialNumber: this.distributionCertificate.serialNumber,
teamId: this.distributionCertificate.appleTeam?.appleTeamIdentifier ?? appleAuthCtx.appleId,
});
}
}
@@ -1,14 +1,13 @@
import { AppleDevice } from '../../../../graphql/generated';
import { APPLE_DEVICE_CLASS_LABELS } from '../../../../graphql/types/credentials/AppleDevice';
import { promptAsync } from '../../.././../prompts';
import { AppleDeviceFragmentWithAppleTeam } from '../../api/graphql/queries/AppleDeviceQuery';
import { AppleDevice, AppleDeviceFragment } from '../../../graphql/generated';
import { APPLE_DEVICE_CLASS_LABELS } from '../../../graphql/types/credentials/AppleDevice';
import { promptAsync } from '../.././../prompts';
export async function chooseDevices(
allDevices: AppleDeviceFragmentWithAppleTeam[],
allDevices: AppleDeviceFragment[],
preselectedDeviceIdentifiers: string[] = []
): Promise<AppleDevice[]> {
const preselectedDeviceIdentifierSet = new Set(preselectedDeviceIdentifiers);
const isSelected = (device: AppleDeviceFragmentWithAppleTeam) =>
const isSelected = (device: AppleDeviceFragment) =>
preselectedDeviceIdentifierSet.size === 0 ||
preselectedDeviceIdentifierSet.has(device.identifier);
const { devices } = await promptAsync({
@@ -27,12 +26,12 @@ export async function chooseDevices(
return devices;
}
function formatDeviceLabel(device: AppleDeviceFragmentWithAppleTeam): string {
export function formatDeviceLabel(device: AppleDeviceFragment): string {
const deviceDetails = formatDeviceDetails(device);
return `${device.name ?? device.identifier}${deviceDetails !== '' ? ` ${deviceDetails}` : ''}`;
}
function formatDeviceDetails(device: AppleDeviceFragmentWithAppleTeam): string {
function formatDeviceDetails(device: AppleDeviceFragment): string {
let details = '';
if (device.deviceClass) {
details += APPLE_DEVICE_CLASS_LABELS[device.deviceClass];
@@ -1,27 +1,154 @@
import assert from 'assert';
import chalk from 'chalk';
import dateformat from 'dateformat';
import {
AppleDistributionCertificateFragment,
AppleTeamFragment,
} from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
import { promptAsync } from '../../../prompts';
import { CredentialsManager } from '../../CredentialsManager';
import { Account } from '../../../user/Account';
import { fromNow } from '../../../utils/date';
import { Context } from '../../context';
import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
import { AppLookupParams } from '../api/GraphqlClient';
import { AppleTooManyCertsError } from '../appstore/AppStoreApi';
import {
DistributionCertificate,
DistributionCertificateStoreInfo,
} from '../appstore/Credentials.types';
import {
filterRevokedDistributionCerts,
sortCertificatesByExpiryDesc,
} from '../appstore/CredentialsUtils';
import {
IosAppCredentials,
IosDistCredentials,
distributionCertificateSchema,
} from '../credentials';
import { findP12CertSerialNumber } from '../utils/p12Certificate';
import { filterRevokedDistributionCertsFromEasServers } from '../appstore/CredentialsUtils';
import { distributionCertificateSchema } from '../credentials';
import { validateDistributionCertificateAsync } from '../validators/validateDistributionCertificate';
export function formatDistributionCertificate(
distributionCertificate: AppleDistributionCertificateFragment,
validSerialNumbers?: string[]
): string {
const {
serialNumber,
developerPortalIdentifier,
appleTeam,
validityNotBefore,
validityNotAfter,
updatedAt,
} = distributionCertificate;
let line: string = '';
if (developerPortalIdentifier) {
line += `Cert ID: ${developerPortalIdentifier}`;
}
line += `${line === '' ? '' : ', '}Serial number: ${serialNumber}${
appleTeam ? `, ${formatAppleTeam(appleTeam)}` : ''
}`;
line += chalk.gray(
`\n Created: ${fromNow(new Date(validityNotBefore))} ago, Updated: ${fromNow(
new Date(updatedAt)
)} ago,`
);
line += chalk.gray(`\n Expires: ${dateformat(validityNotAfter, 'expiresHeaderFormat')}`);
const apps = distributionCertificate.iosAppBuildCredentialsList.map(
buildCredentials => buildCredentials.iosAppCredentials.app
);
if (apps.length) {
const appFullNames = apps.map(app => app.fullName).join(',');
line += chalk.gray(`\n 📲 Used by: ${appFullNames}`);
}
if (validSerialNumbers?.includes(serialNumber)) {
line += chalk.gray("\n ✅ Currently valid on Apple's servers.");
} else {
line += '';
}
return line;
}
function formatAppleTeam({ appleTeamIdentifier, appleTeamName }: AppleTeamFragment): string {
return `Team ID: ${appleTeamIdentifier}${appleTeamName ? `, Team name: ${appleTeamName}` : ''}`;
}
async function _selectDistributionCertificateAsync(
distCerts: AppleDistributionCertificateFragment[],
validDistributionCertificates?: AppleDistributionCertificateFragment[]
): Promise<AppleDistributionCertificateFragment | null> {
const validDistCertSerialNumbers = validDistributionCertificates?.map(
distCert => distCert.serialNumber
);
const { chosenDistCert } = await promptAsync({
type: 'select',
name: 'chosenDistCert',
message: 'Select certificate from the list.',
choices: distCerts.map(distCert => ({
title: formatDistributionCertificate(distCert, validDistCertSerialNumbers),
value: distCert,
})),
});
return chosenDistCert;
}
/**
* select a distribution certificate from an account (validity status shown on a best effort basis)
* */
export async function selectDistributionCertificateWithDependenciesAsync(
ctx: Context,
account: Account
): Promise<AppleDistributionCertificateFragment | null> {
const distCertsForAccount = await ctx.ios.getDistributionCertificatesForAccountAsync(account);
if (distCertsForAccount.length === 0) {
Log.warn(`There are no Distribution Certificates available in your EAS account.`);
return null;
}
if (!ctx.appStore.authCtx) {
return _selectDistributionCertificateAsync(distCertsForAccount);
}
// get valid certs on the developer portal
const certInfoFromApple = await ctx.appStore.listDistributionCertificatesAsync();
const validDistCerts = await filterRevokedDistributionCertsFromEasServers(
distCertsForAccount,
certInfoFromApple
);
return _selectDistributionCertificateAsync(distCertsForAccount, validDistCerts);
}
/**
* select a distribution certificate from a valid set (curated on a best effort basis)
* */
export async function selectValidDistributionCertificateAsync(
ctx: Context,
appLookupParams: AppLookupParams
): Promise<AppleDistributionCertificateFragment | null> {
const distCertsForAccount = await ctx.ios.getDistributionCertificatesForAccountAsync(
appLookupParams.account
);
if (distCertsForAccount.length === 0) {
Log.warn(`There are no Distribution Certificates available in your EAS account.`);
return null;
}
if (!ctx.appStore.authCtx) {
return _selectDistributionCertificateAsync(distCertsForAccount);
}
// filter by apple team
assert(ctx.appStore.authCtx, 'authentication to the Apple App Store is required');
const appleTeamIdentifier = ctx.appStore.authCtx.team.id;
const distCertsForAppleTeam = distCertsForAccount.filter(distCert => {
return !distCert.appleTeam || distCert.appleTeam.appleTeamIdentifier === appleTeamIdentifier;
});
// filter by valid certs on the developer portal
const certInfoFromApple = await ctx.appStore.listDistributionCertificatesAsync();
const validDistCerts = await filterRevokedDistributionCertsFromEasServers(
distCertsForAppleTeam,
certInfoFromApple
);
Log.log(
`${validDistCerts.length}/${distCertsForAccount.length} Distribution Certificates are currently valid for Apple Team ${ctx.appStore.authCtx?.team.id}.`
);
return _selectDistributionCertificateAsync(validDistCerts);
}
const APPLE_DIST_CERTS_TOO_MANY_GENERATED_ERROR = `
You can have only ${chalk.underline(
'three'
@@ -31,7 +158,6 @@ Please remember that Apple Distribution Certificates are not application specifi
`;
export async function provideOrGenerateDistributionCertificateAsync(
manager: CredentialsManager,
ctx: Context,
accountName: string
): Promise<DistributionCertificate> {
@@ -50,11 +176,11 @@ export async function provideOrGenerateDistributionCertificateAsync(
}
return isValid
? userProvided
: await provideOrGenerateDistributionCertificateAsync(manager, ctx, accountName);
: await provideOrGenerateDistributionCertificateAsync(ctx, accountName);
}
}
}
return await generateDistributionCertificateAsync(manager, ctx, accountName);
return await generateDistributionCertificateAsync(ctx, accountName);
}
async function promptForDistCertAsync(ctx: Context): Promise<DistributionCertificate | null> {
@@ -78,7 +204,6 @@ async function promptForDistCertAsync(ctx: Context): Promise<DistributionCertifi
}
async function generateDistributionCertificateAsync(
manager: CredentialsManager,
ctx: Context,
accountName: string
): Promise<DistributionCertificate> {
@@ -125,101 +250,7 @@ async function generateDistributionCertificateAsync(
throw e;
}
}
return await generateDistributionCertificateAsync(manager, ctx, accountName);
}
interface SelectOptions {
filterInvalid?: boolean;
}
type ValidityStatus = 'UNKNOWN' | 'VALID' | 'INVALID';
export async function selectDistributionCertificateAsync(
ctx: Context,
accountName: string,
options: SelectOptions = {}
): Promise<IosDistCredentials | null> {
const credentials = await ctx.ios.getAllCredentialsAsync(accountName);
let distCredentials = credentials.userCredentials.filter(
(cred): cred is IosDistCredentials => cred.type === 'dist-cert'
);
let validDistCredentials: IosDistCredentials[] | null = null;
if (ctx.appStore.authCtx) {
const certInfoFromApple = await ctx.appStore.listDistributionCertificatesAsync();
validDistCredentials = await filterRevokedDistributionCerts(distCredentials, certInfoFromApple);
}
distCredentials =
options.filterInvalid && validDistCredentials ? validDistCredentials : distCredentials;
if (distCredentials.length === 0) {
Log.warn('There are no Distribution Certificates available in your Expo account.');
return null;
}
const format = (distCredentials: IosDistCredentials): string => {
const usedByApps = credentials.appCredentials.filter(
cred => cred.distCredentialsId === distCredentials.id
);
let validityStatus: ValidityStatus;
if (!validDistCredentials) {
validityStatus = 'UNKNOWN';
} else if (validDistCredentials.includes(distCredentials)) {
validityStatus = 'VALID';
} else {
validityStatus = 'INVALID';
}
return formatDistributionCertificate(distCredentials, usedByApps, validityStatus);
};
const { credentialsIndex } = await promptAsync({
type: 'select',
name: 'credentialsIndex',
message: 'Select certificate from the list.',
choices: distCredentials.map((entry, index) => ({
title: format(entry),
value: index,
})),
});
return distCredentials[credentialsIndex];
}
export function formatDistributionCertificate(
distributionCertificate: IosDistCredentials,
usedByApps: IosAppCredentials[],
validityStatus: ValidityStatus = 'UNKNOWN'
): string {
const joinApps = usedByApps.map(i => `${i.experienceName} (${i.bundleIdentifier})`).join(', ');
const usedByString = joinApps
? `\n ${chalk.gray(`used by ${joinApps}`)}`
: `\n ${chalk.gray(`not used by any apps`)}`;
let serialNumber = distributionCertificate.distCertSerialNumber;
try {
if (!serialNumber) {
serialNumber = findP12CertSerialNumber(
distributionCertificate.certP12,
distributionCertificate.certPassword
);
}
} catch (error) {
serialNumber = chalk.red('invalid serial number');
}
let validityText;
if (validityStatus === 'VALID') {
validityText = chalk.gray("\n ✅ Currently valid on Apple's servers.");
} else if (validityStatus === 'INVALID') {
validityText = chalk.gray("\n ❌ No longer valid on Apple's servers.");
} else {
validityText = chalk.gray(
"\n ❓ Validity of this certificate on Apple's servers is unknown."
);
}
return `Distribution Certificate (Cert ID: ${
distributionCertificate.certId || '-----'
}, Serial number: ${serialNumber}, Team ID: ${
distributionCertificate.teamId
})${usedByString}${validityText}`;
return await generateDistributionCertificateAsync(ctx, accountName);
}
function formatDistributionCertificateFromApple(
@@ -231,20 +262,3 @@ function formatDistributionCertificateFromApple(
return `${name} (${status}) - Cert ID: ${id}, Serial number: ${serialNumber}, Team ID: ${appleInfo.ownerId}, Team name: ${ownerName}
expires: ${expiresDate}, created: ${createdDate}`;
}
export async function getValidDistCertsAsync(
ctx: Context,
accountName: string
): Promise<IosDistCredentials[]> {
const credentials = await ctx.ios.getAllCredentialsAsync(accountName);
const distCredentials = credentials.userCredentials.filter(
(cred): cred is IosDistCredentials => cred.type === 'dist-cert'
);
if (!ctx.appStore.authCtx) {
Log.log(chalk.yellow(`Unable to determine validity of Distribution Certificates.`));
return distCredentials;
}
const certInfoFromApple = await ctx.appStore.listDistributionCertificatesAsync();
const validCerts = await filterRevokedDistributionCerts(distCredentials, certInfoFromApple);
return sortCertificatesByExpiryDesc(certInfoFromApple, validCerts);
}
@@ -1,38 +1,11 @@
import chalk from 'chalk';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { Context } from '../../context';
import {
DistributionCertificate,
ProvisioningProfile,
ProvisioningProfileStoreInfo,
} from '../appstore/Credentials.types';
import { IosAppCredentials } from '../credentials';
export async function selectProvisioningProfileFromAppleAsync(
ctx: Context,
bundleIdentifier: string
): Promise<ProvisioningProfileStoreInfo | null> {
const profiles = await ctx.appStore.listProvisioningProfilesAsync(bundleIdentifier);
if (profiles.length === 0) {
Log.warn(
`There are no provisioning profiles available on Apple Developer Portal for bundle identifier ${bundleIdentifier}.`
);
return null;
}
const { credentialsIndex } = await promptAsync({
type: 'select',
name: 'credentialsIndex',
message: 'Select provisioning profile from the list.',
choices: profiles.map((entry, index) => ({
title: formatProvisioningProfileFromApple(entry),
value: index,
})),
});
return profiles[credentialsIndex];
}
export function formatProvisioningProfileFromApple(
appleInfo: ProvisioningProfileStoreInfo
@@ -45,36 +18,6 @@ export function formatProvisioningProfileFromApple(
return `Provisioning Profile - ID: ${id}${details}`;
}
export async function selectProvisioningProfileFromExpoAsync(
ctx: Context,
accountName: string
): Promise<IosAppCredentials | null> {
const profiles = (await ctx.ios.getAllCredentialsAsync(accountName)).appCredentials.filter(
({ credentials }) => !!credentials.provisioningProfile && !!credentials.provisioningProfileId
);
if (profiles.length === 0) {
Log.warn('There are no provisioning profiles available in your Expo account.');
return null;
}
const format = (profile: IosAppCredentials) => {
const id = chalk.green(profile.credentials.provisioningProfileId || '-----');
const teamId = profile.credentials.teamId || '------';
return `Provisioning Profile (ID: ${id}, Team ID: ${teamId})`;
};
const { credentialsIndex } = await promptAsync({
type: 'select',
name: 'credentialsIndex',
message: 'Select provisioning profile from the list.',
choices: profiles.map((entry, index) => ({
title: format(entry),
value: index,
})),
});
return profiles[credentialsIndex];
}
export async function generateProvisioningProfileAsync(
ctx: Context,
bundleIdentifier: string,
@@ -1,57 +1,47 @@
import chalk from 'chalk';
import {
AppleDistributionCertificateFragment,
AppleProvisioningProfileIdentifiersFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Account } from '../../../user/Account';
import { Context } from '../../context';
import { IosAppCredentials, getAppLookupParams } from '../credentials';
import { selectDistributionCertificateAsync } from './DistributionCertificateUtils';
import { RemoveSpecificProvisioningProfile } from './RemoveProvisioningProfile';
import { AppLookupParams } from '../api/GraphqlClient';
import { selectDistributionCertificateWithDependenciesAsync } from './DistributionCertificateUtils';
import { RemoveProvisioningProfiles } from './RemoveProvisioningProfile';
interface RemoveOptions {
shouldRevoke?: boolean;
}
export class SelectAndRemoveDistributionCertificate {
constructor(private account: Account) {}
export class RemoveDistributionCertificate implements Action {
constructor(private accountName: string, private options: RemoveOptions = {}) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const selected = await selectDistributionCertificateAsync(ctx, this.accountName);
if (selected?.id) {
await manager.runActionAsync(
new RemoveSpecificDistributionCertificate(selected?.id, this.accountName, this.options)
);
async runAsync(ctx: Context): Promise<void> {
const selected = await selectDistributionCertificateWithDependenciesAsync(ctx, this.account);
if (selected) {
await new RemoveDistributionCertificate(this.account, selected).runAsync(ctx);
Log.succeed('Removed distribution certificate');
Log.newLine();
}
}
}
interface RemoveSpecificOptions {
shouldRevoke?: boolean;
}
export class RemoveSpecificDistributionCertificate implements Action {
export class RemoveDistributionCertificate {
constructor(
private userCredentialsId: number | string,
private accountName: string,
private options: RemoveSpecificOptions = {}
private account: Account,
private distributionCertificate: AppleDistributionCertificateFragment
) {}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const distributionCertificate = await ctx.ios.getDistributionCertificateByIdAsync(
this.userCredentialsId,
this.accountName
public async runAsync(ctx: Context): Promise<void> {
const apps = this.distributionCertificate.iosAppBuildCredentialsList.map(
buildCredentials => buildCredentials.iosAppCredentials.app
);
const credentials = await ctx.ios.getAllCredentialsAsync(this.accountName);
const apps = credentials.appCredentials.filter(
cred => cred.distCredentialsId === this.userCredentialsId
);
const appList = apps.map(appCred => chalk.green(appCred.experienceName)).join(', ');
if (appList && !ctx.nonInteractive) {
if (apps.length !== 0) {
const appFullNames = apps.map(app => app.fullName).join(',');
if (ctx.nonInteractive) {
throw new Error(
`Certificate is currently used by ${appFullNames} and cannot be deleted in non-interactive mode.`
);
}
const confirm = await confirmAsync({
message: `You are removing certificate used by ${appList}. Do you want to continue?`,
message: `You are removing certificate used by ${appFullNames}. Do you want to continue?`,
});
if (!confirm) {
Log.log('Aborting');
@@ -60,43 +50,41 @@ export class RemoveSpecificDistributionCertificate implements Action {
}
Log.log('Removing Distribution Certificate');
await ctx.ios.deleteDistributionCertificateAsync(this.userCredentialsId, this.accountName);
await ctx.ios.deleteDistributionCertificateAsync(this.distributionCertificate.id);
let shouldRevoke = this.options.shouldRevoke;
if (distributionCertificate.certId) {
if (!shouldRevoke && !ctx.nonInteractive) {
if (this.distributionCertificate.developerPortalIdentifier) {
let shouldRevoke = false;
if (!ctx.nonInteractive) {
shouldRevoke = await confirmAsync({
message: `Do you also want to revoke this Distribution Certificate on Apple Developer Portal?`,
});
} else if (ctx.nonInteractive) {
Log.log('Skipping certificate revocation on the Apple Developer Portal.');
}
if (shouldRevoke) {
await ctx.appStore.revokeDistributionCertificateAsync([distributionCertificate.certId]);
await ctx.appStore.revokeDistributionCertificateAsync([
this.distributionCertificate.developerPortalIdentifier,
]);
}
}
await this.removeInvalidProvisioningProfilesAsync(manager, ctx, apps, shouldRevoke);
await this.removeInvalidProvisioningProfilesAsync(ctx);
}
private async removeInvalidProvisioningProfilesAsync(
manager: CredentialsManager,
ctx: Context,
apps: IosAppCredentials[],
shouldRevoke?: boolean
) {
for (const appCredentials of apps) {
const appLookupParams = getAppLookupParams(
appCredentials.experienceName,
appCredentials.bundleIdentifier
);
if (!(await ctx.ios.getProvisioningProfileAsync(appLookupParams))) {
continue;
private async removeInvalidProvisioningProfilesAsync(ctx: Context): Promise<void> {
const buildCredentialsList = this.distributionCertificate.iosAppBuildCredentialsList;
const appsWithProfilesToRemove: AppLookupParams[] = [];
const profilesToRemove: AppleProvisioningProfileIdentifiersFragment[] = [];
for (const buildCredentials of buildCredentialsList) {
const projectName = buildCredentials.iosAppCredentials.app.slug;
const { bundleIdentifier } = buildCredentials.iosAppCredentials.appleAppIdentifier;
const appLookupParams = { account: this.account, projectName, bundleIdentifier };
const maybeProvisioningProfile = buildCredentials.provisioningProfile;
if (maybeProvisioningProfile) {
appsWithProfilesToRemove.push(appLookupParams);
profilesToRemove.push(maybeProvisioningProfile);
}
Log.log(
`Removing Provisioning Profile for ${appCredentials.experienceName} (${appCredentials.bundleIdentifier})`
);
await manager.runActionAsync(
new RemoveSpecificProvisioningProfile(appLookupParams, { shouldRevoke })
);
}
await new RemoveProvisioningProfiles(appsWithProfilesToRemove, profilesToRemove).runAsync(ctx);
}
}
@@ -1,48 +1,32 @@
import { assert } from '@expo/config';
import { AppleProvisioningProfileIdentifiersFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { AppLookupParams, getAppLookupParams } from '../credentials';
import { selectProvisioningProfileFromExpoAsync } from './ProvisioningProfileUtils';
import { AppLookupParams } from '../api/GraphqlClient';
interface RemoveOptions {
shouldRevoke?: boolean;
}
export class RemoveProvisioningProfile implements Action {
constructor(private accountName: string, private options: RemoveOptions = {}) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const selected = await selectProvisioningProfileFromExpoAsync(ctx, this.accountName);
if (selected) {
const app = getAppLookupParams(selected.experienceName, selected.bundleIdentifier);
await manager.runActionAsync(new RemoveSpecificProvisioningProfile(app, this.options));
}
}
}
interface RemoveSpecificOptions {
shouldRevoke?: boolean;
}
export class RemoveSpecificProvisioningProfile implements Action {
constructor(private app: AppLookupParams, private options: RemoveSpecificOptions = {}) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
await ctx.ios.deleteProvisioningProfileAsync(this.app);
Log.succeed(
`Removed provisioning profile for @${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`
export class RemoveProvisioningProfiles {
constructor(
private apps: AppLookupParams[],
private provisioningProfiles: AppleProvisioningProfileIdentifiersFragment[]
) {
assert(
apps.length === provisioningProfiles.length,
'apps must correspond to the provisioning profiles being removed'
);
}
let { shouldRevoke } = this.options;
if (!shouldRevoke && !ctx.nonInteractive) {
shouldRevoke = await confirmAsync({
message: 'Do you also want to revoke this provisioning profile on Apple Developer Portal?',
});
}
if (shouldRevoke) {
await ctx.appStore.revokeProvisioningProfileAsync(this.app.bundleIdentifier);
async runAsync(ctx: Context): Promise<void> {
if (this.provisioningProfiles.length === 0) {
Log.log(`Skipping deletion of Provisioning Profiles`);
return;
}
await ctx.ios.deleteProvisioningProfilesAsync(
this.provisioningProfiles.map(profile => profile.id)
);
const appAndBundles = this.apps
.map(app => `@${app.account.name}/${app.projectName} (${app.bundleIdentifier})`)
.join(',');
Log.succeed(`Successfully removed provisioning profiles for ${appAndBundles}`);
}
}
@@ -0,0 +1,234 @@
import assert from 'assert';
import chalk from 'chalk';
import differenceBy from 'lodash/differenceBy';
import isEqual from 'lodash/isEqual';
import nullthrows from 'nullthrows';
import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action';
import {
AppleDeviceFragment,
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
AppleTeamFragment,
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync, pressAnyKeyToContinueAsync } from '../../../prompts';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
import { MissingCredentialsNonInteractiveError } from '../errors';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from './BuildCredentialsUtils';
import { chooseDevices, formatDeviceLabel } from './DeviceUtils';
import { SetupDistributionCertificate } from './SetupDistributionCertificate';
export class SetupAdhocProvisioningProfile {
constructor(private app: AppLookupParams) {}
async runAsync(ctx: Context): Promise<IosAppBuildCredentialsFragment> {
const distCert = await new SetupDistributionCertificate(
this.app,
IosDistributionType.AdHoc
).runAsync(ctx);
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
if (ctx.nonInteractive) {
if (areBuildCredentialsSetup) {
return nullthrows(await getBuildCredentialsAsync(ctx, this.app, IosDistributionType.AdHoc));
} else {
throw new MissingCredentialsNonInteractiveError(
'Provisioning profile is not configured correctly. Please run this command again in interactive mode.'
);
}
}
const currentBuildCredentials = await getBuildCredentialsAsync(
ctx,
this.app,
IosDistributionType.AdHoc
);
if (areBuildCredentialsSetup) {
const buildCredentials = nullthrows(currentBuildCredentials);
if (await this.shouldUseExistingProfileAsync(ctx, buildCredentials)) {
return buildCredentials;
}
}
return await this.runWithDistributionCertificateAsync(ctx, distCert);
}
async runWithDistributionCertificateAsync(
ctx: Context,
distCert: AppleDistributionCertificateFragment
): Promise<IosAppBuildCredentialsFragment> {
const currentBuildCredentials = await getBuildCredentialsAsync(
ctx,
this.app,
IosDistributionType.AdHoc
);
// 1. Resolve Apple Team
let appleTeam: AppleTeamFragment | null =
distCert.appleTeam ?? currentBuildCredentials?.provisioningProfile?.appleTeam ?? null;
if (!appleTeam) {
await ctx.appStore.ensureAuthenticatedAsync();
appleTeam = await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app);
}
assert(appleTeam, 'Apple Team must be defined here');
// 2. Fetch devices registered on EAS servers
let registeredAppleDevices = await ctx.ios.getDevicesForAppleTeamAsync(this.app, appleTeam);
if (registeredAppleDevices.length === 0) {
const shouldRegisterDevices = await confirmAsync({
message: `You don't have any registered devices yet. Would you like to register them now?`,
initial: true,
});
if (shouldRegisterDevices) {
registeredAppleDevices = await this.registerDevicesAsync(ctx, appleTeam);
} else {
throw new Error(`Run 'eas device:create' to register your devices first`);
}
}
// 3. Choose devices for internal distribution
const provisionedDeviceIdentifiers = (
currentBuildCredentials?.provisioningProfile?.appleDevices ?? []
).map(i => i.identifier);
const chosenDevices = await chooseDevices(registeredAppleDevices, provisionedDeviceIdentifiers);
// 4. Reuse or create the profile on Apple Developer Portal
const provisioningProfileStoreInfo = await ctx.appStore.createOrReuseAdhocProvisioningProfileAsync(
chosenDevices.map(({ identifier }) => identifier),
this.app.bundleIdentifier,
distCert.serialNumber
);
// 5. Create or update the profile on servers
const appleAppIdentifier = await ctx.ios.createOrGetExistingAppleAppIdentifierAsync(
this.app,
appleTeam
);
let appleProvisioningProfile: AppleProvisioningProfileFragment | null = null;
if (currentBuildCredentials?.provisioningProfile) {
if (
currentBuildCredentials.provisioningProfile.developerPortalIdentifier !==
provisioningProfileStoreInfo.provisioningProfileId
) {
await ctx.ios.deleteProvisioningProfilesAsync([
currentBuildCredentials.provisioningProfile.id,
]);
appleProvisioningProfile = await ctx.ios.createProvisioningProfileAsync(
this.app,
appleAppIdentifier,
{
appleProvisioningProfile: provisioningProfileStoreInfo.provisioningProfile,
developerPortalIdentifier: provisioningProfileStoreInfo.provisioningProfileId,
}
);
} else {
appleProvisioningProfile = currentBuildCredentials.provisioningProfile;
}
} else {
appleProvisioningProfile = await ctx.ios.createProvisioningProfileAsync(
this.app,
appleAppIdentifier,
{
appleProvisioningProfile: provisioningProfileStoreInfo.provisioningProfile,
developerPortalIdentifier: provisioningProfileStoreInfo.provisioningProfileId,
}
);
}
// 6. Create (or update) app build credentials
assert(appleProvisioningProfile);
return await assignBuildCredentialsAsync(
ctx,
this.app,
IosDistributionType.AdHoc,
distCert,
appleProvisioningProfile,
appleTeam
);
}
private async areBuildCredentialsSetupAsync(ctx: Context): Promise<boolean> {
const buildCredentials = await getBuildCredentialsAsync(
ctx,
this.app,
IosDistributionType.AdHoc
);
return await validateProvisioningProfileAsync(ctx, this.app, buildCredentials);
}
private async shouldUseExistingProfileAsync(
ctx: Context,
buildCredentials: IosAppBuildCredentialsFragment
): Promise<boolean> {
const provisioningProfile = nullthrows(buildCredentials.provisioningProfile);
const appleTeam = nullthrows(provisioningProfile.appleTeam);
const registeredAppleDevices = await ctx.ios.getDevicesForAppleTeamAsync(this.app, appleTeam);
const provisionedDevices = provisioningProfile.appleDevices;
const allRegisteredDevicesAreProvisioned = doUDIDsMatch(
registeredAppleDevices.map(({ identifier }) => identifier),
provisionedDevices.map(({ identifier }) => identifier)
);
if (allRegisteredDevicesAreProvisioned) {
return await confirmAsync({
message: `All your registered devices are present in the Provisioning Profile. Would you like to reuse it?`,
initial: true,
});
} else {
const missingDevices = differenceBy(registeredAppleDevices, provisionedDevices, 'identifier');
Log.warn(`The provisioning profile is missing the following devices:`);
for (const missingDevice of missingDevices) {
Log.warn(`- ${formatDeviceLabel(missingDevice)}`);
}
return !(await confirmAsync({
message: `Would you like to choose the devices to provision again?`,
initial: true,
}));
}
}
private async registerDevicesAsync(
ctx: Context,
appleTeam: AppleTeamFragment
): Promise<AppleDeviceFragment[]> {
const action = new DeviceCreateAction(this.app.account, appleTeam);
const method = await action.runAsync();
while (true) {
if (method !== RegistrationMethod.INPUT) {
Log.newLine();
Log.log(chalk.bold("Press any key if you've already finished device registration."));
await pressAnyKeyToContinueAsync();
}
Log.newLine();
const devices = await ctx.ios.getDevicesForAppleTeamAsync(this.app, appleTeam, {
useCache: false,
});
if (devices.length === 0) {
Log.warn('There are still no registered devices.');
// if the user used the input method there should be some devices available
if (method === RegistrationMethod.INPUT) {
throw new Error('Input registration method has failed');
}
} else {
return devices;
}
}
}
}
function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
return isEqual(new Set(udidsA), new Set(udidsB));
}
@@ -1,200 +1,94 @@
import { iOSDistributionType } from '@expo/eas-json';
import assert from 'assert';
import { IosDistributionType, IosEnterpriseProvisioning } from '@expo/eas-json';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { AppleDevice, IosAppBuildCredentialsFragment } from '../../../graphql/generated';
import {
IosDistributionType as GraphQLIosDistributionType,
IosAppBuildCredentialsFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { findAccountByName } from '../../../user/Account';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { isCredentialsMap, readIosCredentialsAsync } from '../../credentialsJson/read';
import { AppLookupParams, IosAppCredentials, IosDistCredentials } from '../credentials';
import { AppLookupParams as GraphQLAppLookupParams } from '../api/GraphqlClient';
import { displayProjectCredentials } from '../utils/printCredentials';
import { readAppleTeam } from '../utils/provisioningProfile';
import { SetupAdhocProvisioningProfile } from './SetupAdhocProvisioningProfile';
import { SetupInternalProvisioningProfile } from './SetupInternalProvisioningProfile';
import { SetupProvisioningProfile } from './SetupProvisioningProfile';
import { SetupAdhocProvisioningProfile } from './new/SetupAdhocProvisioningProfile';
type AppCredentialsAndDistCert = {
appCredentials: IosAppCredentials;
distCert: IosDistCredentials | null;
};
interface Options {
app: GraphQLAppLookupParams;
distribution: IosDistributionType;
enterpriseProvisioning?: IosEnterpriseProvisioning;
skipCredentialsCheck?: boolean;
}
export class SetupBuildCredentials implements Action {
constructor(private app: AppLookupParams, private distribution: iOSDistributionType) {}
interface IosAppBuildCredentials {
iosDistributionType: GraphQLIosDistributionType;
provisioningProfile: string;
distributionCertificate: {
certificateP12: string;
certificatePassword: string;
};
}
export class SetupBuildCredentials implements Action<IosAppBuildCredentials> {
constructor(private options: Options) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<IosAppBuildCredentials> {
const { app } = this.options;
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
await ctx.bestEffortAppStoreAuthenticateAsync();
if (ctx.appStore.authCtx) {
await ctx.appStore.ensureBundleIdExistsAsync(this.app, { enablePushNotifications: true });
await ctx.appStore.ensureBundleIdExistsAsync(
{
accountName: app.account.name,
bundleIdentifier: app.bundleIdentifier,
projectName: app.projectName,
},
{ enablePushNotifications: true }
);
}
let iosAppBuildCredentials: IosAppBuildCredentialsFragment | null = null;
try {
if (this.distribution === 'internal') {
const account = findAccountByName(ctx.user.accounts, this.app.accountName);
if (!account) {
throw new Error(`You do not have access to the ${this.app.accountName} account`);
}
const action = new SetupAdhocProvisioningProfile({
account,
projectName: this.app.projectName,
bundleIdentifier: this.app.bundleIdentifier,
});
await manager.runActionAsync(action);
iosAppBuildCredentials = action.iosAppBuildCredentials;
} else {
await manager.runActionAsync(new SetupProvisioningProfile(this.app));
}
const buildCredentials = await this.setupBuildCredentials(ctx);
const appInfo = `@${app.account.name}/${app.projectName} (${app.bundleIdentifier})`;
displayProjectCredentials(app, buildCredentials);
Log.newLine();
Log.log(chalk.green(`All credentials are ready to build ${appInfo}`));
Log.newLine();
return {
iosDistributionType: buildCredentials.iosDistributionType,
provisioningProfile: nullthrows(buildCredentials.provisioningProfile?.provisioningProfile),
distributionCertificate: {
certificateP12: nullthrows(buildCredentials.distributionCertificate?.certificateP12),
certificatePassword: nullthrows(
buildCredentials.distributionCertificate?.certificatePassword
),
},
};
} catch (error) {
Log.error('Failed to setup credentials.');
throw error;
}
const { appCredentials, distCert } = await this.unifyCredentialsFormatAsync(
ctx,
iosAppBuildCredentials
);
const appInfo = `@${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`;
displayProjectCredentials(this.app, appCredentials, /* pushKey */ null, distCert);
Log.newLine();
Log.log(chalk.green(`All credentials are ready to build ${appInfo}`));
Log.newLine();
}
async unifyCredentialsFormatAsync(
ctx: Context,
iosAppBuildCredentials: IosAppBuildCredentialsFragment | null
): Promise<AppCredentialsAndDistCert> {
if (!iosAppBuildCredentials) {
const [appCredentials, distCert] = await Promise.all([
ctx.ios.getAppCredentialsAsync(this.app),
ctx.ios.getDistributionCertificateAsync(this.app),
]);
return {
appCredentials,
distCert,
};
} else {
const { distributionCertificate, provisioningProfile } = iosAppBuildCredentials;
assert(distributionCertificate && provisioningProfile);
return {
appCredentials: {
experienceName: `${this.app.accountName}/${this.app.projectName}`,
bundleIdentifier: this.app.bundleIdentifier,
credentials: {
provisioningProfile: provisioningProfile.provisioningProfile ?? undefined,
provisioningProfileId: provisioningProfile.developerPortalIdentifier ?? undefined,
teamId: provisioningProfile.appleTeam?.appleTeamIdentifier || '',
teamName: provisioningProfile.appleTeam?.appleTeamName ?? undefined,
devices: provisioningProfile.appleDevices?.filter(device => device) as
| AppleDevice[]
| undefined,
},
},
distCert: {
// the id doesn't really matter, it's only for displaying credentials
id: null as any,
type: 'dist-cert',
certId: distributionCertificate.developerPortalIdentifier ?? undefined,
certP12: distributionCertificate.certificateP12 ?? '',
certPassword: distributionCertificate.certificatePassword ?? '',
teamId: distributionCertificate.appleTeam?.appleTeamIdentifier || '',
teamName: distributionCertificate.appleTeam?.appleTeamName ?? undefined,
},
};
}
}
}
export class SetupBuildCredentialsFromCredentialsJson implements Action {
constructor(private app: AppLookupParams) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
let localCredentials;
try {
localCredentials = await readIosCredentialsAsync(ctx.projectDir);
} catch (error) {
Log.error(
'Reading credentials from credentials.json failed. Make sure this file is correct and all credentials are present there.'
);
throw error;
}
// TODO: implement storing multi-target credentials on EAS servers
if (isCredentialsMap(localCredentials)) {
throw new Error(
'Storing multi-target iOS credentials from credentials.json on EAS servers is not yet supported.'
);
}
const team = readAppleTeam(localCredentials.provisioningProfile);
await ctx.ios.updateProvisioningProfileAsync(this.app, {
...team,
provisioningProfile: localCredentials.provisioningProfile,
});
const credentials = await ctx.ios.getAllCredentialsAsync(this.app.accountName);
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
const appsUsingCert = distCert?.id
? (credentials.appCredentials || []).filter(
(cred: IosAppCredentials) => cred.distCredentialsId === distCert.id
)
: [];
const appInfo = `@${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`;
const newDistCert = {
...team,
certP12: localCredentials.distributionCertificate.certP12,
certPassword: localCredentials.distributionCertificate.certPassword,
};
if (appsUsingCert.length > 1 && distCert?.id) {
const { update } = await promptAsync({
type: 'select',
name: 'update',
message:
'Current distribution certificate is used by multiple apps. Do you want to update all of them?',
choices: [
{ title: 'Update all apps', value: 'all' },
{ title: `Update only ${appInfo}`, value: 'app' },
],
});
if (update === 'all') {
await ctx.ios.updateDistributionCertificateAsync(
distCert.id,
this.app.accountName,
newDistCert
);
async setupBuildCredentials(ctx: Context): Promise<IosAppBuildCredentialsFragment> {
const { app, distribution, enterpriseProvisioning } = this.options;
if (distribution === 'internal') {
if (enterpriseProvisioning === 'adhoc') {
return await new SetupAdhocProvisioningProfile(app).runAsync(ctx);
} else if (enterpriseProvisioning === 'universal') {
return await new SetupProvisioningProfile(
app,
GraphQLIosDistributionType.Enterprise
).runAsync(ctx);
} else {
const createdDistCert = await ctx.ios.createDistributionCertificateAsync(
this.app.accountName,
newDistCert
);
await ctx.ios.useDistributionCertificateAsync(this.app, createdDistCert.id);
return await new SetupInternalProvisioningProfile(app).runAsync(ctx);
}
} else if (distCert?.id) {
await ctx.ios.updateDistributionCertificateAsync(
distCert.id,
this.app.accountName,
newDistCert
);
} else {
const createdDistCert = await ctx.ios.createDistributionCertificateAsync(
this.app.accountName,
newDistCert
return await new SetupProvisioningProfile(app, GraphQLIosDistributionType.AppStore).runAsync(
ctx
);
await ctx.ios.useDistributionCertificateAsync(this.app, createdDistCert.id);
}
displayProjectCredentials(
this.app,
await ctx.ios.getAppCredentialsAsync(this.app),
undefined,
await ctx.ios.getDistributionCertificateAsync(this.app)
);
Log.newLine();
Log.log(chalk.green(`All credentials are ready to build ${appInfo}`));
Log.newLine();
}
}
@@ -0,0 +1,189 @@
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import {
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
AppleTeamFragment,
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Context } from '../../context';
import {
IosTargetCredentials,
isCredentialsMap,
readIosCredentialsAsync,
} from '../../credentialsJson/read';
import { AppLookupParams } from '../api/GraphqlClient';
import { displayProjectCredentials } from '../utils/printCredentials';
import { readAppleTeam } from '../utils/provisioningProfile';
import {
assignBuildCredentialsAsync,
getBuildCredentialsAsync,
getDistributionCertificateAsync,
getProvisioningProfileAsync,
} from './BuildCredentialsUtils';
export class SetupBuildCredentialsFromCredentialsJson {
constructor(private app: AppLookupParams, private distributionType: IosDistributionType) {}
async getDistributionCertificateToAssignAsync(
ctx: Context,
targetCredentials: IosTargetCredentials,
appleTeam: AppleTeamFragment,
currentDistributionCertificate: AppleDistributionCertificateFragment | null
): Promise<AppleDistributionCertificateFragment> {
const { distributionCertificate } = targetCredentials;
const { certP12, certPassword } = distributionCertificate;
if (!currentDistributionCertificate) {
return await ctx.ios.createDistributionCertificateAsync(this.app.account, {
certP12,
certPassword,
teamId: appleTeam.appleTeamIdentifier,
teamName: appleTeam.appleTeamName ?? undefined,
});
}
const isSameCertificate = currentDistributionCertificate.certificateP12 === certP12;
if (!isSameCertificate) {
return await ctx.ios.createDistributionCertificateAsync(this.app.account, {
certP12,
certPassword,
teamId: appleTeam.appleTeamIdentifier,
teamName: appleTeam.appleTeamName ?? undefined,
});
}
// If the configured Distribution Certificate is the same as the new one, just use the currently configured certificate
return currentDistributionCertificate;
}
async createNewProvisioningProfileAsync(
ctx: Context,
targetCredentials: IosTargetCredentials,
appleTeam: AppleTeamFragment
): Promise<AppleProvisioningProfileFragment> {
const { provisioningProfile } = targetCredentials;
const appleAppIdentifier = await ctx.ios.createOrGetExistingAppleAppIdentifierAsync(
this.app,
appleTeam
);
return await ctx.ios.createProvisioningProfileAsync(this.app, appleAppIdentifier, {
appleProvisioningProfile: provisioningProfile,
});
}
async getProvisioningProfileToAssignAsync(
ctx: Context,
targetCredentials: IosTargetCredentials,
appleTeam: AppleTeamFragment,
currentProvisioningProfile: AppleProvisioningProfileFragment | null
): Promise<AppleProvisioningProfileFragment> {
const { provisioningProfile } = targetCredentials;
if (!currentProvisioningProfile) {
return await this.createNewProvisioningProfileAsync(ctx, targetCredentials, appleTeam);
}
const isSameProfile = currentProvisioningProfile.provisioningProfile === provisioningProfile;
if (!isSameProfile) {
return await this.createNewProvisioningProfileAsync(ctx, targetCredentials, appleTeam);
}
// If the configured Provisioning Profile is the same as the new one, just use the currently configured profile
return currentProvisioningProfile;
}
async runAsync(ctx: Context): Promise<IosAppBuildCredentialsFragment> {
let localCredentials;
try {
localCredentials = await readIosCredentialsAsync(ctx.projectDir);
} catch (error) {
Log.error(
'Reading credentials from credentials.json failed. Make sure this file is correct and all credentials are present there.'
);
throw error;
}
// TODO: implement storing multi-target credentials on EAS servers
if (isCredentialsMap(localCredentials)) {
throw new Error(
'Storing multi-target iOS credentials from credentials.json on EAS servers is not yet supported.'
);
}
// currently configured credentials
const buildCredentials = await getBuildCredentialsAsync(ctx, this.app, this.distributionType);
const currentDistributionCertificate = await getDistributionCertificateAsync(
ctx,
this.app,
this.distributionType
);
const currentProfile = await getProvisioningProfileAsync(ctx, this.app, this.distributionType);
const appInfo = `@${this.app.account.name}/${this.app.projectName} (${this.app.bundleIdentifier})`;
// new credentials from local json
const appleTeamFromProvisioningProfile = readAppleTeam(localCredentials.provisioningProfile);
const appleTeam = await ctx.ios.createOrGetExistingAppleTeamAsync(this.app.account, {
appleTeamIdentifier: appleTeamFromProvisioningProfile.teamId,
appleTeamName: appleTeamFromProvisioningProfile.teamName,
});
const distributionCertificateToAssign = await this.getDistributionCertificateToAssignAsync(
ctx,
localCredentials,
appleTeam,
currentDistributionCertificate
);
const provisioningProfileToAssign = await this.getProvisioningProfileToAssignAsync(
ctx,
localCredentials,
appleTeam,
currentProfile
);
const didCredentialsChange =
!currentDistributionCertificate ||
!currentProfile ||
currentDistributionCertificate.id !== distributionCertificateToAssign.id ||
currentProfile.id !== provisioningProfileToAssign.id;
if (!didCredentialsChange) {
Log.log('Credentials have already been configured to your credentials json.');
return nullthrows(buildCredentials, 'buildCredentials should have been defined.');
}
if (buildCredentials) {
Log.log('Currently configured credentials:');
displayProjectCredentials(this.app, buildCredentials);
if (!ctx.nonInteractive) {
const confirm = await confirmAsync({
message: `Would you like to replace this configuration with credentials from credentials json?`,
});
if (!confirm) {
throw new Error('Aborting setup of build credentials from credentials json');
}
}
}
Log.log(`Assigning credentials from credentials json to ${appInfo}...`);
const newBuildCredentials = await assignBuildCredentialsAsync(
ctx,
this.app,
this.distributionType,
distributionCertificateToAssign,
provisioningProfileToAssign,
appleTeam
);
Log.log('New credentials configuration:');
displayProjectCredentials(this.app, newBuildCredentials);
Log.newLine();
Log.log(chalk.green(`All credentials are ready to build ${appInfo}`));
Log.newLine();
return newBuildCredentials;
}
}
@@ -1,99 +1,135 @@
import assert from 'assert';
import sortBy from 'lodash/sortBy';
import {
AppleDistributionCertificate,
AppleDistributionCertificateFragment,
IosDistributionType,
} from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync, promptAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { AppLookupParams } from '../credentials';
import { validateDistributionCertificateAsync } from '../validators/validateDistributionCertificate';
import { AppLookupParams } from '../api/GraphqlClient';
import { AppleDistributionCertificateMutationResult } from '../api/graphql/mutations/AppleDistributionCertificateMutation';
import { getValidCertSerialNumbers } from '../appstore/CredentialsUtils';
import { AppleTeamMissingError, MissingCredentialsNonInteractiveError } from '../errors';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
import { CreateDistributionCertificate } from './CreateDistributionCertificate';
import {
formatDistributionCertificate,
getValidDistCertsAsync,
} from './DistributionCertificateUtils';
import {
UseExistingDistributionCertificate,
UseSpecificDistributionCertificate,
} from './UseDistributionCertificate';
import { formatDistributionCertificate } from './DistributionCertificateUtils';
export class SetupDistributionCertificateForApp implements Action {
constructor(private app: AppLookupParams) {}
export class SetupDistributionCertificate {
private validDistCerts?: AppleDistributionCertificateFragment[];
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
if (await this.isCurrentCertificateValidAsync(ctx)) {
return;
}
constructor(private app: AppLookupParams, private distributionType: IosDistributionType) {}
if (ctx.nonInteractive) {
throw new Error(
'Distribution Certificate is not configured correctly. Please run this command again in interactive mode.'
public async runAsync(ctx: Context): Promise<AppleDistributionCertificateFragment> {
const appleTeam = await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app);
try {
const currentCertificate = await ctx.ios.getDistributionCertificateForAppAsync(
this.app,
this.distributionType,
{ appleTeam }
);
if (ctx.nonInteractive) {
return await this.runNonInteractiveAsync(ctx, currentCertificate);
} else {
return await this.runInteractiveAsync(ctx, currentCertificate);
}
} catch (err) {
if (err instanceof AppleTeamMissingError && ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError();
}
throw err;
}
const existingCertificates = await getValidDistCertsAsync(ctx, this.app.accountName);
if (existingCertificates.length === 0) {
await this.createNewCertificateAsync(manager, ctx);
return;
}
// autoselect credentials if we find valid certs
const autoselectedCertificate = existingCertificates[0];
const credentials = await ctx.ios.getAllCredentialsAsync(this.app.accountName);
const usedByApps = credentials.appCredentials.filter(
cred => cred.distCredentialsId === autoselectedCertificate.id
);
const confirm = await confirmAsync({
message: `${formatDistributionCertificate(
autoselectedCertificate,
usedByApps,
ctx.appStore.authCtx ? 'VALID' : 'UNKNOWN'
)} \n Would you like to use this certificate?`,
});
if (!confirm) {
await this.createOrReuseAsync(manager, ctx);
return;
}
Log.log(`Using Distribution Certificate: ${autoselectedCertificate.certId || '-----'}`);
await manager.runActionAsync(
new UseSpecificDistributionCertificate(this.app, autoselectedCertificate.id)
);
}
private async isCurrentCertificateValidAsync(ctx: Context): Promise<boolean> {
const currentCertificate = await ctx.ios.getDistributionCertificateAsync(this.app);
private async runNonInteractiveAsync(
ctx: Context,
currentCertificate: AppleDistributionCertificateFragment | null
): Promise<AppleDistributionCertificateFragment> {
// TODO: implement validation
Log.addNewLineIfNone();
Log.warn('Distribution Certificate is not validated for non-interactive builds.');
if (!currentCertificate) {
throw new MissingCredentialsNonInteractiveError();
}
return currentCertificate;
}
private async runInteractiveAsync(
ctx: Context,
currentCertificate: AppleDistributionCertificateFragment | null
): Promise<AppleDistributionCertificateFragment> {
if (await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) {
assert(currentCertificate, 'currentCertificate is defined here');
return currentCertificate;
}
const validDistCertsOnFile = await this.getValidDistCertsAsync(ctx);
return validDistCertsOnFile.length === 0
? await this.createNewDistCertAsync(ctx)
: await this.createOrReuseDistCert(ctx);
}
private async isCurrentCertificateValidAsync(
ctx: Context,
currentCertificate: AppleDistributionCertificateFragment | null
): Promise<boolean> {
if (!currentCertificate) {
return false;
}
if (ctx.appStore.authCtx) {
const isValid = await validateDistributionCertificateAsync(ctx, currentCertificate);
if (!isValid) {
Log.warn("Current Distribution Certificate is no longer valid on Apple's server");
}
return isValid;
const now = new Date();
if (
now < new Date(currentCertificate.validityNotBefore) ||
now > new Date(currentCertificate.validityNotAfter)
) {
return false;
}
return true;
}
private async createNewCertificateAsync(
manager: CredentialsManager,
ctx: Context
): Promise<void> {
const action = new CreateDistributionCertificate(this.app.accountName);
await manager.runActionAsync(action);
await manager.runActionAsync(
new UseSpecificDistributionCertificate(this.app, action.distCredentials.id)
if (!ctx.appStore.authCtx) {
Log.warn(
"Skipping Distribution Certificate validation on Apple Servers because we aren't authenticated."
);
return true;
}
const validCertSerialNumbers = (await this.getValidDistCertsAsync(ctx)).map(
i => i.serialNumber
);
const isValid = validCertSerialNumbers.includes(currentCertificate.serialNumber);
if (!isValid) {
Log.warn('Current distribution certificate is no longer valid in Apple Dev Portal');
}
return isValid;
}
private async createOrReuseAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
private async createOrReuseDistCert(ctx: Context): Promise<AppleDistributionCertificateFragment> {
const validDistCerts = await this.getValidDistCertsAsync(ctx);
const autoselectedDistCert = validDistCerts[0];
const useAutoselected = await confirmAsync({
message: `Reuse this distribution certificate?\n${formatDistributionCertificate(
autoselectedDistCert
)}`,
});
if (useAutoselected) {
Log.log(
`Using distribution certificate with serial number ${autoselectedDistCert.serialNumber}`
);
return autoselectedDistCert;
}
const { action } = await promptAsync({
type: 'select',
name: 'action',
message: 'Select an iOS Distribution Certificate to use for code signing:',
message: 'Select the iOS distribution certificate to use for code signing:',
choices: [
{
title: '[Choose existing certificate] (Recommended)',
title: '[Choose another existing certificate] (Recommended)',
value: 'CHOOSE_EXISTING',
},
{ title: '[Add a new certificate]', value: 'GENERATE' },
@@ -101,9 +137,58 @@ export class SetupDistributionCertificateForApp implements Action {
});
if (action === 'GENERATE') {
await this.createNewCertificateAsync(manager, ctx);
} else if (action === 'CHOOSE_EXISTING') {
await manager.runActionAsync(new UseExistingDistributionCertificate(this.app));
return await this.createNewDistCertAsync(ctx);
} else {
return await this.reuseDistCertAsync(ctx);
}
}
private async createNewDistCertAsync(
ctx: Context
): Promise<AppleDistributionCertificateMutationResult> {
return new CreateDistributionCertificate(this.app.account).runAsync(ctx);
}
async reuseDistCertAsync(ctx: Context): Promise<AppleDistributionCertificate> {
const validDistCerts = await this.getValidDistCertsAsync(ctx);
const { distCert } = await promptAsync({
type: 'select',
name: 'distCert',
message: 'Select the iOS Distribution Certificate from the list:',
choices: validDistCerts.map(distCert => ({
title: formatDistributionCertificate(distCert),
value: distCert,
})),
});
return distCert;
}
private async getValidDistCertsAsync(
ctx: Context
): Promise<AppleDistributionCertificateFragment[]> {
if (this.validDistCerts) {
return this.validDistCerts;
}
const validDistCertSerialNumbers = getValidCertSerialNumbers(
await ctx.appStore.listDistributionCertificatesAsync()
);
const validDistCertSerialNumberSet = new Set(validDistCertSerialNumbers);
const distCertsForAccount = await ctx.ios.getDistributionCertificatesForAccountAsync(
this.app.account
);
const distCertsForAppleTeam = distCertsForAccount.filter(distCert => {
return (
!distCert.appleTeam ||
distCert.appleTeam.appleTeamIdentifier === ctx.appStore.authCtx?.team.id
);
});
const validDistCerts = distCertsForAppleTeam.filter(distCert => {
return validDistCertSerialNumberSet.has(distCert.serialNumber);
});
this.validDistCerts = sortBy(validDistCerts, 'validityNotAfter', 'desc');
return this.validDistCerts;
}
}
@@ -0,0 +1,115 @@
import { IosAppBuildCredentialsFragment, IosDistributionType } from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
import { getAllBuildCredentialsAsync } from './BuildCredentialsUtils';
import { SetupAdhocProvisioningProfile } from './SetupAdhocProvisioningProfile';
import { SetupProvisioningProfile } from './SetupProvisioningProfile';
/**
* It's used when setting up credentials for internal distribution but `enterpriseProvisioning` is not set.
*
* TLDR: If the user authenticates with an account with Apple Developer Enterprise Program membership we ask them
* to choose if they want to set up an adhoc or universal distribution provisioning profile. Otherwise, always
* set up an adhoc provisioning profile.
*/
export class SetupInternalProvisioningProfile {
constructor(private app: AppLookupParams) {}
async runAsync(ctx: Context): Promise<IosAppBuildCredentialsFragment> {
const buildCredentials = await getAllBuildCredentialsAsync(ctx, this.app);
const adhocBuildCredentialsExist =
buildCredentials.filter(
({ iosDistributionType }) => iosDistributionType === IosDistributionType.AdHoc
).length > 0;
const enterpriseBuildCredentialsExist =
buildCredentials.filter(
({ iosDistributionType }) => iosDistributionType === IosDistributionType.Enterprise
).length > 0;
if (!ctx.nonInteractive) {
if (ctx.appStore.authCtx) {
if (ctx.appStore.authCtx.team.inHouse) {
return await this.askForDistributionTypeAndSetupAsync(
ctx,
'Which credentials would you like to set up?'
);
} else {
return await this.setupAdhocProvisioningProfileAsync(ctx);
}
} else {
if (adhocBuildCredentialsExist && enterpriseBuildCredentialsExist) {
Log.log('You have set up both adhoc and universal distribution credentials.');
return await this.askForDistributionTypeAndSetupAsync(
ctx,
'Which credentials would you like to use?'
);
} else if (adhocBuildCredentialsExist) {
return await this.setupAdhocProvisioningProfileAsync(ctx);
} else if (enterpriseBuildCredentialsExist) {
return await this.setupUniversalProvisioningProfileAsync(ctx);
} else {
const { team } = await ctx.appStore.ensureAuthenticatedAsync();
if (team.inHouse) {
return await this.askForDistributionTypeAndSetupAsync(
ctx,
'Which credentials would you like to set up?'
);
} else {
return await this.setupAdhocProvisioningProfileAsync(ctx);
}
}
}
} else {
if (adhocBuildCredentialsExist && enterpriseBuildCredentialsExist) {
throw new Error(
`You're in non-interactive mode. You have set up both adhoc and universal distribution credentials. Please set the 'enterpriseProvisioning' property (to 'adhoc' or 'universal') in eas.json to choose the credentials to use.`
);
} else if (adhocBuildCredentialsExist) {
return await this.setupAdhocProvisioningProfileAsync(ctx);
} else if (enterpriseBuildCredentialsExist) {
return await this.setupUniversalProvisioningProfileAsync(ctx);
} else {
throw new Error(
`You're in non-interactive mode. EAS CLI couldn't find any credentials suitable for internal distribution. Please run again in interactive mode.`
);
}
}
}
private async setupAdhocProvisioningProfileAsync(
ctx: Context
): Promise<IosAppBuildCredentialsFragment> {
return await new SetupAdhocProvisioningProfile(this.app).runAsync(ctx);
}
private async setupUniversalProvisioningProfileAsync(
ctx: Context
): Promise<IosAppBuildCredentialsFragment> {
return await new SetupProvisioningProfile(this.app, IosDistributionType.Enterprise).runAsync(
ctx
);
}
private async askForDistributionTypeAndSetupAsync(
ctx: Context,
message: string
): Promise<IosAppBuildCredentialsFragment> {
const { distributionType } = await promptAsync({
type: 'select',
name: 'distributionType',
message,
choices: [
{ title: 'Universal Distribution', value: IosDistributionType.Enterprise },
{ title: 'Adhoc Distribution', value: IosDistributionType.AdHoc },
],
});
if (distributionType === IosDistributionType.Enterprise) {
return await this.setupUniversalProvisioningProfileAsync(ctx);
} else {
return await this.setupAdhocProvisioningProfileAsync(ctx);
}
}
}
@@ -1,152 +1,150 @@
import assert from 'assert';
import pick from 'lodash/pick';
import nullthrows from 'nullthrows';
import Log from '../../../log';
import { confirmAsync, promptAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import {
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import { confirmAsync } from '../../../prompts';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
import { AppLookupParams, IosDistCredentials } from '../credentials';
import { MissingCredentialsNonInteractiveError } from '../errors';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
import {
validateProvisioningProfileAsync,
validateProvisioningProfileWithoutApple,
} from '../validators/validateProvisioningProfile';
import {
ConfigureProvisioningProfile,
UseExistingProvisioningProfile,
} from './ConfigureProvisioningProfile';
assignBuildCredentialsAsync,
getBuildCredentialsAsync,
getProvisioningProfileAsync,
} from './BuildCredentialsUtils';
import { ConfigureProvisioningProfile } from './ConfigureProvisioningProfile';
import { CreateProvisioningProfile } from './CreateProvisioningProfile';
import { formatProvisioningProfileFromApple } from './ProvisioningProfileUtils';
import { SetupDistributionCertificateForApp } from './SetupDistributionCertificate';
import { SetupDistributionCertificate } from './SetupDistributionCertificate';
export class SetupProvisioningProfile implements Action {
constructor(private app: AppLookupParams) {}
/**
* Sets up either APP_STORE or ENTERPRISE provisioning profiles
*/
export class SetupProvisioningProfile {
constructor(private app: AppLookupParams, private distributionType: IosDistributionType) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
await manager.runActionAsync(new SetupDistributionCertificateForApp(this.app));
if (await this.isCurrentProfileValidAsync(ctx)) {
return;
async areBuildCredentialsSetupAsync(ctx: Context): Promise<boolean> {
const buildCredentials = await getBuildCredentialsAsync(ctx, this.app, this.distributionType);
return await validateProvisioningProfileAsync(ctx, this.app, buildCredentials);
}
async assignNewAndDeleteOldProfileAsync(
ctx: Context,
distCert: AppleDistributionCertificateFragment,
currentProfile: AppleProvisioningProfileFragment
): Promise<IosAppBuildCredentialsFragment> {
const buildCredentials = await this.createAndAssignProfileAsync(ctx, distCert);
// delete 'currentProfile' since its no longer valid
await ctx.ios.deleteProvisioningProfilesAsync([currentProfile.id]);
return buildCredentials;
}
async createAndAssignProfileAsync(
ctx: Context,
distCert: AppleDistributionCertificateFragment
): Promise<IosAppBuildCredentialsFragment> {
const provisioningProfile = await new CreateProvisioningProfile(this.app, distCert).runAsync(
ctx
);
return await assignBuildCredentialsAsync(
ctx,
this.app,
this.distributionType,
distCert,
provisioningProfile
);
}
async configureAndAssignProfileAsync(
ctx: Context,
distCert: AppleDistributionCertificateFragment,
originalProvisioningProfile: AppleProvisioningProfileFragment
): Promise<IosAppBuildCredentialsFragment | null> {
const profileConfigurator = new ConfigureProvisioningProfile(
this.app,
distCert,
originalProvisioningProfile
);
const updatedProvisioningProfile = await profileConfigurator.runAsync(ctx);
if (!updatedProvisioningProfile) {
return null;
}
return await assignBuildCredentialsAsync(
ctx,
this.app,
this.distributionType,
distCert,
updatedProvisioningProfile
);
}
async runAsync(ctx: Context): Promise<IosAppBuildCredentialsFragment> {
const distCert = await new SetupDistributionCertificate(
this.app,
this.distributionType
).runAsync(ctx);
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
if (areBuildCredentialsSetup) {
return nullthrows(await getBuildCredentialsAsync(ctx, this.app, this.distributionType));
}
if (ctx.nonInteractive) {
throw new Error(
throw new MissingCredentialsNonInteractiveError(
'Provisioning profile is not configured correctly. Please run this command again in interactive mode.'
);
}
if (!ctx.appStore.authCtx) {
await manager.runActionAsync(new CreateProvisioningProfile(this.app));
return;
const currentProfile = await getProvisioningProfileAsync(ctx, this.app, this.distributionType);
if (!currentProfile) {
return await this.createAndAssignProfileAsync(ctx, distCert);
}
// See if the profile we have exists on the Apple Servers
const existingProfiles = await ctx.appStore.listProvisioningProfilesAsync(
this.app.bundleIdentifier
);
if (existingProfiles.length === 0) {
await manager.runActionAsync(new CreateProvisioningProfile(this.app));
return;
}
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
assert(distCert, 'missing distribution certificate');
const autoselectedProfile = this.choosePreferred(existingProfiles, distCert);
// autoselect credentials if we find valid certs
if (!ctx.nonInteractive) {
const confirm = await confirmAsync({
message: `${formatProvisioningProfileFromApple(
autoselectedProfile
)} \n Would you like to use this profile?`,
});
if (!confirm) {
await this.createOrReuseAsync(manager, ctx);
return;
}
} else {
Log.log(`Using provisioning profile: ${autoselectedProfile.provisioningProfileId}`);
}
await ctx.ios.updateProvisioningProfileAsync(
this.app,
pick(autoselectedProfile, [
'provisioningProfile',
'provisioningProfileId',
'teamId',
'teamName',
])
const currentProfileFromServer = this.getCurrentProfileStoreInfo(
existingProfiles,
currentProfile
);
await manager.runActionAsync(new ConfigureProvisioningProfile(this.app));
}
private async isCurrentProfileValidAsync(ctx: Context): Promise<boolean> {
const currentProfile = await ctx.ios.getProvisioningProfileAsync(this.app);
const currentCertificate = await ctx.ios.getDistributionCertificateAsync(this.app);
if (!currentProfile || !currentCertificate) {
return false;
if (!currentProfileFromServer) {
return await this.assignNewAndDeleteOldProfileAsync(ctx, distCert, currentProfile);
}
if (!ctx.appStore.authCtx || !currentProfile.provisioningProfileId) {
if (!currentProfile.provisioningProfileId) {
Log.warn(
"The provisioning profile we have on file cannot be validated on Apple's servers."
);
}
const validationResult = validateProvisioningProfileWithoutApple(
currentProfile,
currentCertificate,
this.app.bundleIdentifier
);
if (!validationResult.ok) {
Log.warn(validationResult.error);
return false;
}
return true;
}
const validationResult = await validateProvisioningProfileAsync(
ctx,
currentProfile,
currentCertificate,
this.app.bundleIdentifier
);
if (!validationResult.ok) {
Log.warn(validationResult.error);
return false;
}
return true;
}
private choosePreferred(
profiles: ProvisioningProfileStoreInfo[],
distCert: IosDistCredentials
): ProvisioningProfileStoreInfo {
// prefer the profile that already has the same dist cert associated with it
const profileWithSameCert = profiles.find(profile =>
profile.certificates.some(cert => cert.id === distCert.certId)
);
// if not, just get an arbitrary profile
return profileWithSameCert || profiles[0];
}
private async createOrReuseAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const { action } = await promptAsync({
type: 'select',
name: 'action',
message: 'Select a provisioning profile:',
choices: [
{
title: '[Choose existing provisioning profile] (Recommended)',
value: 'CHOOSE_EXISTING',
},
{ title: '[Add a new provisioning profile]', value: 'GENERATE' },
],
const confirm = await confirmAsync({
message: `${formatProvisioningProfileFromApple(
currentProfileFromServer
)} \n Would you like to reuse the original profile?`,
});
if (action === 'GENERATE') {
await manager.runActionAsync(new CreateProvisioningProfile(this.app));
} else if (action === 'CHOOSE_EXISTING') {
await manager.runActionAsync(new UseExistingProvisioningProfile(this.app));
if (!confirm) {
return await this.assignNewAndDeleteOldProfileAsync(ctx, distCert, currentProfile);
}
// If we get here, we've verified the current profile still exists on Apple
// But something wasn't quite right, so we want to fix and update it
const updatedProfile = await this.configureAndAssignProfileAsync(ctx, distCert, currentProfile);
if (!updatedProfile) {
// Something went wrong, so just create a new profile instead
return await this.assignNewAndDeleteOldProfileAsync(ctx, distCert, currentProfile);
}
return updatedProfile;
}
private getCurrentProfileStoreInfo(
profiles: ProvisioningProfileStoreInfo[],
currentProfile: AppleProvisioningProfileFragment
): ProvisioningProfileStoreInfo | null {
return (
profiles.find(profile =>
currentProfile.developerPortalIdentifier
? currentProfile.developerPortalIdentifier === profile.provisioningProfileId
: currentProfile.provisioningProfile === profile.provisioningProfile
) ?? null
);
}
}
@@ -1,23 +1,18 @@
import { Platform } from '@expo/eas-build-job';
import { IosDistributionType as IosDistributionTypeGraphql } from '../../../graphql/generated';
import Log from '../../../log';
import { ensureAppIdentifierIsDefinedAsync } from '../../../project/projectUtils';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { updateIosCredentialsAsync } from '../../credentialsJson/update';
import { AppLookupParams } from '../credentials';
import { AppLookupParams } from '../api/GraphqlClient';
export class UpdateCredentialsJson implements Action {
constructor(private app: AppLookupParams) {}
export class UpdateCredentialsJson {
constructor(
private app: AppLookupParams,
private iosDistributionTypeGraphql: IosDistributionTypeGraphql
) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const bundleIdentifer = await ensureAppIdentifierIsDefinedAsync({
projectDir: ctx.projectDir,
platform: Platform.IOS,
exp: ctx.exp,
});
async runAsync(ctx: Context): Promise<void> {
Log.log('Updating iOS credentials in credentials.json');
await updateIosCredentialsAsync(ctx, bundleIdentifer);
await updateIosCredentialsAsync(ctx, this.app, this.iosDistributionTypeGraphql);
Log.succeed(
'iOS part of your local credentials.json is synced with values stored on EAS servers.'
);
@@ -1,91 +0,0 @@
import chalk from 'chalk';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { getAppLookupParams } from '../credentials';
import { displayIosUserCredentials } from '../utils/printCredentials';
import {
provideOrGenerateDistributionCertificateAsync,
selectDistributionCertificateAsync,
} from './DistributionCertificateUtils';
import { RemoveSpecificProvisioningProfile } from './RemoveProvisioningProfile';
export class UpdateDistributionCertificate implements Action {
constructor(private accountName: string) {}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const selected = await selectDistributionCertificateAsync(ctx, this.accountName);
if (!selected) {
return;
}
const credentials = await ctx.ios.getAllCredentialsAsync(this.accountName);
const apps = credentials.appCredentials.filter(cred => cred.distCredentialsId === selected.id);
const appList = apps.map(appCred => chalk.green(appCred.experienceName)).join(', ');
if (apps.length > 1) {
if (ctx.nonInteractive) {
throw new Error(
`Start the CLI without the '--non-interactive' flag to update the certificate used by ${appList}.`
);
}
const confirm = await confirmAsync({
message: `You are updating certificate used by ${appList}. Do you want to continue?`,
});
if (!confirm) {
Log.log('Aborting update process');
return;
}
}
await manager.runActionAsync(
new UpdateSpecificDistributionCertificate(selected.id, this.accountName)
);
const newDistCert = await ctx.ios.getDistributionCertificateByIdAsync(
selected.id,
this.accountName
);
displayIosUserCredentials(newDistCert);
Log.newLine();
}
}
export class UpdateSpecificDistributionCertificate implements Action {
constructor(private userCredentialsId: number | string, private accountName: string) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const credentials = await ctx.ios.getAllCredentialsAsync(this.accountName);
const apps = credentials.appCredentials.filter(
cred => cred.distCredentialsId === this.userCredentialsId
);
const newDistCert = await provideOrGenerateDistributionCertificateAsync(
manager,
ctx,
this.accountName
);
await ctx.ios.updateDistributionCertificateAsync(
this.userCredentialsId,
this.accountName,
newDistCert
);
Log.succeed('Updated distribution certificate');
Log.newLine();
for (const appCredentials of apps) {
Log.log(
`Removing provisioning profile for ${appCredentials.experienceName} (${appCredentials.bundleIdentifier})`
);
const appLookupParams = getAppLookupParams(
appCredentials.experienceName,
appCredentials.bundleIdentifier
);
await manager.runActionAsync(
new RemoveSpecificProvisioningProfile(appLookupParams, { shouldRevoke: true })
);
}
}
}
@@ -1,34 +0,0 @@
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { AppLookupParams } from '../credentials';
import { ConfigureProvisioningProfile } from './ConfigureProvisioningProfile';
import { selectDistributionCertificateAsync } from './DistributionCertificateUtils';
export class UseExistingDistributionCertificate implements Action {
constructor(private app: AppLookupParams) {}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const selected = await selectDistributionCertificateAsync(ctx, this.app.accountName, {
filterInvalid: true,
});
if (selected) {
await manager.runActionAsync(new UseSpecificDistributionCertificate(this.app, selected.id));
}
const profile = await ctx.ios.getProvisioningProfileAsync(this.app);
if (profile) {
await manager.runActionAsync(new ConfigureProvisioningProfile(this.app));
}
}
}
export class UseSpecificDistributionCertificate implements Action {
constructor(private app: AppLookupParams, private userCredentialsId: number | string) {}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
await ctx.ios.useDistributionCertificateAsync(this.app, this.userCredentialsId);
Log.succeed(
`Assigned distribution certificate to @${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`
);
}
}
@@ -0,0 +1,20 @@
import {
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
} from '../../../../graphql/generated';
import { testProvisioningProfileFragment } from '../../../__tests__/fixtures-ios';
import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
import { AppleProvisioningProfileMutationResult } from '../../api/graphql/mutations/AppleProvisioningProfileMutation';
export class ConfigureProvisioningProfile {
constructor(
private app: AppLookupParams,
private distributionCertificate: AppleDistributionCertificateFragment,
private originalProvisioningProfile: AppleProvisioningProfileFragment
) {}
public async runAsync(ctx: Context): Promise<AppleProvisioningProfileMutationResult | null> {
return testProvisioningProfileFragment;
}
}
@@ -0,0 +1,16 @@
import { AppleDistributionCertificateFragment } from '../../../../graphql/generated';
import { testProvisioningProfileFragment } from '../../../__tests__/fixtures-ios';
import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
import { AppleProvisioningProfileMutationResult } from '../../api/graphql/mutations/AppleProvisioningProfileMutation';
export class CreateProvisioningProfile {
constructor(
private app: AppLookupParams,
private distributionCertificate: AppleDistributionCertificateFragment
) {}
async runAsync(ctx: Context): Promise<AppleProvisioningProfileMutationResult> {
return testProvisioningProfileFragment;
}
}
@@ -0,0 +1,14 @@
import {
AppleDistributionCertificateFragment,
IosDistributionType,
} from '../../../../graphql/generated';
import { testDistCertFragmentOneDependency } from '../../../__tests__/fixtures-ios';
import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
export class SetupDistributionCertificate {
constructor(private app: AppLookupParams, private distributionType: IosDistributionType) {}
public async runAsync(_ctx: Context): Promise<AppleDistributionCertificateFragment> {
return testDistCertFragmentOneDependency;
}
}
@@ -1,15 +1,15 @@
import { confirmAsync } from '../../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../../../__tests__/fixtures-context';
import { confirmAsync } from '../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import {
testDistCertFragmentNoDependencies,
testProvisioningProfile,
testProvisioningProfileFragment,
} from '../../../../__tests__/fixtures-ios';
import { ManageIosBeta } from '../../../../manager/ManageIosBeta';
import { MissingCredentialsNonInteractiveError } from '../../../errors';
} from '../../../__tests__/fixtures-ios';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { ConfigureProvisioningProfile } from '../ConfigureProvisioningProfile';
jest.mock('../../../../../prompts');
jest.mock('../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
describe('ConfigureProvisioningProfile', () => {
@@ -29,7 +29,7 @@ describe('ConfigureProvisioningProfile', () => {
useExistingProvisioningProfileAsync: jest.fn(() => mockProvisioningProfileFromApple),
},
});
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(ctx);
const provProfConfigurator = new ConfigureProvisioningProfile(
appLookupParams,
testDistCertFragmentNoDependencies,
@@ -38,7 +38,7 @@ describe('ConfigureProvisioningProfile', () => {
await provProfConfigurator.runAsync(ctx);
// expect provisioning profile not to be updated on expo servers
expect((ctx.newIos.updateProvisioningProfileAsync as any).mock.calls.length).toBe(1);
expect((ctx.ios.updateProvisioningProfileAsync as any).mock.calls.length).toBe(1);
// expect provisioning profile not to be updated on apple portal
expect((ctx.appStore.useExistingProvisioningProfileAsync as any).mock.calls.length).toBe(1);
});
@@ -46,7 +46,7 @@ describe('ConfigureProvisioningProfile', () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(ctx);
const provProfConfigurator = new ConfigureProvisioningProfile(
appLookupParams,
testDistCertFragmentNoDependencies,
@@ -57,7 +57,7 @@ describe('ConfigureProvisioningProfile', () => {
);
// expect provisioning profile not to be updated on expo servers
expect((ctx.newIos.updateProvisioningProfileAsync as any).mock.calls.length).toBe(0);
expect((ctx.ios.updateProvisioningProfileAsync as any).mock.calls.length).toBe(0);
// expect provisioning profile not to be updated on apple portal
expect((ctx.appStore.useExistingProvisioningProfileAsync as any).mock.calls.length).toBe(0);
});
@@ -1,19 +1,18 @@
import { confirmAsync } from '../../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../../__tests__/fixtures-appstore';
import { createCtxMock, createManagerMock } from '../../../../__tests__/fixtures-context';
import { confirmAsync } from '../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import {
testDistCertFragmentNoDependencies,
testProvisioningProfile,
} from '../../../../__tests__/fixtures-ios';
import { ManageIosBeta } from '../../../../manager/ManageIosBeta';
import { MissingCredentialsNonInteractiveError } from '../../../errors';
} from '../../../__tests__/fixtures-ios';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { CreateProvisioningProfile } from '../CreateProvisioningProfile';
jest.mock('../../../../../prompts');
jest.mock('../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
describe('CreateProvisioningProfile', () => {
it('creates a Provisioning Profile in Interactive Mode', async () => {
const manager = createManagerMock();
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
@@ -23,34 +22,33 @@ describe('CreateProvisioningProfile', () => {
createProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
},
});
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(ctx);
const createProvProfAction = new CreateProvisioningProfile(
appLookupParams,
testDistCertFragmentNoDependencies
);
await createProvProfAction.runAsync(manager, ctx);
await createProvProfAction.runAsync(ctx);
// expect provisioning profile to be created on expo servers
expect((ctx.newIos.createProvisioningProfileAsync as any).mock.calls.length).toBe(1);
expect((ctx.ios.createProvisioningProfileAsync as any).mock.calls.length).toBe(1);
// expect provisioning profile to be created on apple portal
expect((ctx.appStore.createProvisioningProfileAsync as any).mock.calls.length).toBe(1);
});
it('errors in Non Interactive Mode', async () => {
const manager = createManagerMock();
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(ctx);
const createProvProfAction = new CreateProvisioningProfile(
appLookupParams,
testDistCertFragmentNoDependencies
);
await expect(createProvProfAction.runAsync(manager, ctx)).rejects.toThrowError(
await expect(createProvProfAction.runAsync(ctx)).rejects.toThrowError(
MissingCredentialsNonInteractiveError
);
// expect provisioning profile not to be created on expo servers
expect((ctx.newIos.createProvisioningProfileAsync as any).mock.calls.length).toBe(0);
expect((ctx.ios.createProvisioningProfileAsync as any).mock.calls.length).toBe(0);
// expect provisioning profile not to be created on apple portal
expect((ctx.appStore.createProvisioningProfileAsync as any).mock.calls.length).toBe(0);
});
@@ -1,12 +1,12 @@
import mockdate from 'mockdate';
import { AppleDistributionCertificateQuery } from '../../../api/graphql/queries/AppleDistributionCertificateQuery';
import { AppleDistributionCertificateQuery } from '../../api/graphql/queries/AppleDistributionCertificateQuery';
import { formatDistributionCertificate } from '../DistributionCertificateUtils';
jest.mock('../../../api/graphql/queries/AppleDistributionCertificateQuery');
jest.mock('../../api/graphql/queries/AppleDistributionCertificateQuery');
jest.mock('chalk', () => {
return {
__esModule: true, // this property makes it work
default: { gray: jest.fn(log => log) },
default: { gray: jest.fn(log => log), underline: jest.fn(log => log) },
};
});
@@ -1,19 +1,19 @@
import { confirmAsync } from '../../../../../prompts';
import { createCtxMock } from '../../../../__tests__/fixtures-context';
import { confirmAsync } from '../../../../prompts';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import {
testDistCertFragmentNoDependencies,
testDistCertFragmentOneDependency,
} from '../../../../__tests__/fixtures-ios';
import { ManageIosBeta } from '../../../../manager/ManageIosBeta';
} from '../../../__tests__/fixtures-ios';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { RemoveDistributionCertificate } from '../RemoveDistributionCertificate';
jest.mock('../../../../../prompts');
jest.mock('../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
describe('RemoveDistributionCertificate', () => {
it('deletes the distribution certificate on Expo and Apple servers when there are no App Dependencies in Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: false });
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(ctx);
const removeDistCertAction = new RemoveDistributionCertificate(
appLookupParams.account,
testDistCertFragmentNoDependencies
@@ -21,15 +21,15 @@ describe('RemoveDistributionCertificate', () => {
await removeDistCertAction.runAsync(ctx);
// expect dist cert to be deleted on expo servers
expect((ctx.newIos.deleteDistributionCertificateAsync as any).mock.calls.length).toBe(1);
expect((ctx.ios.deleteDistributionCertificateAsync as any).mock.calls.length).toBe(1);
// expect dist cert to be deleted on apple portal
expect((ctx.appStore.revokeDistributionCertificateAsync as any).mock.calls.length).toBe(1);
// expect provisioning profile deletion to be skipped because there arent any associated with the dist cert
expect((ctx.newIos.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(0);
expect((ctx.ios.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(0);
});
it('deletes the distribution certificate on Expo servers when there are no App Dependencies in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(ctx);
const removeDistCertAction = new RemoveDistributionCertificate(
appLookupParams.account,
testDistCertFragmentNoDependencies
@@ -37,15 +37,15 @@ describe('RemoveDistributionCertificate', () => {
await removeDistCertAction.runAsync(ctx);
// expect dist cert to be deleted on expo servers
expect((ctx.newIos.deleteDistributionCertificateAsync as any).mock.calls.length).toBe(1);
expect((ctx.ios.deleteDistributionCertificateAsync as any).mock.calls.length).toBe(1);
// not supported in non-interactive mode
expect((ctx.appStore.revokeDistributionCertificateAsync as any).mock.calls.length).toBe(0);
// expect provisioning profile deletion to be skipped because there arent any associated with the dist cert
expect((ctx.newIos.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(0);
expect((ctx.ios.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(0);
});
it('deletes the distribution certificate and its provisioning profile on Expo and Apple servers when there are App Dependencies in Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: false });
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(ctx);
const removeDistCertAction = new RemoveDistributionCertificate(
appLookupParams.account,
testDistCertFragmentOneDependency
@@ -53,15 +53,15 @@ describe('RemoveDistributionCertificate', () => {
await removeDistCertAction.runAsync(ctx);
// expect dist cert to be deleted on expo servers
expect((ctx.newIos.deleteDistributionCertificateAsync as any).mock.calls.length).toBe(1);
expect((ctx.ios.deleteDistributionCertificateAsync as any).mock.calls.length).toBe(1);
// expect dist cert to be deleted on apple portal
expect((ctx.appStore.revokeDistributionCertificateAsync as any).mock.calls.length).toBe(1);
// expect provisioning profile deletion to be invoked on expo servers
expect((ctx.newIos.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(1);
expect((ctx.ios.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(1);
});
it('errors when the distribution certificate has App Dependencies in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(ctx);
const removeDistCertAction = new RemoveDistributionCertificate(
appLookupParams.account,
testDistCertFragmentOneDependency
@@ -1,11 +1,11 @@
import { createCtxMock } from '../../../../__tests__/fixtures-context';
import { ManageIosBeta } from '../../../../manager/ManageIosBeta';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { RemoveProvisioningProfiles } from '../RemoveProvisioningProfile';
describe('RemoveProvisioningProfile', () => {
it('Basic Case', async () => {
const ctx = createCtxMock();
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(ctx);
const testProvisioningProfile = {
id: 'test-id',
developerPortalIdentifier: 'test-developer-portal-identifier',
@@ -17,6 +17,6 @@ describe('RemoveProvisioningProfile', () => {
await removeProfilesAction.runAsync(ctx);
// expect provisioning profile to be deleted from servers
expect((ctx.newIos.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(1);
expect((ctx.ios.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(1);
});
});
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more