Compare commits

...
182 Commits
Author SHA1 Message Date
Dominik Sokal dc86f27199 v0.14.1 2021-05-11 11:23:22 +02:00
jkh d97ed80867 [eas-cli] set default urql requestPolicy to network-only (#397)
* [eas-cli] set default urql requestPolicy to network-only

* add changelog
2021-05-11 11:14:55 +02:00
Quinlan Jung 3601b448fe [eas-cli] create keystore action (#387)
* [eas-cli] create android keystore action

* 1: some pr feedback
2021-05-10 16:38:17 -07:00
Quinlan Jung 68aab13d51 [eas-cli] Keystore utils and fixtures (#386)
* [eas-cli] Keystore utils and fixtures

validate using pem decryption instead

* 0: some pr feedback

* pr feedback
2021-05-10 16:25:07 -07:00
Brent Vatne 6cef13d756 Update CHANGELOG 2021-05-10 11:36:59 -07:00
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
Dominik Sokal b650f76979 v0.8.0 2021-04-06 11:12:43 +02:00
Dominik Sokal 96e59c198d update CHANGELOG.md 2021-04-06 11:11:47 +02:00
Dominik Sokal bf1704d268 use graphql for generating upload sessions (#307)
* use graphql for generating upload sessions

* address PR feedback

* fix tests
2021-04-06 11:06:47 +02:00
Dominik Sokal 279dfd00c8 eas build - use GraphQL instead of REST endpoints for build queries (#306)
* use graphql for printing pending builds

* use graphql for polling build status

* remove some unused code
2021-04-02 12:10:59 +02:00
Dominik Sokal 4d2577865a eas build - use GraphQL instead of REST endpoints (#303)
* regenerate graphql schema

* use graphql mutations for creating builds
2021-04-02 12:06:10 +02:00
Quinlan Jung 9d88fba40e [eas-cli] configure provisioning profile using new api (#299)
* [eas-cli] configure provisioning profile using new api

change positional args

* configure prov prof: pr feedback

* fix lint
2021-04-01 23:16:34 -07:00
Juwan Wheatley 9d2353b4f7 [eas-cli] use flags for secrets:create and rename target to scope (#300)
* use flags instead of args for secrets:create

* rename target to scope

* show secret ID before timestamp

* add changelog entries and note about name validation source of truth
2021-04-01 16:13:44 -04:00
Dominik Sokal 44b338d50a unhide secrets:create and secrets:delete (#302) 2021-04-01 19:06:23 +02:00
Bartłomiej Klocek 62114cab3b [Submit] Skip tar extraction logic in CLI (#297)
* [submit] Skip tar extraction logic for URL

* Remove tar extraction utils

* Update CHANGELOG

* Simplified path handling code

* New unit tests

* Add URL validation
2021-04-01 18:11:23 +02:00
Dominik Sokal d426fb0355 fix string interpolation 2021-04-01 11:34:32 +02:00
Quinlan Jung 2a8d6683ff [eas-cli] fix typo in tests 2021-03-31 17:57:46 -07:00
Juwan WheatleyandDominik Sokal 7b18165390 [eas-cli] fix secret name validation when prompted (#298)
* [eas-cli] fix secret name validation when prompted

* add changelog entry

* Update CHANGELOG.md

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

Co-authored-by: Dominik Sokal <[email protected]>
2021-03-31 19:43:46 -04:00
Dominik Sokal 814a44ac27 change cache object schema + rename secretEnvs -> environmentSecrets (#295)
* change cache object schema

* update CHANGELOG.md

* rename secretEnvs -> environmentSecrets

* update @expo/eas-build-job to 0.2.22
2021-03-31 10:20:04 +02:00
jkh f5483c8996 [eas-cli] add command eas channel:rollout (#290) 2021-03-30 13:43:37 -07:00
Juwan Wheatley acdbed4762 [eas-cli] fix "irreversible" typo (#282) 2021-03-30 16:24:19 +02:00
Dominik Sokal f09e4b54cf add distribution/release channel to graphql formatter 2021-03-30 16:23:41 +02:00
Vincent Thibault 859fdf5e46 Add distribution/channel informations in build:view/list (#284)
* Add new informations in build:view/list

* Update CHANGELOG.md

* Add nullish coalescing operator
2021-03-30 16:17:16 +02:00
Wojciech Kozyra 6aa72d5b7d [eas-cli] add analytics support (#162) 2021-03-29 11:58:05 +02:00
Quinlan Jung 886900df29 [eas-cli] make dist cert setup generic with graphql api (#294)
* [eas-cli] make dist cert setup generic

* fix tsc
2021-03-26 13:17:07 -07:00
Quinlan Jung a450fed1f8 [eas-cli] create provisioning profile using graphql api (#293) 2021-03-26 13:16:37 -07:00
Dominik Sokal 4c78100adb use graphql to create unpublished app (#288) 2021-03-26 09:01:42 +01:00
Dominik Sokal 0317e6fe71 refactor build:list and build:view to use graphql (#286) 2021-03-26 08:56:04 +01:00
jkh 6825877344 [eas-cli] add support for rollouts to channel:view command (#289) 2021-03-24 07:04:07 -07:00
Dominik Sokal a47ea88d28 Update CHANGELOG.md 2021-03-23 10:37:57 +01:00
Wojciech Kozyra a9e7f304ec Update CHANGELOG.md 2021-03-22 13:36:10 +01:00
Wojciech Kozyra d92a2ae3f4 v0.7.0 2021-03-22 13:33:34 +01:00
Dominik Sokal b7dea35da4 better message when build was canceled 2021-03-22 12:02:08 +01:00
Wojciech Kozyra 5a5f11752d Update @expo/eas-build-job 2021-03-22 12:02:08 +01:00
Wojciech Kozyra b6c300b5c2 [eas-json] Detect dependency cycle for build profiles (#283)
* [eas-json] Detect dependency cycle for build profiles

* Update CHANGELOG.md

* review feedback
2021-03-22 11:15:38 +01:00
Wojciech Kozyra 0117dbfa5f [eas-cli] verify that "name" in app.sjon can be used as iOS target name (#280) 2021-03-18 12:15:52 +01:00
Dominik Sokal c106f8a1a4 fix project utils calls after merge 2021-03-18 09:55:55 +01:00
Dominik Sokal 8b9741a70c refactor project utils (#278) 2021-03-18 09:51:30 +01:00
Juwan Wheatley 271b85fd74 [eas-cli] create commands for managing environment secrets (#275)
* set up graphql queries and mutations

* add secrets: list command

* add create command

* list all secrets

* add deletion command, add ID to list, add EAS check to create

* update changelog

* fix typo

* use target enum

* use US cancel

* move AccountResolver to generic place

* secret isn't reassigned

* check for eas in delete

* make mutation inputs required

* update generated graphql files

* use findAccountByName directly instead of AccountResolver

* update list format

* move accountresolver back into device manager file

* restore old devicemanager source

* lint
2021-03-17 14:50:40 -04:00
Dominik Sokal 21a481790b [ENG-438] allow for installing custom bundler version (#277)
* allow for installing custom bundler version

* update CHANGELOG.md

* bump @expo/eas-build-job to 0.2.20

* fix printing docs url
2021-03-17 13:52:52 +01:00
Wojciech Kozyra ab5d9237f1 [eas-cli] update external errors logic (#276) 2021-03-16 17:10:37 +01:00
Wojciech Kozyra 756853f2f3 [eas-cli] autmatically commit when EAS_BUILD_AUTOCOMMIT is set (#271) 2021-03-15 09:23:30 +01:00
Bartłomiej Klocek 5eb80d15f6 [ENG-509][submit] Fix local archive prompt (#273) 2021-03-12 11:34:31 +01:00
Dominik Sokal a98043b4ad print common error messages when builds fail (#272)
* print common error messages when builds fail

* update CHANGELOG

* address PR feedback

* address more PR feedback
2021-03-12 10:29:33 +01:00
jkh d0818932e7 [eas-cli] add --republish and --group flags to branch:publish command. (#253) 2021-03-11 05:37:18 -08:00
Dominik Sokal 23b60ed385 fix typo 2021-03-09 11:08:39 +01:00
Dominik Sokal 5d2811bf21 update CHANGELOG 2021-03-09 11:05:14 +01:00
Dominik Sokal 7b202f2aa7 v0.6.0 2021-03-09 10:42:59 +01:00
Dominik SokalandWojciech Kozyra fa6e9c1735 [ENG-444] support internal distribution in non-interactive builds (#269)
* support internal distribution in non-interactive builds

* update CHANGELOG.md

* address PR feedback

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

Co-authored-by: Wojciech Kozyra <[email protected]>

* address more PR feedback

* update error message

Co-authored-by: Wojciech Kozyra <[email protected]>
2021-03-09 10:40:52 +01:00
Wojciech KozyraandDominik Sokal c3300036d8 [eas-cli] handle unknown team name in device list (#268)
* [eas-cli] handle unknown team name in device list

* Update CHANGELOG.md

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

Co-authored-by: Dominik Sokal <[email protected]>
2021-03-05 14:05:06 +01:00
Dominik Sokal 5efcdb2940 [eas-cli] use Metadata type from @expo/eas-build-job (#267) 2021-03-05 09:43:23 +01:00
Wojciech Kozyra 8113450ea4 [eas-json] add experimental.disableIosBundleIdentifierValidation flag (#263) 2021-03-04 15:12:45 +01:00
Dominik Sokal d10b0c3c65 iOS builds: default to build configuration 'Release' (#266)
* iOS builds: default to build configuration 'Release'

* update CHANGELOG.md

* fix tests
2021-03-04 10:25:59 +01:00
Bartosz Kaszubowski e8de0e8712 add installation section to eas-cli readme (#255) 2021-03-03 16:53:50 -08:00
Bartłomiej Klocek e1da1099da [eas submit] UX improvements (#144)
* Add finish message for iOS Submissions

* Improve summary table

* Improve table look

* Update CHANGELOG
2021-03-03 16:57:08 +01:00
Dominik Sokal 264aa8fefa [ENG-326] send more build metadata: release channel, build profile, git commit hash (#265)
* send more build metadata: release channel, build profile, git commit hash

* update CHANGELOG.md

* address PR feedback

* upgrade @expo/config-plugins 1.0.19-alpha.0 -> 1.0.21-alpha.0

* address PR feedback
2021-03-03 16:00:59 +01:00
jkh 941d0294b7 [eas-cli] use generated graphql types (#262) 2021-03-03 06:53:54 -08:00
Wojciech Kozyra 366f4e8914 [eas-cli] display size of the archive (#264) 2021-03-03 14:11:31 +01:00
Dominik Sokal 1460c3c5a2 add first item to the PR checklist (#260) 2021-03-03 09:16:37 +01:00
jkh 6271472827 [eas-cli] add branch:delete command (#248) 2021-03-02 09:43:53 -08:00
Dominik Sokal 3bc4a054ae update CHANGELOG.md 2021-03-02 11:35:19 +01:00
Dominik Sokal dd85d559d7 v0.5.0 2021-03-02 11:14:43 +01:00
Wojciech Kozyra ab10b082e5 [eas-cli] fix submit with tar.gz files (#257)
* [eas-cli] fix submit with tar.gz files

* [eas-cli] update CHANGELOG.md
2021-03-02 10:52:19 +01:00
Dominik Sokal 60b60066f9 enable internal distribution support for self-managed credentials (#256)
* enable internal distribution support for self-managed credentials

* update CHANGELOG.md

* address PR feedback

* fix typo
2021-03-02 10:49:59 +01:00
Wojciech Kozyra 9e9e517698 [eas-cli] detect untracked files when checking for changes in repository (#259)
* [eas-cli] detect untracked files when checking for changes in repository

* Update CHANGELOG.md
2021-03-02 10:49:27 +01:00
Wojciech Kozyra 020c0f2b98 [eas-cli] commit all files before build (#251)
* [eas-cli] commit all files before build

* Update CHANGELOG.md
2021-03-01 10:08:25 +01:00
Wojciech Kozyra dd74ebdc51 [eas-cli] use error.expoApiV2ErrorCode to check error code (#252) 2021-03-01 09:24:22 +01:00
Wojciech KozyraandDominik Sokal ddf7381efb [eas-cli] EAS Build - build cache support (#247)
* [eas-cli] EAS Build - build cache support

* Update CHANGELOG

* Update CHANGELOG.md

Co-authored-by: Dominik Sokal <[email protected]>
2021-03-01 09:24:05 +01:00
Cedric van Puttenandjkh 3b67b912e4 [eas-cli] Add channel edit list and view commands (#249)
* [eas-cli] add channel:view command

* [eas-cli] add channel:list and channel:edit commands

* [eas-cli] Fix styling issue in channel edit command

* [eas-cli] Assume single branch and update when listing

* [eas-cli] Move graphql queries back to command files

* [eas-cli] Cleam up graphql limits

* [eas-cli] Show current branch when editing channel

* [eas-cli] Reword channel:edit description

Co-authored-by: jkh <[email protected]>

* [eas-cli] Reword channel:edit question

Co-authored-by: jkh <[email protected]>

* [eas-cli] Update table output of channel commands

* [eas-cli] Remove limit from properties and hardcode limit

Co-authored-by: jkh <[email protected]>

* [eas-cli] Reformat todos to fit repo better

Co-authored-by: jkh <[email protected]>

* [eas-cli] Remove limit from channel list query

Co-authored-by: jkh <[email protected]>
2021-02-27 14:54:02 +01:00
Cedric van Putten 37d7fa4510 [eas-cli] Fix json output for branch and channel commands (#250) 2021-02-27 14:30:03 +01:00
jkh 096601473a [eas-cli] fix tsc in prompt.ts (#254) 2021-02-26 07:42:28 -08:00
jkh 2e25ed81ca [eas-cli] rename fileHash to fileSHA256 (#244) 2021-02-23 04:35:48 -08:00
jkh 4e11ea1d77 [eas-cli] add default bundler for branch:publish (#237) 2021-02-23 04:28:37 -08:00
Dominik Sokal d9d470426b upgrade @expo/eas-build-job 0.2.12 -> 0.2.13 (#245)
* upgrade @expo/eas-build-job 0.2.12 -> 0.2.13

* fix tests

* update CHANGELOG.md
2021-02-23 12:52:24 +01:00
Dominik Sokal 119b4ad8b2 update CHANGELOG.md 2021-02-23 11:26:26 +01:00
Dominik Sokal ae1e9f000d update README.md 2021-02-23 11:25:46 +01:00
Dominik Sokal eb0505f8f8 v0.4.3 2021-02-23 11:12:02 +01:00
Dominik Sokal c8f7800ab5 update CHANGELOG.md 2021-02-23 11:10:15 +01:00
Wojciech Kozyra bc4e7becdd [eas-cli] use fixed version of @expo/eas-json (#243) 2021-02-22 15:03:06 +01:00
jkh 71ece19c40 [eas-cli] rename updateGroup, updateMessage, branchName, channelName (#242) 2021-02-19 12:07:39 -08:00
Dominik Sokal e9053c0d15 add support for ios simulator builds (#240)
* add support for ios simulator builds

* update CHANGELOG.md

* bump @expo/eas-build-job to 0.2.12

* fix lint errors
2021-02-19 13:35:06 +01:00
Dominik Sokal cf76296142 update CHANGELOG.md 2021-02-18 11:43:33 +01:00
Dominik Sokal 5e5d3626f4 v0.4.2 2021-02-18 11:36:14 +01:00
Dominik Sokal 5808c5b412 add CHANGELOG.md (#239) 2021-02-18 11:30:13 +01:00
Dominik Sokal cd64392d44 [eas-cli] bump @expo/config-plugins to 1.0.19-alpha.0 (#238) 2021-02-18 11:20:39 +01:00
Wojciech Kozyra 79f889e16c v0.4.1 2021-02-16 19:17:33 +01:00
Wojciech Kozyra 595bfecf1c [eas-cli] fix "buildType" is not allowed error 2021-02-16 19:15:10 +01:00
255 changed files with 18520 additions and 8250 deletions

No files matched your search

+6
View File
@@ -1,3 +1,9 @@
<!-- Thanks for contributing to _EAS CLI_! Before you submit your pull request, please make sure to check the following boxes by putting an x in the [ ] (don't: [x ], [ x], do: [x]) -->
# Checklist
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary.
# Why
Please describe the motivation for this PR, and link to relevant GitHub issues, forums posts, or feature requests.
+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
+244
View File
@@ -0,0 +1,244 @@
# Changelog
This is the log of notable changes to EAS CLI and related packages.
## main
### 🛠 Breaking changes
### 🎉 New features
### 🐛 Bug fixes
- Make the default urql requestPolicy 'network-only' [#397](https://github.com/expo/eas-cli/pull/397) by [@jkhales](https://github.com/jkhales))
### 🧹 Chores
## [0.14.0](https://github.com/expo/eas-cli/releases/tag/v0.14.0) - 2021-05-10
### 🛠 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))
### 🧹 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))
### 🐛 Bug fixes
- Fix environment secret creation prompt. ([#298](https://github.com/expo/eas-cli/pull/298) by [@fiberjw](https://github.com/fiberjw))
### 🧹 Chores
- Replace REST API calls with GraphQL counterparts. ([#286](https://github.com/expo/eas-cli/pull/286), [#288](https://github.com/expo/eas-cli/pull/288), [#303](https://github.com/expo/eas-cli/pull/303), [#306](https://github.com/expo/eas-cli/pull/306), [#307](https://github.com/expo/eas-cli/pull/307) by [@dsokal](https://github.com/dsokal))
## [0.7.0](https://github.com/expo/eas-cli/releases/tag/v0.7.0) - 2021-03-22
### 🎉 New features
- Print common error messages when builds fail. ([#272](https://github.com/expo/eas-cli/pull/272) by [@dsokal](https://github.com/dsokal))
- Commit automatically if `EAS_BUILD_AUTOCOMMIT` is set. ([#271](https://github.com/expo/eas-cli/pull/271) by [@wkozyra95](https://github.com/wkozyra95))
- Allow for installing custom `bundler` version on EAS Build. ([#277](https://github.com/expo/eas-cli/pull/277) by [@dsokal](https://github.com/dsokal))
- Add support for managing environment secrets. ([#275](https://github.com/expo/eas-cli/pull/275) by [@fiberjw](https://github.com/fiberjw))
### 🐛 Bug fixes
- Fix `eas submit` local archive prompt for `.aab` files when submitting for iOS. ([#273](https://github.com/expo/eas-cli/pull/273) by [@barthap](https://github.com/barthap))
- Verify whether "name" field in app.json contains any alphanumeric characters. ([#280](https://github.com/expo/eas-cli/pull/280) by [@wkozyra95](https://github.com/wkozyra95))
- Detect dependency cycles in eas.json build profiles. ([#283](https://github.com/expo/eas-cli/pull/283) by [@wkozyra95](https://github.com/wkozyra95))
## [0.6.0](https://github.com/expo/eas-cli/releases/tag/v0.6.0) - 2021-03-09
### 🛠 Breaking changes
- Generic iOS projects: build release builds by default. ([#266](https://github.com/expo/eas-cli/pull/266) by [@dsokal](https://github.com/dsokal))
### 🎉 New features
- Log the size of the archived project when uploading. ([#264](https://github.com/expo/eas-cli/pull/264) by [@wkozyra95](https://github.com/wkozyra95))
- Add more build metadata (release channel, build profile name, git commit hash). ([#265](https://github.com/expo/eas-cli/pull/265) by [@dsokal](https://github.com/dsokal))
- Display App Store link after successful submission. ([#144](https://github.com/expo/eas-cli/pull/144) by [@barthap](https://github.com/barthap))
- Add `experimental.disableIosBundleIdentifierValidation` flag to eas.json. ([#263](https://github.com/expo/eas-cli/pull/263) by [@wkozyra95](https://github.com/wkozyra95))
- Support internal distribution in non-interactive builds. ([#269](https://github.com/expo/eas-cli/pull/269) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Print Apple Team ID in the output of `device:list` when the team name is unknown. ([#268](https://github.com/expo/eas-cli/pull/268) by [@wkozyra95](https://github.com/wkozyra95))
## [0.5.0](https://github.com/expo/eas-cli/releases/tag/v0.5.0) - 2021-03-02
### 🎉 New features
- Add support for build cache. ([#247](https://github.com/expo/eas-cli/pull/247) by [@wkozyra95](https://github.com/wkozyra95))
- Enable internal distribution support for self-managed credentials. ([#256](https://github.com/expo/eas-cli/pull/256) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Make sure all files are committed before build. ([#251](https://github.com/expo/eas-cli/pull/251) by [@wkozyra95](https://github.com/wkozyra95))
- Fix `eas submit` support for tar.gz files. ([#257](https://github.com/expo/eas-cli/pull/257)) by [@wkozyra95](https://github.com/wkozyra95))
- Show untracked files when checking `git status`. ([#259](https://github.com/expo/eas-cli/pull/259) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
- Upgrade `@expo/eas-build-job` from `0.2.12` to `0.2.13`. ([#245](https://github.com/expo/eas-cli/pull/245) by [@dsokal](https://github.com/dsokal))
## [0.4.3](https://github.com/expo/eas-cli/releases/tag/v0.4.3) - 2021-02-23
### 🎉 New features
- Add support for iOS simulator builds. ([#240](https://github.com/expo/eas-cli/pull/240) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Use fixed version of `@expo/eas-json`. ([#243](https://github.com/expo/eas-cli/pull/243) by [@wkozyra95](https://github.com/wkozyra95))
## [0.4.2](https://github.com/expo/eas-cli/releases/tag/v0.4.2) - 2021-02-18
### 🐛 Bug fixes
- Fix detecting application target (iOS builds). ([#238](https://github.com/expo/eas-cli/pull/238) by [@dsokal](https://github.com/dsokal))
## [0.4.1](https://github.com/expo/eas-cli/releases/tag/v0.4.1) - 2021-02-16
### 🐛 Bug fixes
- Fix `"buildType" is not allowed` error. ([#595bf](https://github.com/expo/eas-cli/commit/595bfecf1cbff0f76e7fd2049fe16f6f38bbe150) by [@wkozyra95](https://github.com/wkozyra95))
## [0.4.0](https://github.com/expo/eas-cli/releases/tag/v0.4.0) - 2021-02-16
### 🎉 New features
- Add build:cancel command. ([#219](https://github.com/expo/eas-cli/pull/219) by [@wkozyra95](https://github.com/wkozyra95))
- Implement version auto increment for iOS builds. ([#231](https://github.com/expo/eas-cli/pull/231) by [@dsokal](https://github.com/dsokal))
- Add support for builder environment customizations. ([#230](https://github.com/expo/eas-cli/pull/230) by [@wkozyra95](https://github.com/wkozyra95))
- Add `schemeBuildConfiguration` option for generic iOS builds. ([#234](https://github.com/expo/eas-cli/pull/234) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Fix `--no-wait` flag for `eas build`. ([#226](https://github.com/expo/eas-cli/pull/226) by [@paul-ridgway](https://github.com/paul-ridgway))
- Fix running builds from project subdirectories. ([#229](https://github.com/expo/eas-cli/pull/229) by [@wkozyra95](https://github.com/wkozyra95))
+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.4.0"
"version": "0.14.1"
}
+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",
+176 -28
View File
@@ -7,27 +7,28 @@ EAS command line tool
[![Downloads/week](https://img.shields.io/npm/dw/eas-cli.svg)](https://npmjs.org/package/eas-cli)
[![License](https://img.shields.io/npm/l/eas-cli.svg)](https://github.com/expo/eas-cli/blob/main/package.json)
<!-- toc -->
* [eas-cli](#eas-cli)
* [Installation](#installation)
* [Usage](#usage)
* [Commands](#commands)
<!-- tocstop -->
# Installation
```sh
npm install -g eas-cli
# or
yarn global add eas-cli
```
# Usage
<!-- usage -->
```sh-session
$ npm install -g eas-cli
$ eas COMMAND
running command...
$ eas (-v|--version|version)
eas-cli/0.4.0 darwin-x64 node-v14.15.4
$ eas --help [COMMAND]
USAGE
$ eas COMMAND
...
```sh
eas COMMAND
# runs the command
eas (-v|--version|version)
# prints the version
eas --help COMMAND
# outputs help for specific command
```
<!-- usagestop -->
# Commands
@@ -35,6 +36,7 @@ USAGE
* [`eas account:login`](#eas-accountlogin)
* [`eas account:logout`](#eas-accountlogout)
* [`eas account:view`](#eas-accountview)
* [`eas analytics [STATUS]`](#eas-analytics-status)
* [`eas build`](#eas-build)
* [`eas build:cancel [BUILD_ID]`](#eas-buildcancel-build_id)
* [`eas build:configure`](#eas-buildconfigure)
@@ -45,7 +47,15 @@ USAGE
* [`eas device:list`](#eas-devicelist)
* [`eas device:view [UDID]`](#eas-deviceview-udid)
* [`eas help [COMMAND]`](#eas-help-command)
* [`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`
@@ -59,7 +69,7 @@ ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.4.0/build/commands/account/login.ts)_
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/account/login.ts)_
## `eas account:logout`
@@ -73,7 +83,7 @@ ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.4.0/build/commands/account/logout.ts)_
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/account/logout.ts)_
## `eas account:view`
@@ -87,7 +97,18 @@ ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.4.0/build/commands/account/view.ts)_
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/account/view.ts)_
## `eas analytics [STATUS]`
View or change analytics settings
```
USAGE
$ eas analytics [STATUS]
```
_See code: [build/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/analytics.ts)_
## `eas build`
@@ -99,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
@@ -106,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.4.0/build/commands/build/index.ts)_
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -117,7 +140,7 @@ USAGE
$ eas build:cancel [BUILD_ID]
```
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.4.0/build/commands/build/cancel.ts)_
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/build/cancel.ts)_
## `eas build:configure`
@@ -132,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.4.0/build/commands/build/configure.ts)_
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/build/configure.ts)_
## `eas build:list`
@@ -148,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.4.0/build/commands/build/list.ts)_
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/build/list.ts)_
## `eas build:view [BUILD_ID]`
@@ -159,7 +182,7 @@ USAGE
$ eas build:view [BUILD_ID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.4.0/build/commands/build/view.ts)_
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/build/view.ts)_
## `eas credentials`
@@ -170,7 +193,7 @@ USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.4.0/build/commands/credentials.ts)_
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/credentials.ts)_
## `eas device:create`
@@ -181,7 +204,7 @@ USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.4.0/build/commands/device/create.ts)_
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/device/create.ts)_
## `eas device:list`
@@ -195,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.4.0/build/commands/device/list.ts)_
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/device/list.ts)_
## `eas device:view [UDID]`
@@ -206,7 +229,7 @@ USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.4.0/build/commands/device/view.ts)_
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -225,6 +248,50 @@ OPTIONS
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
## `eas secret:create`
Create an environment secret on the current project or owner account.
```
USAGE
$ eas secret:create
OPTIONS
--name=name Name of the secret
--scope=(account|project) [default: project] Scope for the secret
--value=value Value of the secret
```
_See code: [build/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/secret/create.ts)_
## `eas secret:delete`
Delete an environment secret by ID.
```
USAGE
$ eas secret: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/secret/delete.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/secret/delete.ts)_
## `eas secret:list`
Lists environment secrets available for your current app
```
USAGE
$ eas secret:list
```
_See code: [build/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v0.14.1/build/commands/secret/list.ts)_
## `eas submit --platform=(android|ios)`
Submits build artifact to app store
@@ -303,5 +370,86 @@ EXAMPLES
and provide its App ID
```
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.4.0/build/commands/submit.ts)_
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.14.1/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.1/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.1/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.1/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.1/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.1/build/commands/webhook/view.ts)_
<!-- commandsstop -->
+2
View File
@@ -3,6 +3,8 @@ 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:
plugins:
File diff suppressed because it is too large. Load diff
+15 -8
View File
@@ -1,29 +1,34 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.4.0",
"version": "0.14.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "0.0.0-alpha.17",
"@amplitude/identify": "^1.5.0",
"@amplitude/node": "^1.5.0",
"@expo/apple-utils": "0.0.0-alpha.18",
"@expo/config": "~3.3.19",
"@expo/config-plugins": "1.0.18",
"@expo/eas-build-job": "0.2.10",
"@expo/eas-json": "^0.4.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/pkcs12": "^0.0.4",
"@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",
@@ -37,9 +42,9 @@
"graphql": "^15.3.0",
"graphql-tag": "^2.11.0",
"indent-string": "^4.0.0",
"jks-js": "^1.0.0",
"keychain": "^1.3.0",
"lodash": "^4.17.20",
"md5-file": "^5.0.0",
"mime": "^2.4.7",
"minimatch": "^3.0.4",
"node-fetch": "^2.6.1",
@@ -161,7 +166,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) {
+101
View File
@@ -0,0 +1,101 @@
import { Identify } from '@amplitude/identify';
import * as Amplitude from '@amplitude/node';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
import UserSettings from './user/UserSettings';
/**
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
* the src directory and would change the directory structure of the emitted files
* under the build directory
*/
const packageJSON = require('../package.json');
const PLATFORM_TO_ANALYTICS_PLATFORM: { [platform: string]: string } = {
darwin: 'Mac',
win32: 'Windows',
linux: 'Linux',
};
let client: Amplitude.NodeClient | null = null;
let userIdentifyCalled = false;
let identifyData: {
userId: string;
deviceId: string;
identify: Identify;
} | null = null;
export async function initAsync(): Promise<void> {
if (process.env.DISABLE_EAS_ANALYTICS) {
await UserSettings.setAsync('amplitudeEnabled', false);
}
const amplitudeEnabled = await UserSettings.getAsync('amplitudeEnabled', true);
if (amplitudeEnabled) {
const apiKey =
process.env.EXPO_STAGING || process.env.EXPO_LOCAL
? 'cdebbc678931403439486c4750781544'
: '4ac443afd5073c0df6169291db1d3495';
client = Amplitude.init(apiKey);
}
}
export async function setUserDataAsync(
userId: string,
properties: Record<string, any>
): Promise<void> {
const identify = new Identify();
Object.entries(properties).forEach(([property, value]) => {
identify.set(property, value);
});
const savedDeviceId = await UserSettings.getAsync('amplitudeDeviceId', null);
const deviceId = savedDeviceId ?? uuidv4();
if (!savedDeviceId) {
await UserSettings.setAsync('amplitudeDeviceId', deviceId);
}
identifyData = {
userId,
deviceId,
identify,
};
ensureUserIdentified();
}
export async function flushAsync(): Promise<void> {
if (client) {
await client.flush();
}
}
export function logEvent(name: string, properties: Record<string, any> = {}) {
if (client) {
ensureUserIdentified();
const { userId, deviceId } = identifyData ?? {};
client.logEvent({
event_type: name,
event_properties: properties,
...(userId && { user_id: userId }),
...(deviceId && { device_id: deviceId }),
...getContext(),
});
}
}
function ensureUserIdentified() {
if (client && !userIdentifyCalled && identifyData) {
client.identify(identifyData.userId, identifyData.deviceId, identifyData.identify);
userIdentifyCalled = true;
}
}
function getContext() {
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
return {
os_name: platform,
os_version: os.release(),
device_brand: platform,
device_model: platform,
app_version: packageJSON?.version,
};
}
@@ -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');
});
});
});
@@ -90,3 +90,21 @@ async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig)
throw new Error('Missing values in AndroidManifest.xml');
}
}
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
androidManifestPath
);
return AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidConfig.Updates.Config.RELEASE_CHANNEL
);
} catch (err) {
return null;
}
}
@@ -24,8 +24,12 @@ function isApplicationIdValid(applicationId: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(applicationId);
}
export async function ensureApplicationIdIsValidAsync(projectDir: string) {
const applicationId = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.Android);
export async function ensureApplicationIdIsValidAsync(projectDir: string, exp: ExpoConfig) {
const applicationId = await ensureAppIdentifierIsDefinedAsync({
projectDir,
platform: Platform.ANDROID,
exp,
});
if (!isApplicationIdValid(applicationId)) {
const configDescription = getProjectConfigDescription(projectDir);
Log.error(
+40 -19
View File
@@ -1,4 +1,4 @@
import { Workflow } from '@expo/eas-build-job';
import { Android, Metadata, Workflow } from '@expo/eas-build-job';
import { EasConfig } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -8,29 +8,32 @@ import AndroidCredentialsProvider, {
AndroidCredentials,
} from '../../credentials/android/AndroidCredentialsProvider';
import { createCredentialsContextAsync } from '../../credentials/context';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
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';
import { prepareJobAsync } from './prepareJob';
export async function prepareAndroidBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig
): Promise<() => Promise<string>> {
const buildCtx = createBuildContext<Platform.Android>({
): Promise<() => Promise<string | undefined>> {
const buildCtx = createBuildContext<Platform.ANDROID>({
commandCtx,
platform: Platform.Android,
platform: Platform.ANDROID,
easConfig,
});
const { buildProfile } = buildCtx;
if (
buildProfile.workflow === Workflow.Generic &&
buildProfile.workflow === Workflow.GENERIC &&
!(await fs.pathExists(path.join(commandCtx.projectDir, 'android')))
) {
throw new Error(
@@ -41,7 +44,7 @@ export async function prepareAndroidBuildAsync(
}
if (
buildProfile.workflow === Workflow.Generic &&
buildProfile.workflow === Workflow.GENERIC &&
buildProfile.distribution === 'internal' &&
buildProfile.gradleCommand?.match(/bundle/)
) {
@@ -60,30 +63,52 @@ This means that it will most likely produce an AAB and you will not be able to i
}
}
await ensureApplicationIdIsValidAsync(commandCtx.projectDir);
await ensureApplicationIdIsValidAsync(commandCtx.projectDir, commandCtx.exp);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {},
ensureCredentialsAsync: ensureAndroidCredentialsAsync,
ensureProjectConfiguredAsync: async () => {
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
if (buildCtx.buildProfile.workflow === Workflow.GENERIC) {
await validateAndSyncProjectConfigurationAsync(commandCtx.projectDir, commandCtx.exp);
}
},
prepareJobAsync,
sendBuildRequestAsync: async (
appId: string,
job: Android.Job,
metadata: Metadata
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
if (job.type === Workflow.GENERIC) {
const graphqlJob = transformGenericJob(job);
return await BuildMutation.createAndroidGenericBuildAsync({
appId,
job: graphqlJob,
metadata: graphqlMetadata,
});
} else {
const graphqlJob = transformManagedJob(job);
return await BuildMutation.createAndroidManagedBuildAsync({
appId,
job: graphqlJob,
metadata: graphqlMetadata,
});
}
},
});
}
function shouldProvideCredentials(ctx: BuildContext<Platform.Android>): boolean {
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
return (
ctx.buildProfile.workflow === Workflow.Managed ||
(ctx.buildProfile.workflow === Workflow.Generic && !ctx.buildProfile.withoutCredentials)
ctx.buildProfile.workflow === Workflow.MANAGED ||
(ctx.buildProfile.workflow === Workflow.GENERIC && !ctx.buildProfile.withoutCredentials)
);
}
async function ensureAndroidCredentialsAsync(
ctx: BuildContext<Platform.Android>
ctx: BuildContext<Platform.ANDROID>
): Promise<CredentialsResult<AndroidCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
@@ -100,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,
@@ -15,7 +15,7 @@ export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void
}
await AndroidConfig.EasBuild.configureEasBuildAsync(ctx.projectDir);
await configureApplicationIdAsync(ctx.projectDir, ctx.exp, ctx.allowExperimental);
await ensureApplicationIdIsValidAsync(ctx.projectDir);
await ensureApplicationIdIsValidAsync(ctx.projectDir, ctx.exp);
const easGradlePath = AndroidConfig.EasBuild.getEasBuildGradlePath(ctx.projectDir);
await gitAddAsync(easGradlePath, { intentToAdd: true });
@@ -0,0 +1,44 @@
import { Android } from '@expo/eas-build-job';
import {
AndroidGenericJobInput,
AndroidManagedBuildType,
AndroidManagedJobInput,
} from '../../graphql/generated';
import { transformProjectArchive } from '../graphql';
export function transformGenericJob(job: Android.GenericJob): AndroidGenericJobInput {
return {
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
secrets: job.secrets,
builderEnvironment: job.builderEnvironment,
cache: job.cache,
gradleCommand: job.gradleCommand,
artifactPath: job.artifactPath,
};
}
export function transformManagedJob(job: Android.ManagedJob): AndroidManagedJobInput {
return {
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
secrets: job.secrets,
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,34 +1,28 @@
import {
Android,
ArchiveSource,
ArchiveSourceType,
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 { readSecretEnvsAsync } from '../../credentials/credentialsJson/read';
import { getProjectAccountNameAsync } from '../../project/projectUtils';
import { readEnvironmentSecretsAsync } from '../../credentials/credentialsJson/read';
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;
}
export async function prepareJobAsync(
ctx: BuildContext<Platform.Android>,
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Job> {
if (ctx.buildProfile.workflow === Workflow.Generic) {
if (ctx.buildProfile.workflow === Workflow.GENERIC) {
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
} else if (ctx.buildProfile.workflow === Workflow.MANAGED) {
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else {
@@ -37,22 +31,23 @@ export async function prepareJobAsync(
}
interface CommonJobProperties {
platform: Platform.Android;
platform: Platform.ANDROID;
projectArchive: ArchiveSource;
builderEnvironment: Android.BuilderEnvironment;
cache: Cache;
secrets: {
buildCredentials?: {
keystore: Android.Keystore;
};
secretEnvs?: Record<string, string>;
environmentSecrets?: Record<string, string>;
};
}
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.Android>,
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Partial<CommonJobProperties>> {
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
const environmentSecrets = await readEnvironmentSecretsAsync(ctx.commandCtx.projectDir);
const credentials = jobData.credentials;
const buildCredentials = credentials
? {
@@ -61,18 +56,16 @@ async function prepareJobCommonAsync(
dataBase64: credentials.keystore.keystore,
keystorePassword: credentials.keystore.keystorePassword,
keyAlias: credentials.keystore.keyAlias,
keyPassword: credentials.keystore.keyPassword,
// TODO(quin): this is a hack for pkcs12 keystores which don't actually require a keyPassword
keyPassword: credentials.keystore.keyPassword ?? credentials.keystore.keystorePassword,
},
},
}
: {};
return {
platform: Platform.Android,
projectArchive: {
type: ArchiveSourceType.S3,
bucketKey: jobData.archiveBucketKey,
},
platform: Platform.ANDROID,
projectArchive: jobData.projectArchive,
builderEnvironment: {
image: ctx.buildProfile.image,
node: ctx.buildProfile.node,
@@ -80,15 +73,19 @@ async function prepareJobCommonAsync(
ndk: ctx.buildProfile.ndk,
env: ctx.buildProfile.env,
},
cache: {
...ctx.buildProfile.cache,
clear: ctx.commandCtx.clearCache,
},
secrets: {
...(secretEnvs ? { secretEnvs } : {}),
...(environmentSecrets ? { environmentSecrets } : {}),
...buildCredentials,
},
};
}
async function prepareGenericJobAsync(
ctx: BuildContext<Platform.Android>,
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData,
buildProfile: AndroidGenericBuildProfile
): Promise<Partial<Android.GenericJob>> {
@@ -96,7 +93,7 @@ async function prepareGenericJobAsync(
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.Generic,
type: Workflow.GENERIC,
gradleCommand: buildProfile.gradleCommand,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
@@ -105,17 +102,17 @@ async function prepareGenericJobAsync(
}
async function prepareManagedJobAsync(
ctx: BuildContext<Platform.Android>,
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData,
buildProfile: AndroidManagedBuildProfile
): Promise<Partial<Android.ManagedJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
const username = getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync());
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.Managed,
username: accountName,
type: Workflow.MANAGED,
username,
buildType: buildProfile.buildType,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
+114 -56
View File
@@ -1,19 +1,23 @@
import { Job } 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';
import { apiClient } from '../api';
import { UploadSessionType } from '../graphql/generated';
import { BuildResult } from '../graphql/mutations/BuildMutation';
import Log from '../log';
import { promptAsync } from '../prompts';
import { UploadType, uploadAsync } from '../uploads';
import { uploadAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import { platformDisplayNames } from './constants';
import { requestedPlatformDisplayNames } from './constants';
import { BuildContext } from './context';
import { runLocalBuildAsync } from './local';
import { collectMetadata } from './metadata';
import { AnalyticsEvent, Platform, TrackingContext } from './types';
import Analytics from './utils/analytics';
import { Platform, TrackingContext } from './types';
import Analytics, { Event } from './utils/analytics';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
commitChangedFilesAsync,
commitPromptAsync,
isGitStatusCleanAsync,
makeProjectTarballAsync,
@@ -25,7 +29,7 @@ export interface CredentialsResult<Credentials> {
credentials: Credentials;
}
interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration> {
interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration, TJob extends Job> {
ctx: BuildContext<TPlatform>;
projectConfiguration: ProjectConfiguration;
@@ -36,30 +40,34 @@ 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>;
}
export async function prepareBuildRequestForPlatformAsync<
TPlatform extends Platform,
Credentials,
ProjectConfiguration
>(builder: Builder<TPlatform, Credentials, ProjectConfiguration>): Promise<() => Promise<string>> {
ProjectConfiguration,
TJob extends Job
>(
builder: Builder<TPlatform, Credentials, ProjectConfiguration, TJob>
): Promise<() => Promise<string | undefined>> {
const credentialsResult = await withAnalyticsAsync(
async () => await builder.ensureCredentialsAsync(builder.ctx),
{
successEvent: AnalyticsEvent.GATHER_CREDENTIALS_SUCCESS,
failureEvent: AnalyticsEvent.GATHER_CREDENTIALS_FAIL,
successEvent: Event.GATHER_CREDENTIALS_SUCCESS,
failureEvent: Event.GATHER_CREDENTIALS_FAIL,
trackingCtx: builder.ctx.trackingCtx,
}
);
if (!builder.ctx.commandCtx.skipProjectConfiguration) {
await withAnalyticsAsync(async () => await builder.ensureProjectConfiguredAsync(builder.ctx), {
successEvent: AnalyticsEvent.CONFIGURE_PROJECT_SUCCESS,
failureEvent: AnalyticsEvent.CONFIGURE_PROJECT_FAIL,
successEvent: Event.CONFIGURE_PROJECT_SUCCESS,
failureEvent: Event.CONFIGURE_PROJECT_FAIL,
trackingCtx: builder.ctx.trackingCtx,
});
}
@@ -67,56 +75,61 @@ export async function prepareBuildRequestForPlatformAsync<
if (!(await isGitStatusCleanAsync())) {
Log.addNewLineIfNone();
await reviewAndCommitChangesAsync(
`[EAS Build] Run EAS Build for ${platformDisplayNames[builder.ctx.platform as Platform]}`,
`[EAS Build] Run EAS Build for ${
requestedPlatformDisplayNames[builder.ctx.platform as Platform]
}`,
{
nonInteractive: builder.ctx.commandCtx.nonInteractive,
}
);
}
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 await withAnalyticsAsync(
async () => {
if (Log.isDebug) {
Log.log(`Starting ${platformDisplayNames[job.platform]} build`);
}
const {
data: { buildId, deprecationInfo },
} = await apiClient
.post(`projects/${builder.ctx.commandCtx.projectId}/builds`, {
json: {
job,
metadata,
},
})
.json();
printDeprecationWarnings(deprecationInfo);
return buildId;
},
{
successEvent: AnalyticsEvent.BUILD_REQUEST_SUCCESS,
failureEvent: AnalyticsEvent.BUILD_REQUEST_FAIL,
trackingCtx: builder.ctx.trackingCtx,
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.'
);
}
);
} catch (error) {
const body = error?.response?.body;
if (body && JSON.parse(body)?.errors?.[0]?.code === 'TURTLE_DEPRECATED_JOB_FORMAT') {
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli');
throw error;
}
throw error;
}
};
}
@@ -132,19 +145,22 @@ async function uploadProjectAsync<TPlatform extends Platform>(
projectTarballPath = projectTarball.path;
const { bucketKey } = await uploadAsync(
UploadType.TURTLE_PROJECT_SOURCES,
UploadSessionType.EasBuildProjectSources,
projectTarball.path,
createProgressTracker({
total: projectTarball.size,
message: 'Uploading to EAS Build',
message: ratio =>
`Uploading to EAS Build (${formatBytes(projectTarball.size * ratio)} / ${formatBytes(
projectTarball.size
)})`,
completedMessage: `Uploaded to EAS`,
})
);
return bucketKey;
},
{
successEvent: AnalyticsEvent.PROJECT_UPLOAD_SUCCESS,
failureEvent: AnalyticsEvent.PROJECT_UPLOAD_FAIL,
successEvent: Event.PROJECT_UPLOAD_SUCCESS,
failureEvent: Event.PROJECT_UPLOAD_FAIL,
trackingCtx: ctx.trackingCtx,
}
);
@@ -155,11 +171,45 @@ async function uploadProjectAsync<TPlatform extends Platform>(
}
}
async function sendBuildRequestAsync<
TPlatform extends Platform,
Credentials,
ProjectConfiguration,
TJob extends Job
>(
builder: Builder<TPlatform, Credentials, ProjectConfiguration, TJob>,
job: TJob,
metadata: Metadata
): Promise<string> {
const { ctx } = builder;
return await withAnalyticsAsync(
async () => {
if (Log.isDebug) {
Log.log(`Starting ${requestedPlatformDisplayNames[job.platform]} build`);
}
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
ctx.commandCtx.projectId,
job,
metadata
);
printDeprecationWarnings(deprecationInfo);
return build.id;
},
{
successEvent: Event.BUILD_REQUEST_SUCCESS,
failureEvent: Event.BUILD_REQUEST_FAIL,
trackingCtx: ctx.trackingCtx,
}
);
}
async function withAnalyticsAsync<Result>(
fn: () => Promise<Result>,
analytics: {
successEvent: AnalyticsEvent;
failureEvent: AnalyticsEvent;
successEvent: Event;
failureEvent: Event;
trackingCtx: TrackingContext;
}
): Promise<Result> {
@@ -183,12 +233,17 @@ enum ShouldCommitChanges {
}
async function reviewAndCommitChangesAsync(
commitMessage: string,
initialCommitMessage: string,
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
): Promise<void> {
if (process.env.EAS_BUILD_AUTOCOMMIT) {
await commitChangedFilesAsync(initialCommitMessage);
Log.withTick('Committed changes.');
return;
}
if (nonInteractive) {
throw new Error(
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode to review and commit changes.'
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode or set EAS_BUILD_AUTOCOMMIT=1 in your environment.'
);
}
const { selected } = await promptAsync({
@@ -212,10 +267,13 @@ async function reviewAndCommitChangesAsync(
"Aborting, run the command again once you're ready. Make sure to commit any changes you've made."
);
} else if (selected === ShouldCommitChanges.Yes) {
await commitPromptAsync(commitMessage);
await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes.');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await showDiffAsync();
await reviewAndCommitChangesAsync(commitMessage, { nonInteractive, askedFirstTime: false });
await reviewAndCommitChangesAsync(initialCommitMessage, {
nonInteractive,
askedFirstTime: false,
});
}
}
+5 -5
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')),
};
@@ -132,7 +132,7 @@ enum ShouldCommitChanges {
}
async function reviewAndCommitChangesAsync(
commitMessage: string,
initialCommitMessage: string,
askedFirstTime: boolean = true
): Promise<void> {
const { selected } = await promptAsync({
@@ -152,10 +152,10 @@ async function reviewAndCommitChangesAsync(
});
if (selected === ShouldCommitChanges.Yes) {
await commitPromptAsync(commitMessage);
await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await showDiffAsync();
await reviewAndCommitChangesAsync(commitMessage, false);
await reviewAndCommitChangesAsync(initialCommitMessage, false);
}
}
+12 -6
View File
@@ -1,12 +1,18 @@
import { Platform, RequestedPlatform } from './types';
import { AppPlatform } from '../graphql/generated';
import { RequestedPlatform } from './types';
export const platformDisplayNames = {
[RequestedPlatform.iOS]: 'iOS',
export const requestedPlatformDisplayNames: Record<RequestedPlatform, string> = {
[RequestedPlatform.Ios]: 'iOS',
[RequestedPlatform.Android]: 'Android',
[RequestedPlatform.All]: 'Android and iOS',
};
export const platformEmojis = {
[Platform.iOS]: '🍎',
[Platform.Android]: '🤖',
export const appPlatformDisplayNames: Record<AppPlatform, string> = {
[AppPlatform.Android]: 'Android',
[AppPlatform.Ios]: 'iOS',
};
export const appPlatformEmojis = {
[AppPlatform.Ios]: '🍎',
[AppPlatform.Android]: '🤖',
};
+27 -12
View File
@@ -1,10 +1,13 @@
import { ExpoConfig, getConfig } from '@expo/config';
import { AndroidBuildProfile, EasConfig, iOSBuildProfile } from '@expo/eas-json';
import { ExpoConfig } from '@expo/config';
import { AndroidBuildProfile, EasConfig, IosBuildProfile } from '@expo/eas-json';
import { v4 as uuidv4 } from 'uuid';
import { getProjectAccountName } from '../project/projectUtils';
import { findAccountByName } from '../user/Account';
import { Actor } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { Platform, RequestedPlatform, TrackingContext } from './types';
import Analytics, { Event } from './utils/analytics';
export interface CommandContext {
requestedPlatform: RequestedPlatform;
@@ -15,8 +18,9 @@ export interface CommandContext {
accountName: string;
projectName: string;
exp: ExpoConfig;
trackingCtx: TrackingContext;
nonInteractive: boolean;
local: boolean;
clearCache: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
@@ -25,26 +29,29 @@ export interface CommandContext {
export async function createCommandContextAsync({
requestedPlatform,
profile,
exp,
projectDir,
projectId,
trackingCtx,
nonInteractive = false,
local,
clearCache = false,
skipCredentialsCheck = false,
skipProjectConfiguration = false,
waitForBuildEnd,
}: {
requestedPlatform: RequestedPlatform;
profile: string;
exp: ExpoConfig;
projectId: string;
projectDir: string;
trackingCtx: TrackingContext;
nonInteractive: boolean;
local: boolean;
clearCache: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
}): Promise<CommandContext> {
const user = await ensureLoggedInAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = getProjectAccountName(exp, user);
const projectName = exp.slug;
@@ -57,8 +64,9 @@ export async function createCommandContextAsync({
accountName,
projectName,
exp,
trackingCtx,
nonInteractive,
local,
clearCache,
skipCredentialsCheck,
skipProjectConfiguration,
waitForBuildEnd,
@@ -77,9 +85,9 @@ export interface ConfigureContext {
hasIosNativeProject: boolean;
}
type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
type PlatformBuildProfile<T extends Platform> = T extends Platform.ANDROID
? AndroidBuildProfile
: iOSBuildProfile;
: IosBuildProfile;
export interface BuildContext<T extends Platform> {
commandCtx: CommandContext;
@@ -101,13 +109,20 @@ export function createBuildContext<T extends Platform>({
if (!buildProfile) {
throw new Error(`${platform} build profile does not exist`);
}
const builderTrackingCtx = {
...commandCtx.trackingCtx,
const accountId = findAccountByName(commandCtx.user.accounts, commandCtx.accountName)?.id;
const trackingCtx = {
tracking_id: uuidv4(),
platform,
...(accountId && { account_id: accountId }),
account_name: commandCtx.accountName,
project_id: commandCtx.projectId,
project_type: buildProfile.workflow,
};
Analytics.logEvent(Event.BUILD_COMMAND, trackingCtx);
return {
commandCtx,
trackingCtx: builderTrackingCtx,
trackingCtx,
platform,
buildProfile,
};
+54 -40
View File
@@ -1,14 +1,16 @@
import { EasJsonReader } from '@expo/eas-json';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import ora from 'ora';
import { apiClient } from '../api';
import { BuildFragment, BuildStatus } from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log from '../log';
import { sleep } from '../utils/promise';
import { prepareAndroidBuildAsync } from './android/build';
import { CommandContext } from './context';
import { prepareIosBuildAsync } from './ios/build';
import { Build, BuildStatus, Platform, RequestedPlatform } from './types';
import { Platform, RequestedPlatform } from './types';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureGitRepoExistsAsync, ensureGitStatusIsCleanAsync } from './utils/repository';
@@ -17,17 +19,17 @@ 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();
if (commandCtx.waitForBuildEnd) {
const builds = await waitForBuildEndAsync(
commandCtx,
scheduledBuilds.map(i => i.buildId)
);
Log.newLine();
const builds = await waitForBuildEndAsync(scheduledBuilds.map(i => i.buildId));
printBuildResults(commandCtx.accountName, builds);
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
}
}
@@ -37,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(
@@ -47,76 +49,79 @@ 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 });
builds.push({ platform: Platform.ANDROID, sendBuildRequestAsync });
}
if (shouldBuildiOS) {
if (shouldBuildIos) {
const sendBuildRequestAsync = await prepareIosBuildAsync(commandCtx, easConfig);
builds.push({ platform: Platform.iOS, sendBuildRequestAsync });
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(
commandCtx: CommandContext,
buildIds: string[],
{ timeoutSec = 1800, intervalSec = 30 } = {}
): Promise<(Build | null)[]> {
{ 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();
let time = new Date().getTime();
const endTime = time + timeoutSec * 1000;
while (time <= endTime) {
const builds: (Build | null)[] = await Promise.all(
const builds: (BuildFragment | null)[] = await Promise.all(
buildIds.map(async buildId => {
try {
const { data } = await apiClient
.get(`projects/${commandCtx.projectId}/builds/${buildId}`)
.json();
return data;
return await BuildQuery.byIdAsync(buildId, { useCache: false });
} catch (err) {
return null;
}
})
);
if (builds.length === 1) {
switch (builds[0]?.status) {
case BuildStatus.FINISHED:
const build = nullthrows(builds[0]);
switch (build.status) {
case BuildStatus.Finished:
spinner.succeed('Build finished');
return builds;
case BuildStatus.IN_QUEUE:
case BuildStatus.InQueue:
spinner.text = 'Build queued...';
break;
case BuildStatus.CANCELED:
case BuildStatus.Canceled:
spinner.text = 'Build canceled';
spinner.stopAndPersist();
return builds;
case BuildStatus.IN_PROGRESS:
case BuildStatus.InProgress:
spinner.text = 'Build in progress...';
break;
case BuildStatus.ERRORED:
case BuildStatus.Errored:
spinner.fail('Build failed');
throw new Error(`Standalone build failed!`);
if (build.error) {
return builds;
} else {
throw new Error(`Standalone build failed!`);
}
default:
spinner.warn('Unknown status.');
throw new Error(`Unknown status: ${builds} - aborting!`);
}
} else {
if (builds.filter(build => build?.status === BuildStatus.FINISHED).length === builds.length) {
if (builds.filter(build => build?.status === BuildStatus.Finished).length === builds.length) {
spinner.succeed('All builds have finished');
return builds;
} else if (
builds.filter(build =>
build?.status
? [BuildStatus.FINISHED, BuildStatus.ERRORED, BuildStatus.CANCELED].includes(
? [BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(
build.status
)
: false
@@ -125,11 +130,11 @@ async function waitForBuildEndAsync(
spinner.fail('Some of the builds were canceled or failed.');
return builds;
} else {
const inQueue = builds.filter(build => build?.status === BuildStatus.IN_QUEUE).length;
const inProgress = builds.filter(build => build?.status === BuildStatus.IN_PROGRESS).length;
const errored = builds.filter(build => build?.status === BuildStatus.ERRORED).length;
const finished = builds.filter(build => build?.status === BuildStatus.FINISHED).length;
const canceled = builds.filter(build => build?.status === BuildStatus.CANCELED).length;
const inQueue = builds.filter(build => build?.status === BuildStatus.InQueue).length;
const inProgress = builds.filter(build => build?.status === BuildStatus.InProgress).length;
const errored = builds.filter(build => build?.status === BuildStatus.Errored).length;
const finished = builds.filter(build => build?.status === BuildStatus.Finished).length;
const canceled = builds.filter(build => build?.status === BuildStatus.Canceled).length;
const unknownState = builds.length - inQueue - inProgress - errored - finished;
spinner.text = [
inQueue && `Builds in queue: ${inQueue}`,
@@ -151,3 +156,12 @@ async function waitForBuildEndAsync(
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
);
}
function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | null)[]): void {
const failedBuilds = (maybeBuilds.filter(i => i) as BuildFragment[]).filter(
i => i.status === BuildStatus.Errored
);
if (failedBuilds.length > 0) {
process.exit(1);
}
}
-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 { platformDisplayNames } from './constants';
function logCredentials(target: 'local' | 'remote', platform: Platform) {
let message = `Using ${target} ${platformDisplayNames[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 = platformDisplayNames[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);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { ArchiveSource, ArchiveSourceType, Metadata, Workflow } from '@expo/eas-build-job';
import {
BuildCredentialsSource,
BuildMetadataInput,
BuildWorkflow,
DistributionType,
ProjectArchiveSourceInput,
ProjectArchiveSourceType,
} from '../graphql/generated';
export function transformProjectArchive(archiveSource: ArchiveSource): ProjectArchiveSourceInput {
if (archiveSource.type === ArchiveSourceType.S3) {
return {
type: ProjectArchiveSourceType.S3,
bucketKey: archiveSource.bucketKey,
};
} else if (archiveSource.type === ArchiveSourceType.URL) {
return {
type: ProjectArchiveSourceType.Url,
url: archiveSource.url,
};
} else {
throw new Error(`Unsupported project archive source type: '${archiveSource.type}'`);
}
}
export function transformMetadata(metadata: Metadata): BuildMetadataInput {
return {
appIdentifier: metadata.appIdentifier,
appName: metadata.appName,
appVersion: metadata.appVersion,
buildProfile: metadata.buildProfile,
cliVersion: metadata.cliVersion,
credentialsSource:
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
distribution: metadata.distribution && transformDistribution(metadata.distribution),
gitCommitHash: metadata.gitCommitHash,
releaseChannel: metadata.releaseChannel,
sdkVersion: metadata.sdkVersion,
trackingContext: metadata.trackingContext,
workflow: metadata.workflow && transformWorkflow(metadata.workflow),
};
}
function transformCredentialsSource(
credentialsSource: Metadata['credentialsSource']
): BuildCredentialsSource {
if (credentialsSource === 'local') {
return BuildCredentialsSource.Local;
} else {
return BuildCredentialsSource.Remote;
}
}
function transformDistribution(distribution: Metadata['distribution']): DistributionType {
if (distribution === 'internal') {
return DistributionType.Internal;
} else if (distribution === 'simulator') {
return DistributionType.Simulator;
} else {
return DistributionType.Store;
}
}
function transformWorkflow(workflow: Metadata['workflow']): BuildWorkflow {
if (workflow === Workflow.GENERIC) {
return BuildWorkflow.Generic;
} else {
return BuildWorkflow.Managed;
}
}
@@ -86,3 +86,12 @@ async function writeExpoPlistAsync(
await writePlistAsync(expoPlistPath, expoPlist);
await gitAddAsync(expoPlistPath, { intentToAdd: true });
}
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const expoPlist = await readExpoPlistAsync(projectDir);
return expoPlist[IOSConfig.Updates.Config.RELEASE_CHANNEL] ?? null;
} catch (err) {
return null;
}
}
@@ -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');
});
+44 -11
View File
@@ -1,33 +1,36 @@
import { IOSConfig } from '@expo/config-plugins';
import { Workflow } from '@expo/eas-build-job';
import { Ios, Metadata, Workflow } from '@expo/eas-build-job';
import { EasConfig } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import sortBy from 'lodash/sortBy';
import path from 'path';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
import { transformMetadata } from '../graphql';
import { Platform } from '../types';
import { ensureBundleIdentifierIsValidAsync } from './bundleIdentifer';
import { validateAndSyncProjectConfigurationAsync } from './configure';
import { ensureIosCredentialsAsync } from './credentials';
import { prepareJobAsync } from './prepareJob';
import { transformGenericJob, transformManagedJob } from './graphql';
import { prepareJobAsync, sanitizedTargetName } from './prepareJob';
export async function prepareIosBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig
): Promise<() => Promise<string>> {
const buildCtx = createBuildContext<Platform.iOS>({
): Promise<() => Promise<string | undefined>> {
const buildCtx = createBuildContext<Platform.IOS>({
commandCtx,
platform: Platform.iOS,
platform: Platform.IOS,
easConfig,
});
if (
buildCtx.buildProfile.workflow === Workflow.Generic &&
buildCtx.buildProfile.workflow === Workflow.GENERIC &&
!(await fs.pathExists(path.join(commandCtx.projectDir, 'ios')))
) {
throw new Error(
@@ -39,15 +42,23 @@ export async function prepareIosBuildAsync(
let iosBuildScheme: string | undefined;
let iosApplicationTarget: string | undefined;
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
if (buildCtx.buildProfile.workflow === Workflow.GENERIC) {
iosBuildScheme = buildCtx.buildProfile.scheme ?? (await resolveSchemeAsync(buildCtx));
iosApplicationTarget = await IOSConfig.Target.getApplicationTargetForSchemeAsync(
iosApplicationTarget = await IOSConfig.BuildScheme.getApplicationTargetForSchemeAsync(
buildCtx.commandCtx.projectDir,
iosBuildScheme
);
} else {
if (!commandCtx.exp.name) {
throw new Error('"expo.name" is required in your app.json');
}
iosApplicationTarget = sanitizedTargetName(commandCtx.exp.name);
if (!iosApplicationTarget) {
throw new Error('"expo.name" needs to contain some alphanumeric characters');
}
}
await ensureBundleIdentifierIsValidAsync(commandCtx.projectDir);
await ensureBundleIdentifierIsValidAsync(commandCtx.projectDir, commandCtx.exp);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
@@ -64,11 +75,33 @@ export async function prepareIosBuildAsync(
});
},
prepareJobAsync,
sendBuildRequestAsync: async (
appId: string,
job: Ios.Job,
metadata: Metadata
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
if (job.type === Workflow.GENERIC) {
const graphqlJob = transformGenericJob(job);
return await BuildMutation.createIosGenericBuildAsync({
appId,
job: graphqlJob,
metadata: graphqlMetadata,
});
} else {
const graphqlJob = transformManagedJob(job);
return await BuildMutation.createIosManagedBuildAsync({
appId,
job: graphqlJob,
metadata: graphqlMetadata,
});
}
},
});
}
async function resolveSchemeAsync(ctx: BuildContext<Platform.iOS>): Promise<string> {
const schemes = IOSConfig.Scheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
async function resolveSchemeAsync(ctx: BuildContext<Platform.IOS>): Promise<string> {
const schemes = IOSConfig.BuildScheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
if (schemes.length === 1) {
return schemes[0];
}
@@ -21,8 +21,12 @@ function isBundleIdentifierValid(bundleIdentifier: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(bundleIdentifier);
}
export async function ensureBundleIdentifierIsValidAsync(projectDir: string) {
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.iOS);
export async function ensureBundleIdentifierIsValidAsync(projectDir: string, exp: ExpoConfig) {
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync({
projectDir,
platform: Platform.IOS,
exp,
});
if (!isBundleIdentifierValid(bundleIdentifier)) {
const configDescription = getProjectConfigDescription(projectDir);
Log.error(
+28 -11
View File
@@ -1,7 +1,12 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import { Workflow } from '@expo/eas-build-job';
import { VersionAutoIncrement, iOSBuildProfile } from '@expo/eas-json';
import {
EasJsonReader,
IosBuildProfile,
IosGenericBuildProfile,
VersionAutoIncrement,
} from '@expo/eas-json';
import Log from '../../log';
import { ConfigureContext } from '../context';
@@ -17,8 +22,14 @@ export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasIosNativeProject) {
return;
}
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
await ensureBundleIdentifierIsValidAsync(ctx.projectDir);
const disableIosBundleIdentifierValidation = (
await new EasJsonReader(ctx.projectDir, 'all').readRawAsync()
)?.experimental?.disableIosBundleIdentifierValidation;
if (!disableIosBundleIdentifierValidation) {
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
await ensureBundleIdentifierIsValidAsync(ctx.projectDir, ctx.exp);
}
if (isExpoUpdatesInstalled(ctx.projectDir)) {
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
@@ -33,19 +44,25 @@ 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 bundleIdentifierFromPbxproj = IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(
projectDir
);
if (!bundleIdentifierFromPbxproj || bundleIdentifierFromPbxproj !== exp.ios?.bundleIdentifier) {
throw new Error(
'Bundle identifier is not configured correctly in your Xcode project. Please run "eas build:configure" to configure it.'
if (workflow === Workflow.GENERIC) {
const genericBuildProfile = buildProfile as IosGenericBuildProfile;
if (!genericBuildProfile.disableIosBundleIdentifierValidation) {
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(
projectDir
);
if (
!bundleIdentifierFromPbxproj ||
bundleIdentifierFromPbxproj !== exp.ios?.bundleIdentifier
) {
throw new Error(
'Bundle identifier is not configured correctly in your Xcode project. Please run "eas build:configure" to configure it.'
);
}
}
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
+22 -23
View File
@@ -1,19 +1,20 @@
import { Workflow } from '@expo/eas-build-job';
import { CredentialsSource, DistributionType } 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>
ctx: BuildContext<Platform.IOS>
): Promise<CredentialsResult<IosCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
@@ -27,7 +28,8 @@ export async function ensureIosCredentialsAsync(
},
workflow: ctx.buildProfile.workflow,
credentialsSource: ctx.buildProfile.credentialsSource,
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
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: DistributionType;
distribution: IosDistributionType;
enterpriseProvisioning?: IosEnterpriseProvisioning;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
}
@@ -46,28 +49,24 @@ 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,
};
}
function shouldProvideCredentials(ctx: BuildContext<Platform.iOS>): boolean {
return true;
function shouldProvideCredentials(ctx: BuildContext<Platform.IOS>): boolean {
return ctx.buildProfile.distribution !== 'simulator';
}
+93
View File
@@ -0,0 +1,93 @@
import { Env, Ios } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import {
DistributionType,
IosGenericJobInput,
IosJobSecretsInput,
IosManagedBuildType,
IosManagedJobInput,
IosSchemeBuildConfiguration,
} from '../../graphql/generated';
import { transformProjectArchive } from '../graphql';
export function transformGenericJob(job: Ios.GenericJob): IosGenericJobInput {
return {
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
distribution: job.distribution && transformDistributionType(job.distribution),
secrets: transformIosSecrets(job.secrets),
builderEnvironment: job.builderEnvironment,
cache: job.cache,
scheme: job.scheme,
schemeBuildConfiguration:
job.schemeBuildConfiguration &&
transformSchemeBuildConfiguration(job.schemeBuildConfiguration),
artifactPath: job.artifactPath,
};
}
export function transformManagedJob(job: Ios.ManagedJob): IosManagedJobInput {
return {
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
distribution: job.distribution && transformDistributionType(job.distribution),
secrets: transformIosSecrets(job.secrets),
builderEnvironment: job.builderEnvironment,
cache: job.cache,
buildType: job.buildType && transformBuildType(job.buildType),
username: job.username,
};
}
function transformDistributionType(distributionType: Ios.DistributionType): DistributionType {
if (distributionType === 'store') {
return DistributionType.Store;
} else if (distributionType === 'internal') {
return DistributionType.Internal;
} else {
return DistributionType.Simulator;
}
}
function transformIosSecrets(secrets: {
buildCredentials?: Ios.BuildCredentials;
env?: Env;
}): IosJobSecretsInput {
const buildCredentials: IosJobSecretsInput['buildCredentials'] = [];
for (const targetName of Object.keys(secrets.buildCredentials ?? {})) {
buildCredentials.push({
targetName,
provisioningProfileBase64: nullthrows(secrets.buildCredentials?.[targetName])
.provisioningProfileBase64,
distributionCertificate: nullthrows(secrets.buildCredentials?.[targetName])
.distributionCertificate,
});
}
return {
buildCredentials,
environmentSecrets: secrets.env,
};
}
function transformSchemeBuildConfiguration(
schemeBuildConfiguration: Ios.SchemeBuildConfiguration
): IosSchemeBuildConfiguration {
if (schemeBuildConfiguration === Ios.SchemeBuildConfiguration.DEBUG) {
return IosSchemeBuildConfiguration.Debug;
} else {
return IosSchemeBuildConfiguration.Release;
}
}
function transformBuildType(buildType: Ios.ManagedBuildType): IosManagedBuildType {
if (buildType === Ios.ManagedBuildType.DEVELOPMENT_CLIENT) {
return IosManagedBuildType.DevelopmentClient;
} else {
return IosManagedBuildType.Release;
}
}
+48 -47
View File
@@ -1,12 +1,5 @@
import {
ArchiveSource,
ArchiveSourceType,
Job,
Workflow,
iOS,
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';
@@ -14,15 +7,16 @@ import {
IosCredentials,
IosTargetCredentials,
isCredentialsMap,
readSecretEnvsAsync,
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;
@@ -31,13 +25,13 @@ interface JobData {
}
export async function prepareJobAsync(
ctx: BuildContext<Platform.iOS>,
ctx: BuildContext<Platform.IOS>,
jobData: JobData
): Promise<Job> {
if (ctx.buildProfile.workflow === Workflow.Generic) {
if (ctx.buildProfile.workflow === Workflow.GENERIC) {
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
} else if (ctx.buildProfile.workflow === Workflow.MANAGED) {
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else {
@@ -46,25 +40,27 @@ export async function prepareJobAsync(
}
interface CommonJobProperties {
platform: Platform.iOS;
platform: Platform.IOS;
projectArchive: ArchiveSource;
builderEnvironment: iOS.BuilderEnvironment;
releaseChannel: string;
builderEnvironment: Ios.BuilderEnvironment;
releaseChannel?: string;
distribution?: Ios.DistributionType;
cache: Cache;
secrets: {
buildCredentials: iOS.BuildCredentials;
secretEnvs?: Record<string, string>;
buildCredentials: Ios.BuildCredentials;
environmentSecrets?: Record<string, string>;
};
}
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.iOS>,
ctx: BuildContext<Platform.IOS>,
{
archiveBucketKey,
credentials,
targetName,
}: { archiveBucketKey: string; credentials?: IosCredentials; targetName?: string }
projectArchive,
}: { credentials?: IosCredentials; targetName?: string; projectArchive: ArchiveSource }
): Promise<Partial<CommonJobProperties>> {
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
const environmentSecrets = await readEnvironmentSecretsAsync(ctx.commandCtx.projectDir);
let buildCredentials: CommonJobProperties['secrets']['buildCredentials'] = {};
if (credentials && isCredentialsMap(credentials)) {
@@ -83,27 +79,30 @@ async function prepareJobCommonAsync(
}
return {
platform: Platform.iOS,
projectArchive: {
type: ArchiveSourceType.S3,
bucketKey: archiveBucketKey,
},
platform: Platform.IOS,
projectArchive,
distribution: ctx.buildProfile.distribution,
builderEnvironment: {
image: ctx.buildProfile.image,
node: ctx.buildProfile.node,
yarn: ctx.buildProfile.yarn,
bundler: ctx.buildProfile.bundler,
cocoapods: ctx.buildProfile.cocoapods,
fastlane: ctx.buildProfile.fastlane,
env: ctx.buildProfile.env,
},
cache: {
...ctx.buildProfile.cache,
clear: ctx.commandCtx.clearCache,
},
secrets: {
...(secretEnvs ? { secretEnvs } : {}),
...(environmentSecrets ? { environmentSecrets } : {}),
buildCredentials,
},
};
}
function prepareTargetCredentials(targetCredentials: IosTargetCredentials): iOS.TargetCredentials {
function prepareTargetCredentials(targetCredentials: IosTargetCredentials): Ios.TargetCredentials {
return {
provisioningProfileBase64: targetCredentials.provisioningProfile,
distributionCertificate: {
@@ -114,21 +113,24 @@ function prepareTargetCredentials(targetCredentials: IosTargetCredentials): iOS.
}
async function prepareGenericJobAsync(
ctx: BuildContext<Platform.iOS>,
ctx: BuildContext<Platform.IOS>,
jobData: JobData,
buildProfile: iOSGenericBuildProfile
): Promise<Partial<iOS.GenericJob>> {
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,
type: Workflow.GENERIC,
scheme: jobData.projectConfiguration.iosBuildScheme,
schemeBuildConfiguration: buildProfile.schemeBuildConfiguration,
schemeBuildConfiguration:
buildProfile.schemeBuildConfiguration === 'Auto'
? undefined
: buildProfile.schemeBuildConfiguration,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
@@ -136,23 +138,22 @@ async function prepareGenericJobAsync(
}
async function prepareManagedJobAsync(
ctx: BuildContext<Platform.iOS>,
ctx: BuildContext<Platform.IOS>,
jobData: JobData,
buildProfile: iOSManagedBuildProfile
): Promise<Partial<iOS.ManagedJob>> {
buildProfile: IosManagedBuildProfile
): Promise<Partial<Ios.ManagedJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
const targetName = sanitizedName(ctx.commandCtx.exp.name);
const username = getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync());
return {
...(await prepareJobCommonAsync(ctx, {
archiveBucketKey: jobData.archiveBucketKey,
credentials: jobData.credentials,
targetName,
targetName: jobData.projectConfiguration.iosApplicationTarget,
projectArchive: jobData.projectArchive,
})),
type: Workflow.Managed,
type: Workflow.MANAGED,
buildType: buildProfile.buildType,
username: accountName,
username,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
};
@@ -160,7 +161,7 @@ async function prepareManagedJobAsync(
// copy-pasted from expo-cli/packages/xdl/src/Exp.ts
// it's used in eject
function sanitizedName(name: string) {
export function sanitizedTargetName(name: string) {
return name
.replace(/[\W_]+/g, '')
.normalize('NFD')
+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;
}
+41 -7
View File
@@ -1,8 +1,14 @@
import { CredentialsSource, DistributionType } from '@expo/eas-json';
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';
import { BuildMetadata, Platform } from './types';
import { readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync } from './ios/UpdatesModule';
import { Platform } from './types';
import { isExpoUpdatesInstalled } from './utils/updates';
/**
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
@@ -18,17 +24,45 @@ export async function collectMetadata<T extends Platform>(
}: {
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
}
): Promise<BuildMetadata> {
): Promise<Metadata> {
return {
trackingContext: ctx.trackingCtx,
appVersion: ctx.commandCtx.exp.version!,
cliVersion: packageJSON.version,
workflow: ctx.buildProfile.workflow,
credentialsSource,
sdkVersion: ctx.commandCtx.exp.sdkVersion,
trackingContext: ctx.trackingCtx,
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
releaseChannel: await resolveReleaseChannel(ctx),
distribution: ctx.buildProfile.distribution ?? 'store',
appName: ctx.commandCtx.exp.name,
appIdentifier:
(await getAppIdentifierAsync(ctx.commandCtx.projectDir, ctx.platform)) ?? undefined,
(await getAppIdentifierAsync({
projectDir: ctx.commandCtx.projectDir,
platform: ctx.platform,
exp: ctx.commandCtx.exp,
})) ?? undefined,
buildProfile: ctx.commandCtx.profile,
gitCommitHash: await gitCommitHashAsync(),
username: getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync()),
};
}
async function resolveReleaseChannel<T extends Platform>(
ctx: BuildContext<T>
): Promise<string | undefined> {
if (!isExpoUpdatesInstalled(ctx.commandCtx.projectDir)) {
return undefined;
}
if (ctx.buildProfile.releaseChannel) {
return ctx.buildProfile.releaseChannel;
}
let maybeReleaseChannel: string | null;
if (ctx.platform === Platform.ANDROID) {
maybeReleaseChannel = await readAndroidReleaseChannelSafelyAsync(ctx.commandCtx.projectDir);
} else {
maybeReleaseChannel = await readIosReleaseChannelSafelyAsync(ctx.commandCtx.projectDir);
}
return maybeReleaseChannel ?? 'default';
}
+2 -110
View File
@@ -1,19 +1,11 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import {
AndroidBuildProfile,
CredentialsSource,
DistributionType,
iOSBuildProfile,
} from '@expo/eas-json';
export { Platform } from '@expo/eas-build-job';
export enum RequestedPlatform {
Android = 'android',
iOS = 'ios',
Ios = 'ios',
All = 'all',
}
export { Platform };
export enum BuildStatus {
IN_QUEUE = 'in-queue',
IN_PROGRESS = 'in-progress',
@@ -23,103 +15,3 @@ export enum BuildStatus {
}
export type TrackingContext = Record<string, string | number>;
export interface Build {
id: string;
status: BuildStatus;
platform: Platform;
createdAt: string;
updatedAt: string;
artifacts?: BuildArtifacts;
metadata?: Partial<BuildMetadata>;
}
interface BuildArtifacts {
buildUrl?: string;
logsUrl: string;
}
export type BuildMetadata = {
/**
* Application version (the expo.version key in app.json/app.config.js)
*/
appVersion: string;
/**
* EAS CLI version
*/
cliVersion: string;
/**
* Build workflow
* It's either 'generic' or 'managed'
*/
workflow: Workflow;
/**
* Credentials source
* Credentials could be obtained either from credential.json or EAS servers.
*/
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
/**
* Expo SDK version
* It's determined by the expo package version in package.json.
* It's undefined if the expo package is not installed for the project.
*/
sdkVersion?: string;
/**
* Release channel (for expo-updates)
* It's undefined if the expo-updates package is not installed for the project.
*/
releaseChannel?: string;
/**
* Tracking context
* It's used to track build process across different Expo services and tools.
*/
trackingContext: TrackingContext;
/**
* Distribution type
* Indicates whether this is a build for store or internal distribution.
*/
distribution: DistributionType;
/**
* App name (expo.name in app.json/app.config.js)
*/
appName?: string;
/**
* App identifier:
* - iOS builds: the bundle identifier (expo.ios.bundleIdentifier in app.json/app.config.js)
* - Android builds: the application id (expo.android.package in app.json/app.config.js)
*/
appIdentifier?: string;
};
export type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
? AndroidBuildProfile
: iOSBuildProfile;
export enum AnalyticsEvent {
BUILD_COMMAND = 'builds cli build command',
PROJECT_UPLOAD_SUCCESS = 'builds cli project upload success',
PROJECT_UPLOAD_FAIL = 'builds cli project upload fail',
GATHER_CREDENTIALS_SUCCESS = 'builds cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'builds cli gather credentials fail',
CONFIGURE_PROJECT_SUCCESS = 'builds cli configure project success',
CONFIGURE_PROJECT_FAIL = 'builds cli configure project fail',
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'builds cli build request fail',
BUILD_STATUS_COMMAND = 'builds cli build status',
CREDENTIALS_SYNC_COMMAND = 'builds cli credentials sync command',
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'builds cli credentials sync update local success',
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'builds cli credentials sync update local fail',
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'builds cli credentials sync update remote success',
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'builds cli credentials sync update remote fail',
}
+25 -1
View File
@@ -1,3 +1,27 @@
import { logEvent } from '../../analytics';
export enum Event {
BUILD_COMMAND = 'build cli build command',
PROJECT_UPLOAD_SUCCESS = 'build cli project upload success',
PROJECT_UPLOAD_FAIL = 'build cli project upload fail',
GATHER_CREDENTIALS_SUCCESS = 'build cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'build cli gather credentials fail',
CONFIGURE_PROJECT_SUCCESS = 'build cli configure project success',
CONFIGURE_PROJECT_FAIL = 'build cli configure project fail',
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'build cli build request fail',
BUILD_STATUS_COMMAND = 'build cli build status',
CREDENTIALS_SYNC_COMMAND = 'build cli credentials sync command',
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'build cli credentials sync update local success',
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'build cli credentials sync update local fail',
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'build cli credentials sync update remote success',
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',
}
export default {
logEvent: (a: any, b: any) => {},
logEvent(name: Event, properties: Record<string, any>) {
logEvent(name, properties);
},
};
@@ -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);
}
+42 -23
View File
@@ -1,57 +1,66 @@
import chalk from 'chalk';
import { BuildFragment, BuildStatus as GraphQLBuildStatus } from '../../graphql/generated';
import formatFields from '../../utils/formatFields';
import { platformDisplayNames } from '../constants';
import { Build, BuildStatus } from '../types';
import { appPlatformDisplayNames } from '../constants';
import { getBuildLogsUrl } from './url';
interface Options {
accountName: string;
}
export default function formatBuild(build: Build, { accountName }: Options) {
export function formatGraphQLBuild(build: BuildFragment) {
const actor = getActorName(build);
const account = build.project.__typename === 'App' ? build.project.ownerAccount.name : 'unknown';
const fields: { label: string; value: string }[] = [
{ label: 'ID', value: build.id },
{
label: 'Platform',
value: platformDisplayNames[build.platform],
value: appPlatformDisplayNames[build.platform],
},
{
label: 'Status',
get value() {
switch (build.status) {
case BuildStatus.IN_QUEUE:
case GraphQLBuildStatus.InQueue:
return chalk.blue('in queue');
case BuildStatus.IN_PROGRESS:
case GraphQLBuildStatus.InProgress:
return chalk.blue('in progress');
case BuildStatus.CANCELED:
case GraphQLBuildStatus.Canceled:
return chalk.gray('canceled');
case BuildStatus.FINISHED:
case GraphQLBuildStatus.Finished:
return chalk.green('finished');
case BuildStatus.ERRORED:
case GraphQLBuildStatus.Errored:
return chalk.red('errored');
default:
return 'unknown';
}
},
},
{
label: 'Distribution',
value: build.distribution?.toLowerCase() ?? chalk.gray('unknown'),
},
{
label: 'Release Channel',
value: build.releaseChannel ?? chalk.gray('unknown'),
},
{
label: 'Logs',
value: getBuildLogsUrl({ buildId: build.id, account: accountName }),
value: getBuildLogsUrl({ buildId: build.id, account }),
},
{
label: 'Artifact',
get value() {
if (build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS) {
if (
build.status === GraphQLBuildStatus.InQueue ||
build.status === GraphQLBuildStatus.InProgress
) {
}
switch (build.status) {
case BuildStatus.IN_QUEUE:
case BuildStatus.IN_PROGRESS:
case GraphQLBuildStatus.InQueue:
case GraphQLBuildStatus.InProgress:
return '<in progress>';
case BuildStatus.CANCELED:
case BuildStatus.ERRORED:
case GraphQLBuildStatus.Canceled:
case GraphQLBuildStatus.Errored:
return '---------';
case BuildStatus.FINISHED: {
case GraphQLBuildStatus.Finished: {
const url = build.artifacts?.buildUrl;
return url ? url : chalk.red('not found');
}
@@ -64,13 +73,23 @@ export default function formatBuild(build: Build, { accountName }: Options) {
{
label: 'Finished at',
value:
build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS
build.status === GraphQLBuildStatus.InQueue ||
build.status === GraphQLBuildStatus.InProgress
? '<in progress>'
: new Date(build.updatedAt).toLocaleString(),
},
// TODO: we can do it once we migrate to GraphQL
// { label: 'Started by', get: build => 'TODO' },
{ label: 'Started by', value: actor ?? 'unknown' },
];
return formatFields(fields);
}
const getActorName = (build: BuildFragment): string => {
if (!build.initiatingActor) {
return 'unknown';
} else if (build.initiatingActor.__typename === 'User') {
return build.initiatingActor.username;
} else {
return build.initiatingActor.firstName ?? 'unknown';
}
};
@@ -1,16 +1,23 @@
import { DistributionType } from '@expo/eas-json';
import assert from 'assert';
import chalk from 'chalk';
import indentString from 'indent-string';
import qrcodeTerminal from 'qrcode-terminal';
import Log from '../../log';
import { platformDisplayNames, platformEmojis } from '../constants';
import { Build } from '../types';
import { getBuildLogsUrl } from './url';
export interface DeprecationInfo {
type: 'user-facing' | 'internal';
message: string;
}
import {
BuildError,
BuildFragment,
BuildStatus,
DistributionType,
EasBuildDeprecationInfo,
EasBuildDeprecationInfoType,
} from '../../graphql/generated';
import Log, { learnMore } from '../../log';
import {
appPlatformDisplayNames,
appPlatformEmojis,
requestedPlatformDisplayNames,
} from '../constants';
import { getBuildLogsUrl, getInternalDistributionInstallUrl } from './url';
export function printLogsUrls(
accountName: string,
@@ -29,55 +36,80 @@ export function printLogsUrls(
buildId,
account: accountName,
});
Log.log(`${platformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`);
Log.log(
`${requestedPlatformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`
);
});
}
}
export function printBuildResults(accountName: string, builds: (Build | null)[]): void {
export function printBuildResults(accountName: string, builds: (BuildFragment | null)[]): void {
Log.newLine();
if (builds.length === 1) {
const [build] = builds;
assert(build, 'Build should be defined');
printBuildResult(accountName, build);
} else {
(builds.filter(i => i) as Build[])
.filter(build => build.status === 'finished')
.forEach(build => printBuildResult(accountName, build));
(builds.filter(i => i) as BuildFragment[]).forEach(build =>
printBuildResult(accountName, build)
);
}
}
function printBuildResult(accountName: string, build: Build): void {
if (build.metadata?.distribution === DistributionType.INTERNAL) {
function printBuildResult(accountName: string, build: BuildFragment): void {
Log.addNewLineIfNone();
if (build.status === BuildStatus.Errored) {
const userError = build.error;
Log.error(
`${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
} build failed${userError ? ':' : ''}`
);
if (userError) {
printUserError(userError);
}
return;
}
if (build.status === BuildStatus.Canceled) {
Log.error(
`${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
} build was canceled`
);
return;
}
if (build.distribution === DistributionType.Internal) {
const logsUrl = getBuildLogsUrl({
buildId: build.id,
account: accountName,
});
const installUrl = getInternalDistributionInstallUrl(build);
qrcodeTerminal.generate(installUrl, code => console.log(`${indentString(code, 2)}\n`));
Log.log(
`${platformEmojis[build.platform]} Open this link on your ${
platformDisplayNames[build.platform]
} devices to install the app:`
`${appPlatformEmojis[build.platform]} Open this link on your ${
appPlatformDisplayNames[build.platform]
} devices (or scan the QR code) to install the app:`
);
Log.log(`${chalk.underline(logsUrl)}`);
Log.newLine();
} else {
// TODO: it looks like buildUrl could possibly be undefined, based on the code below.
// we should account for this case better if it is possible
const url = build.artifacts?.buildUrl ?? '';
Log.log(`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} app:`);
Log.log(`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`);
Log.log(`${chalk.underline(url)}`);
Log.newLine();
}
}
export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): void {
export function printDeprecationWarnings(deprecationInfo?: EasBuildDeprecationInfo | null): void {
if (!deprecationInfo) {
return;
}
if (deprecationInfo.type === 'internal') {
if (deprecationInfo.type === EasBuildDeprecationInfoType.Internal) {
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
Log.warn("Changes won't affect your project config.");
Log.warn(deprecationInfo.message);
} else if (deprecationInfo.type === 'user-facing') {
} else if (deprecationInfo.type === EasBuildDeprecationInfoType.UserFacing) {
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
Log.warn(
'There might be some changes necessary to your project config, latest eas-cli will provide more specific error messages.'
@@ -88,3 +120,10 @@ export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): voi
Log.warn(deprecationInfo);
}
}
export function printUserError(error: BuildError): void {
Log.error(error.message);
if (error.docsUrl) {
Log.error(learnMore(error.docsUrl, { dim: false }));
}
}
+28 -13
View File
@@ -8,6 +8,7 @@ import { v4 as uuidv4 } from 'uuid';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { formatBytes } from '../../utils/files';
import {
doesGitRepoExistAsync,
getGitDiffOutputAsync,
@@ -43,12 +44,11 @@ async function ensureGitRepoExistsAsync(): Promise<void> {
Log.log("We're going to make an initial commit for you repository.");
await spawnAsync('git', ['add', '-A']);
await commitPromptAsync('Initial commit');
await commitPromptAsync({ initialCommitMessage: 'Initial commit', commitAllFiles: true });
}
async function isGitStatusCleanAsync(): Promise<boolean> {
const changes = await gitStatusAsync();
const changes = await gitStatusAsync({ showUntracked: true });
return changes.length === 0;
}
@@ -90,7 +90,7 @@ async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void
message: `Commit changes to git?`,
});
if (answer) {
await commitPromptAsync();
await commitPromptAsync({ commitAllFiles: true });
} else {
throw new Error('Please commit all changes. Aborting...');
}
@@ -120,7 +120,6 @@ async function makeProjectTarballAsync(): Promise<{ path: string; size: number }
try {
await spawnAsync('git', [
'clone',
'--local',
'--no-hardlinks',
'--depth',
'1',
@@ -130,19 +129,26 @@ async function makeProjectTarballAsync(): Promise<{ path: string; size: number }
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.',
]);
} finally {
// Stop the timer
} catch (err) {
clearTimeout(timer);
await fs.remove(shallowClonePath);
const duration = endTimer(compressTimerLabel);
if (spinner.isSpinning) {
const prettyTime = formatMilliseconds(duration);
spinner.succeed(`Compressed project files ${chalk.dim(prettyTime)}`);
spinner.fail();
}
throw err;
} finally {
await fs.remove(shallowClonePath);
}
clearTimeout(timer);
const { size } = await fs.stat(tarPath);
const duration = endTimer(compressTimerLabel);
if (spinner.isSpinning) {
const prettyTime = formatMilliseconds(duration);
spinner.succeed(
`Compressed project files ${chalk.dim(`${prettyTime} (${formatBytes(size)})`)}`
);
}
return { size, path: tarPath };
}
@@ -151,7 +157,13 @@ async function showDiffAsync() {
await gitDiffAsync({ withPager: outputTooLarge });
}
async function commitPromptAsync(initialCommitMessage?: string): Promise<void> {
async function commitPromptAsync({
initialCommitMessage,
commitAllFiles,
}: {
initialCommitMessage?: string;
commitAllFiles?: boolean;
} = {}): Promise<void> {
const { message } = await promptAsync({
type: 'text',
name: 'message',
@@ -159,6 +171,9 @@ async function commitPromptAsync(initialCommitMessage?: string): Promise<void> {
initial: initialCommitMessage,
validate: (input: string) => input !== '',
});
if (commitAllFiles) {
await spawnAsync('git', ['add', '-A']);
}
await commitChangedFilesAsync(message);
}
+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;
}
@@ -2,8 +2,7 @@ import { Command } from '@oclif/command';
import chalk from 'chalk';
import Log from '../../log';
import { getUserAsync } from '../../user/User';
import { getActorDisplayName } from '../../user/actions';
import { getActorDisplayName, getUserAsync } from '../../user/User';
export default class AccountView extends Command {
static description = 'show the username you are logged in as';
@@ -0,0 +1,25 @@
import { Command } from '@oclif/command';
import Log from '../log';
import UserSettings from '../user/UserSettings';
export default class AnalyticsView extends Command {
static description = 'View or change analytics settings';
static args = [{ name: 'STATUS', options: ['on', 'off'] }];
async run() {
const { STATUS: status } = this.parse(AnalyticsView).args;
if (status) {
await UserSettings.setAsync('amplitudeEnabled', status === 'on');
Log.withTick(`${status === 'on' ? 'Enabling' : 'Disabling'} analytics.`);
} else {
const amplitudeEnabled = await UserSettings.getAsync('amplitudeEnabled', null);
Log.log(
`Analytics are ${
amplitudeEnabled === false ? 'disabled' : 'enabled'
} on this eas-cli installation.`
);
}
}
}
+25 -23
View File
@@ -4,7 +4,11 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateBranch } from '../../graphql/generated';
import {
CreateUpdateBranchForAppMutation,
CreateUpdateBranchForAppMutationVariables,
UpdateBranch,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
@@ -14,35 +18,33 @@ import { getBranchNameAsync } from '../../utils/git';
export async function createUpdateBranchOnAppAsync({
appId,
branchName,
}: {
appId: string;
branchName: string;
}): Promise<Pick<UpdateBranch, 'id' | 'branchName'>> {
const data = await withErrorHandlingAsync(
name,
}: CreateUpdateBranchForAppMutationVariables): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateBranch: { createUpdateBranchForApp: Pick<UpdateBranch, 'id' | 'branchName'> } },
{ appId: string; branchName: string }
>(
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
gql`
mutation createUpdateBranchForApp($appId: ID!, $branchName: String!) {
mutation createUpdateBranchForApp($appId: ID!, $name: String!) {
updateBranch {
createUpdateBranchForApp(appId: $appId, branchName: $branchName) {
createUpdateBranchForApp(appId: $appId, name: $name) {
id
branchName
name
}
}
}
`,
{
appId,
branchName,
name,
}
)
.toPromise()
);
return data.updateBranch.createUpdateBranchForApp;
const newBranch = result.updateBranch.createUpdateBranchForApp;
if (!newBranch) {
throw new Error(`Could not create branch ${name}.`);
}
return newBranch;
}
export default class BranchCreate extends Command {
@@ -51,7 +53,7 @@ export default class BranchCreate extends Command {
static args = [
{
name: 'branchName',
name: 'name',
required: false,
description: 'Name of the branch to create',
},
@@ -66,7 +68,7 @@ export default class BranchCreate extends Command {
async run() {
let {
args: { branchName },
args: { name },
flags,
} = this.parse(BranchCreate);
@@ -82,14 +84,14 @@ export default class BranchCreate extends Command {
projectName: exp.slug,
});
if (!branchName) {
if (!name) {
const validationMessage = 'Branch name may not be empty.';
if (flags.json) {
throw new Error(validationMessage);
}
({ branchName } = await promptAsync({
({ name } = await promptAsync({
type: 'text',
name: 'branchName',
name: 'name',
message: 'Please name the branch:',
initial:
(await getBranchNameAsync()) || `branch-${Math.random().toString(36).substr(2, 4)}`,
@@ -97,7 +99,7 @@ export default class BranchCreate extends Command {
}));
}
const newBranch = await createUpdateBranchOnAppAsync({ appId: projectId, branchName });
const newBranch = await createUpdateBranchOnAppAsync({ appId: projectId, name });
if (flags.json) {
Log.log(newBranch);
@@ -105,7 +107,7 @@ export default class BranchCreate extends Command {
}
Log.withTick(
`️Created a new branch: ${chalk.bold(newBranch.branchName)} on project ${chalk.bold(
`️Created a new branch: ${chalk.bold(newBranch.name)} on project ${chalk.bold(
`@${accountName}/${exp.slug}`
)}.`
);
@@ -0,0 +1,156 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateBranchMutation,
DeleteUpdateBranchMutationVariables,
DeleteUpdateBranchResult,
GetBranchInfoQuery,
GetBranchInfoQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
async function getBranchInfoAsync({
appId,
name,
}: GetBranchInfoQueryVariables): Promise<{ branchId: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GetBranchInfoQuery, GetBranchInfoQueryVariables>(
gql`
query GetBranchInfo($appId: String!, $name: String!) {
app {
byId(appId: $appId) {
id
updateBranchByName(name: $name) {
id
name
}
}
}
}
`,
{
appId,
name,
}
)
.toPromise()
);
const branchId = data.app?.byId.updateBranchByName?.id;
if (!branchId) {
throw new Error(`Could not find branch ${name} on ${appId}`);
}
return { branchId };
}
async function deleteBranchOnAppAsync({
branchId,
}: DeleteUpdateBranchMutationVariables): Promise<DeleteUpdateBranchResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateBranchMutation, DeleteUpdateBranchMutationVariables>(
gql`
mutation DeleteUpdateBranch($branchId: ID!) {
updateBranch {
deleteUpdateBranch(branchId: $branchId) {
id
}
}
}
`,
{
branchId,
}
)
.toPromise()
);
return data.updateBranch.deleteUpdateBranch;
}
export default class BranchDelete extends Command {
static hidden = true;
static description = 'Republish an update group';
static args = [
{
name: 'name',
required: false,
description: 'Name of the branch to delete',
},
];
static flags = {
json: flags.boolean({
description: `return JSON with the edited branch's ID and name.`,
default: false,
}),
};
async run() {
let {
args: { name },
flags: { json: jsonFlag },
} = this.parse(BranchDelete);
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 accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: exp.slug,
});
if (!name) {
const validationMessage = 'branch name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please enter the name of the branch to delete:',
validate: value => (value ? true : validationMessage),
}));
}
const { branchId } = await getBranchInfoAsync({ appId: projectId, name });
if (!jsonFlag) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permamently delete branch: "${name}" and all of the updates published on it.` +
`\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Cancelled deletion of branch: "${name}".`);
process.exit(1);
}
}
const deletionResult = await deleteBranchOnAppAsync({
branchId,
});
if (jsonFlag) {
Log.log(JSON.stringify(deletionResult));
}
Log.withTick(
`️Deleted branch "${name}" and all of its updates on project ${chalk.bold(
`@${accountName}/${exp.slug}`
)}.`
);
}
}
+78 -62
View File
@@ -1,17 +1,76 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import { CLIError } from '@oclif/errors';
import chalk from 'chalk';
import CliTable from 'cli-table3';
import gql from 'graphql-tag';
import { format } from 'timeago.js';
import { graphqlClient } from '../../graphql/client';
import { RootQuery, Update, UpdateBranch } from '../../graphql/generated';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
BranchesByAppQuery,
BranchesByAppQueryVariables,
Maybe,
Robot,
Update,
User,
} from '../../graphql/generated';
import Log from '../../log';
import { findProjectRootAsync, getProjectFullNameAsync } from '../../project/projectUtils';
import { getActorDisplayName } from '../../user/actions';
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
.query<BranchesByAppQuery, BranchesByAppQueryVariables>(
gql`
query BranchesByAppQuery($fullName: String!, $limit: Int!) {
app {
byFullName(fullName: $fullName) {
id
fullName
updateBranches(offset: 0, limit: $limit) {
id
name
updates(offset: 0, limit: 1) {
id
actor {
__typename
id
... on User {
username
}
... on Robot {
firstName
}
}
createdAt
message
runtimeVersion
group
}
}
}
}
}
`,
{
fullName,
limit: BRANCHES_LIMIT,
}
)
.toPromise()
);
return data?.app?.byFullName.updateBranches ?? [];
}
export default class BranchList extends Command {
static hidden = true;
@@ -31,82 +90,39 @@ export default class BranchList extends Command {
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const fullName = await getProjectFullNameAsync(projectDir);
const branches = await this.listBranchesAsync({ fullName });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const fullName = await getProjectFullNameAsync(exp);
const branches = await listBranchesAsync({ fullName });
if (flags.json) {
Log.log(JSON.stringify(branches, null, 2));
} else {
const table = new CliTable({ head: ['Branch', 'Latest update'] });
const table = new CliTable({
head: ['branch', ...UPDATE_COLUMNS],
wordWrap: true,
});
table.push(
...branches.map((branch: UpdateBranch) => [
branch.branchName,
...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.`);
}
}
}
async listBranchesAsync({ fullName }: { fullName: string }): Promise<UpdateBranch[]> {
const { data, error } = await graphqlClient
.query<RootQuery>(
gql`
query BranchesByAppQuery($fullName: String!, $limit: Int!) {
app {
byFullName(fullName: $fullName) {
id
fullName
updateBranches(offset: 0, limit: $limit) {
id
branchName
updates(offset: 0, limit: 1) {
id
actor {
__typename
id
... on User {
username
}
... on Robot {
firstName
}
}
updatedAt
updateMessage
}
}
}
}
}
`,
{
fullName,
limit: BRANCHES_LIMIT,
}
)
.toPromise();
if (error) {
if (error.networkError) {
throw new CLIError(`Fetching branches failed: ${error.networkError.message}`);
} else {
throw new CLIError(error.graphQLErrors.map(e => e.message).join('\n'));
}
}
return data?.app?.byFullName.updateBranches ?? [];
}
}
function formatUpdate(update: Update | undefined): string {
export function formatUpdate(update: FormatUpdateParameter): string {
if (!update) {
return 'N/A';
}
const message = update.updateMessage ? `"${update.updateMessage}" ` : '';
return `${message}(${format(update.updatedAt, 'en_US')} by ${getActorDisplayName(
const message = update.message ? `"${update.message}" ` : '';
return `${message}(${format(update.createdAt, 'en_US')} by ${getActorDisplayName(
update.actor as any
)})`;
}
+196 -52
View File
@@ -1,58 +1,139 @@
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';
import Table from 'cli-table3';
import dateFormat from 'dateformat';
import gql from 'graphql-tag';
import { uniqBy } from 'lodash';
import ora from 'ora';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
Actor,
GetUpdateGroupAsyncQuery,
RootQueryUpdatesByGroupArgs,
Update,
UpdateInfoGroup,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import {
findProjectRootAsync,
getBranchByNameAsync,
getProjectAccountNameAsync,
} from '../../project/projectUtils';
import { buildUpdateInfoGroupAsync, collectAssets, uploadAssetsAsync } from '../../project/publish';
import { promptAsync } from '../../prompts';
buildBundlesAsync,
buildUpdateInfoGroupAsync,
collectAssets,
uploadAssetsAsync,
} from '../../project/publish';
import { promptAsync, selectAsync } from '../../prompts';
import { getLastCommitMessageAsync } from '../../utils/git';
import { formatUpdate, listBranchesAsync } from './list';
import { viewUpdateBranchAsync } from './view';
type PublishPlatforms = 'android' | 'ios';
async function getUpdateGroupAsync({
group,
}: RootQueryUpdatesByGroupArgs): Promise<GetUpdateGroupAsyncQuery['updatesByGroup']> {
const { updatesByGroup } = await withErrorHandlingAsync(
graphqlClient
.query<GetUpdateGroupAsyncQuery, RootQueryUpdatesByGroupArgs>(
gql`
query getUpdateGroupAsync($group: ID!) {
updatesByGroup(group: $group) {
id
group
runtimeVersion
manifestFragment
platform
message
}
}
`,
{
group,
}
)
.toPromise()
);
return updatesByGroup;
}
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 = {
message: flags.string({
description: 'Short message describing the updates.',
required: false,
}),
republish: flags.boolean({
description: 'republish an update group',
exclusive: ['input-dir', 'skip-bundler'],
}),
group: flags.string({
description: 'update group to republish',
exclusive: ['input-dir', 'skip-bundler'],
}),
'input-dir': flags.string({
description: 'location of the bundle',
default: 'dist',
required: false,
}),
branch: flags.string({
description: 'current name of the branch.',
}),
message: flags.string({
description: 'Short message describing the updates.',
required: false,
'skip-bundler': flags.boolean({
description: `skip running Expo CLI to bundle the app before publishing`,
default: false,
}),
json: flags.boolean({
description: `return a json with the edited branch's ID and name.`,
description: `return a json with the new update group.`,
default: false,
}),
};
async run() {
let {
flags: { json: jsonFlag, branch: branchName, message: updateMessage, 'input-dir': inputDir },
args: { name },
flags: {
json: jsonFlag,
message,
republish,
group,
'input-dir': inputDir,
'skip-bundler': skipBundler,
},
} = this.parse(BranchPublish);
// If a group was specified, that means we are republishing it.
republish = group ? true : republish;
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug, runtimeVersion },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(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'"
@@ -63,58 +144,110 @@ export default class BranchPublish extends Command {
projectName: slug,
});
if (!branchName) {
if (!name) {
const validationMessage = 'branch name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ branchName } = await promptAsync({
type: 'text',
name: 'branchName',
message: 'Please enter the name of the branch to publish on:',
validate: value => (value ? true : validationMessage),
}));
const branches = await listBranchesAsync({ fullName: `@${accountName}/${slug}` });
name = await selectAsync<string>(
'which branch would you like to publish on?',
branches.map(branch => {
return {
title: `${branch.name} ${chalk.grey(
`- current update: ${formatUpdate(branch.updates[0])}`
)}`,
value: branch.name,
};
})
);
}
assert(name, 'branch name must be specified.');
const { id: branchId, updates } = await viewUpdateBranchAsync({
appId: projectId,
name,
});
let updateInfoGroup: UpdateInfoGroup = {};
let oldMessage: string, oldRuntimeVersion: string;
if (republish) {
// If we are republishing, we don't need to worry about building the bundle or uploading the assets.
// Instead we get the `updateInfoGroup` from the update we wish to republish.
let updatesToRepublish: Pick<
Update,
'group' | 'message' | 'runtimeVersion' | 'manifestFragment' | 'platform'
>[];
if (group) {
updatesToRepublish = await getUpdateGroupAsync({ group });
} else {
const updateGroups = uniqBy(updates, u => u.group).map(update => ({
title: formatUpdateTitle(update),
value: update.group,
}));
if (updateGroups.length === 0) {
throw new Error(
`There are no updates on branch "${name}". Did you mean to do a regular publish?`
);
}
const updateGroup = await selectAsync<string>(
'which update would you like to republish?',
updateGroups
);
updatesToRepublish = updates.filter(update => update.group === updateGroup);
}
for (const update of updatesToRepublish) {
const { platform, manifestFragment } = update;
updateInfoGroup[platform as PublishPlatforms] = JSON.parse(manifestFragment);
}
// These are the same for each member of an update group
group = updatesToRepublish[0].group;
oldMessage = updatesToRepublish[0].message ?? '';
oldRuntimeVersion = updatesToRepublish[0].runtimeVersion;
} else {
// build bundle and upload assets for a new publish
if (!skipBundler) {
await buildBundlesAsync({ projectDir, inputDir });
}
const assetSpinner = ora('Uploading assets...').start();
try {
const assets = collectAssets(inputDir!);
await uploadAssetsAsync(assets);
updateInfoGroup = await buildUpdateInfoGroupAsync(assets);
assetSpinner.succeed('Uploaded assets!');
} catch (e) {
assetSpinner.fail('Failed to upload assets');
throw e;
}
}
if (!updateMessage) {
if (!message) {
const validationMessage = 'publish message may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ publishMessage: updateMessage } = await promptAsync({
({ publishMessage: message } = await promptAsync({
type: 'text',
name: 'publishMessage',
message: `Please enter a publication message.`,
initial: (await getLastCommitMessageAsync())?.trim(),
initial: republish
? `Republish "${oldMessage!}" - group: ${group}`
: (await getLastCommitMessageAsync())?.trim(),
validate: value => (value ? true : validationMessage),
}));
}
const { id: branchId } = await getBranchByNameAsync({
appId: projectId,
branchName: branchName!,
});
let updateInfoGroup;
const assetSpinner = ora('Uploading assets...').start();
try {
const assets = collectAssets(inputDir!);
await uploadAssetsAsync(assets);
updateInfoGroup = await buildUpdateInfoGroupAsync(assets);
assetSpinner.succeed('Uploaded assets!');
} catch (e) {
assetSpinner.fail('Failed to upload assets');
throw e;
}
let newUpdateGroup;
const publishSpinner = ora('Publishing...').start();
try {
newUpdateGroup = await PublishMutation.publishUpdateGroupAsync({
branchId,
updateInfoGroup,
runtimeVersion,
updateMessage,
runtimeVersion: republish ? oldRuntimeVersion! : runtimeVersion,
message,
});
publishSpinner.succeed('Published!');
} catch (e) {
@@ -123,7 +256,7 @@ export default class BranchPublish extends Command {
}
if (jsonFlag) {
Log.log(newUpdateGroup);
Log.log(JSON.stringify(newUpdateGroup));
} else {
const outputMessage = new Table({
wordWrap: true,
@@ -148,12 +281,23 @@ export default class BranchPublish extends Command {
});
outputMessage.push(
[chalk.dim('project'), `@${accountName}/${slug}`],
[chalk.dim('branch'), branchName],
[chalk.dim('branch'), name],
[chalk.dim('runtimeVersion'), runtimeVersion],
[chalk.dim('updateGroupID'), newUpdateGroup.updateGroup],
[chalk.dim('message'), updateMessage]
[chalk.dim('groupID'), newUpdateGroup.group],
[chalk.dim('message'), message]
);
Log.log(outputMessage.toString());
}
}
}
function formatUpdateTitle(
update: Pick<Update, 'message' | 'createdAt' | 'runtimeVersion'> & {
actor?: Pick<Actor, 'firstName'> | null;
}
): string {
const { message, createdAt, actor, runtimeVersion } = update;
return `[${dateFormat(createdAt, 'mmm dd HH:MM')} by ${
actor?.firstName ?? 'unknown'
}, runtimeVersion: ${runtimeVersion}] ${message}`;
}
+17 -20
View File
@@ -4,7 +4,12 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { EditUpdateBranchInput, UpdateBranch } from '../../graphql/generated';
import {
EditUpdateBranchInput,
EditUpdateBranchMutation,
EditUpdateBranchMutationVariables,
UpdateBranch,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
@@ -13,27 +18,18 @@ import { ensureLoggedInAsync } from '../../user/actions';
async function renameUpdateBranchOnAppAsync({
appId,
currentName,
name,
newName,
}: {
appId: string;
currentName: string;
newName: string;
}): Promise<UpdateBranch> {
}: EditUpdateBranchInput): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateBranch: { editUpdateBranch: UpdateBranch } },
{
input: EditUpdateBranchInput;
}
>(
.mutation<EditUpdateBranchMutation, EditUpdateBranchMutationVariables>(
gql`
mutation EditUpdateBranch($input: EditUpdateBranchInput!) {
updateBranch {
editUpdateBranch(input: $input) {
id
branchName
name
}
}
}
@@ -41,8 +37,8 @@ async function renameUpdateBranchOnAppAsync({
{
input: {
appId,
branchName: currentName,
newBranchName: newName,
name,
newName,
},
}
)
@@ -79,8 +75,9 @@ export default class BranchRename extends Command {
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const { exp } = await getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: exp.slug,
@@ -114,18 +111,18 @@ export default class BranchRename extends Command {
const editedBranch = await renameUpdateBranchOnAppAsync({
appId: projectId,
currentName: currentName!,
name: currentName!,
newName: newName!,
});
if (jsonFlag) {
Log.log(editedBranch);
Log.log(JSON.stringify(editedBranch));
return;
}
Log.withTick(
`️Renamed branch from ${currentName} to ${chalk.bold(
editedBranch.branchName
editedBranch.name
)} on project ${chalk.bold(`@${accountName}/${exp.slug}`)}.`
);
}
+57 -61
View File
@@ -6,64 +6,61 @@ import gql from 'graphql-tag';
import { groupBy } from 'lodash';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { Update } from '../../graphql/generated';
import {
Maybe,
Robot,
Update,
UpdateBranch,
User,
ViewBranchQuery,
ViewBranchQueryVariables,
} from '../../graphql/generated';
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;
type TruncatedUpdate = Pick<Update, 'updateGroup' | 'updateMessage' | 'createdAt' | 'actor'>;
type TruncatedUpdate = Pick<Update, 'group' | 'message' | 'createdAt' | 'actor'>;
async function viewUpdateBranchAsync({
export async function viewUpdateBranchAsync({
appId,
branchName,
}: {
appId: string;
branchName: string;
}): Promise<{
id: string;
branchName: string;
updates: TruncatedUpdate[];
}> {
name,
}: Pick<ViewBranchQueryVariables, 'appId' | 'name'>): Promise<
Pick<UpdateBranch, 'id' | 'name'> & {
updates: (Pick<
Update,
'id' | 'group' | 'message' | 'createdAt' | 'runtimeVersion' | 'platform' | 'manifestFragment'
> & {
actor?: Maybe<Pick<User, 'firstName' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
})[];
}
> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{
app: {
byId: {
updateBranchByBranchName: {
id: string;
branchName: string;
updates: TruncatedUpdate[];
};
};
};
},
{
appId: string;
branchName: string;
limit: number;
}
>(
.query<ViewBranchQuery, ViewBranchQueryVariables>(
gql`
query ViewBranch($appId: String!, $branchName: String!, $limit: Int!) {
query ViewBranch($appId: String!, $name: String!, $limit: Int!) {
app {
byId(appId: $appId) {
id
updateBranchByBranchName(branchName: $branchName) {
updateBranchByName(name: $name) {
id
branchName
name
updates(offset: 0, limit: $limit) {
id
updateGroup
updateMessage
group
message
createdAt
runtimeVersion
platform
manifestFragment
actor {
id
... on User {
firstName
username
}
... on Robot {
firstName
@@ -77,13 +74,17 @@ async function viewUpdateBranchAsync({
`,
{
appId,
branchName,
name,
limit: PAGE_LIMIT,
}
)
.toPromise()
);
return data.app.byId.updateBranchByBranchName;
const updateBranch = data.app?.byId.updateBranchByName;
if (!updateBranch) {
throw new Error(`Could not find branch "${name}"`);
}
return updateBranch;
}
export default class BranchView extends Command {
@@ -92,7 +93,7 @@ export default class BranchView extends Command {
static args = [
{
name: 'branchName',
name: 'name',
required: false,
description: 'Name of the branch to view',
},
@@ -107,7 +108,7 @@ export default class BranchView extends Command {
async run() {
let {
args: { branchName },
args: { name },
flags: { json: jsonFlag },
} = this.parse(BranchView);
@@ -115,23 +116,23 @@ export default class BranchView extends Command {
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
if (!branchName) {
if (!name) {
const validationMessage = 'Branch name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ branchName } = await promptAsync({
({ name } = await promptAsync({
type: 'text',
name: 'branchName',
name: 'name',
message: 'Please enter the name of the branch to view:',
validate: value => (value ? true : validationMessage),
}));
@@ -139,38 +140,33 @@ export default class BranchView extends Command {
const UpdateBranch = await viewUpdateBranchAsync({
appId: projectId,
branchName,
name,
});
const updates = Object.values(groupBy(UpdateBranch.updates, u => u.updateGroup)).map(
updateGroup => updateGroup[0]
const updates = Object.values(groupBy(UpdateBranch.updates, u => u.group)).map(
group => group[0]
);
if (jsonFlag) {
Log.log({ ...UpdateBranch, updates });
Log.log(JSON.stringify({ ...UpdateBranch, updates }));
return;
}
const updateGroupTable = new Table({
head: ['createdAt', 'updateMessage', 'updateGroup', 'actor'],
const groupTable = new Table({
head: UPDATE_COLUMNS,
wordWrap: true,
});
for (const update of updates) {
updateGroupTable.push([
new Date(update.createdAt).toLocaleString(),
update.updateMessage,
update.updateGroup,
update.actor?.firstName,
]);
groupTable.push([formatUpdate(update), update.runtimeVersion, update.group]);
}
Log.withTick(
`️Branch: ${chalk.bold(UpdateBranch.branchName)} on project ${chalk.bold(
`️Branch: ${chalk.bold(UpdateBranch.name)} on project ${chalk.bold(
`@${accountName}/${slug}`
)}. Branch ID: ${chalk.bold(UpdateBranch.id)}`
);
Log.log(chalk.bold('Recent update groups published on this branch:'));
Log.log(updateGroupTable.toString());
Log.log(groupTable.toString());
}
}
+13 -12
View File
@@ -1,12 +1,17 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import ora from 'ora';
import { platformEmojis } from '../../build/constants';
import { Platform } from '../../build/types';
import { appPlatformEmojis } from '../../build/constants';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { AppPlatform, Build, BuildStatus } from '../../graphql/generated';
import {
Build,
BuildStatus,
CancelBuildMutation,
CancelBuildMutationVariables,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import {
@@ -19,7 +24,7 @@ import { confirmAsync, selectAsync } from '../../prompts';
async function cancelBuildAsync(buildId: string): Promise<Pick<Build, 'id' | 'status'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<{ build: { cancel: { id: string; status: BuildStatus } } }>(
.mutation<CancelBuildMutation, CancelBuildMutationVariables>(
gql`
mutation CancelBuildMutation($buildId: ID!) {
build(buildId: $buildId) {
@@ -37,15 +42,10 @@ async function cancelBuildAsync(buildId: string): Promise<Pick<Build, 'id' | 'st
return data.build!.cancel;
}
const appPlatformMap = {
[AppPlatform.Android]: Platform.Android,
[AppPlatform.Ios]: Platform.iOS,
};
function formatUnfinishedBuild(
build: Pick<Build, 'id' | 'platform' | 'status' | 'createdAt'>
): string {
const platform = platformEmojis[appPlatformMap[build.platform]];
const platform = appPlatformEmojis[build.platform];
const startTime = new Date(build.createdAt).toLocaleString();
const status = chalk.blue(build.status === BuildStatus.InQueue ? 'in-queue' : 'in-progress');
return `${platform} Started at: ${startTime}, Status: ${status}, Id: ${build.id}`;
@@ -109,8 +109,9 @@ export default class BuildCancel extends Command {
const { BUILD_ID: buildIdFromArg } = this.parse(BuildCancel).args;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const projectFullName = await getProjectFullNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
if (buildIdFromArg) {
await ensureBuildExistsAsync(buildIdFromArg);
@@ -70,10 +70,9 @@ function logSuccess(platform: RequestedPlatform) {
- Run ${chalk.bold('eas build')} when you are ready to create your first build.
- Once the build is completed, run ${chalk.bold('eas submit')} to upload the app to ${storesText}
- ${learnMore(
'https://docs.expo.io/build/introduction',
'Learn more about other capabilities of EAS Build'
)}`);
- ${learnMore('https://docs.expo.io/build/introduction', {
learnMoreMessage: 'Learn more about other capabilities of EAS Build',
})}`);
}
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
@@ -88,7 +87,7 @@ async function promptForPlatformAsync(): Promise<RequestedPlatform> {
},
{
title: 'iOS',
value: RequestedPlatform.iOS,
value: RequestedPlatform.Ios,
},
{
title: 'Android',
+70 -116
View File
@@ -1,31 +1,25 @@
import { ExpoConfig, getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { apiClient } from '../../api';
import {
flushAsync as flushAnalyticsAsync,
initAsync as initAnalyticsAsync,
} from '../../analytics';
import { configureAsync } from '../../build/configure';
import { createCommandContextAsync } from '../../build/context';
import { buildAsync } from '../../build/create';
import { AnalyticsEvent, Build as BuildType, RequestedPlatform } from '../../build/types';
import Analytics from '../../build/utils/analytics';
import formatBuild from '../../build/utils/formatBuild';
import { RequestedPlatform } from '../../build/types';
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 {
@@ -49,122 +43,80 @@ 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);
const user = await ensureLoggedInAsync();
await initAnalyticsAsync(); // TODO: run in oclif hook for every command
try {
await ensureLoggedInAsync();
const nonInteractive = flags['non-interactive'];
if (!flags.platform && nonInteractive) {
throw new Error('--platform is required when building in non-interactive mode');
const nonInteractive = flags['non-interactive'];
if (!flags.platform && nonInteractive) {
throw new Error('--platform is required when building in non-interactive mode');
}
const platform =
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
let { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!flags.local && !(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
if (flags.local && !verifyOptionsForLocalBuilds(platform)) {
process.exitCode = 1;
return;
}
exp = (await ensureProjectConfiguredAsync(projectDir)) ?? exp;
const commandCtx = await createCommandContextAsync({
requestedPlatform: platform,
profile: flags.profile,
exp,
projectDir,
projectId,
nonInteractive,
clearCache: flags['clear-cache'],
skipCredentialsCheck: flags['skip-credentials-check'],
local: flags.local,
skipProjectConfiguration: flags['skip-project-configuration'],
waitForBuildEnd: flags.wait,
});
await buildAsync(commandCtx);
} finally {
await flushAnalyticsAsync(); // TODO: run in oclif hook for every command
}
const platform =
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
const accountName = await getProjectAccountNameAsync(projectDir);
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
accountName,
platform,
projectId,
user,
});
if (accountHasPendingBuilds) {
return;
}
await ensureProjectConfiguredAsync(projectDir);
const trackingCtx = {
tracking_id: uuidv4(),
requested_platform: flags.platform,
};
Analytics.logEvent(AnalyticsEvent.BUILD_COMMAND, trackingCtx);
const commandCtx = await createCommandContextAsync({
requestedPlatform: platform,
profile: flags.profile,
projectDir,
projectId,
trackingCtx,
nonInteractive,
skipCredentialsCheck: flags['skip-credentials-check'],
skipProjectConfiguration: flags['skip-project-configuration'],
waitForBuildEnd: flags.wait,
});
await buildAsync(commandCtx);
}
}
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 apiClient.get<{ data: BuildType }>(
`projects/${projectId}/builds/${pendingBuild?.id}`,
{
responseType: 'json',
}
);
})
);
for (const result of results) {
Log.log(formatBuild(result.body.data, { accountName }));
}
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;
}
}
@@ -180,7 +132,7 @@ async function promptForPlatformAsync(): Promise<RequestedPlatform> {
},
{
title: 'iOS',
value: RequestedPlatform.iOS,
value: RequestedPlatform.Ios,
},
{
title: 'Android',
@@ -191,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?',
@@ -209,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(
+59 -21
View File
@@ -1,14 +1,15 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import ora from 'ora';
import { apiClient } from '../../api';
import { Build } from '../../build/types';
import formatBuild from '../../build/utils/formatBuild';
import { BuildStatus, RequestedPlatform } from '../../build/types';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import { AppPlatform, BuildStatus as GraphQLBuildStatus } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
@@ -17,34 +18,45 @@ export default class BuildList extends Command {
static description = 'list all builds for your project';
static flags = {
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished', 'canceled'] }),
platform: flags.enum({
options: [RequestedPlatform.All, RequestedPlatform.Android, RequestedPlatform.Ios],
}),
status: flags.enum({
options: [
BuildStatus.IN_QUEUE,
BuildStatus.IN_PROGRESS,
BuildStatus.ERRORED,
BuildStatus.FINISHED,
BuildStatus.CANCELED,
],
}),
limit: flags.integer(),
};
async run() {
const { platform, status, limit = 10 } = this.parse(BuildList).flags;
const { platform: requestedPlatform, status: buildStatus, limit = 10 } = this.parse(
BuildList
).flags;
const platform = toAppPlatform(requestedPlatform);
const graphqlBuildStatus = toGraphQLBuildStatus(buildStatus);
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const accountName = await getProjectAccountNameAsync(projectDir);
const projectName = await getProjectFullNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const spinner = ora().start('Fetching the build list for the project…');
try {
const response = await apiClient.get<{ data: { builds: Build[] } }>(
`projects/${projectId}/builds`,
{
searchParams: { platform, status, limit },
responseType: 'json',
}
);
const { builds } = response.body.data;
const builds = await BuildQuery.allForAppAsync(projectId, {
limit,
platform,
status: graphqlBuildStatus,
});
if (builds.length) {
if (platform || status) {
if (platform || graphqlBuildStatus) {
spinner.succeed(
`Showing ${builds.length} matching builds for the project ${projectName}`
);
@@ -53,7 +65,7 @@ export default class BuildList extends Command {
}
const list = builds
.map(build => formatBuild(build, { accountName }))
.map(build => formatGraphQLBuild(build))
.join(`\n\n${chalk.dim('———')}\n\n`);
Log.log(`\n${list}`);
@@ -66,3 +78,29 @@ export default class BuildList extends Command {
}
}
}
const toAppPlatform = (requestedPlatform?: RequestedPlatform): AppPlatform | undefined => {
if (!requestedPlatform || requestedPlatform === RequestedPlatform.All) {
return undefined;
} else if (requestedPlatform === RequestedPlatform.Android) {
return AppPlatform.Android;
} else {
return AppPlatform.Ios;
}
};
const toGraphQLBuildStatus = (buildStatus: BuildStatus): GraphQLBuildStatus | undefined => {
if (!buildStatus) {
return undefined;
} else if (buildStatus === BuildStatus.IN_QUEUE) {
return GraphQLBuildStatus.InQueue;
} else if (buildStatus === BuildStatus.IN_PROGRESS) {
return GraphQLBuildStatus.InProgress;
} else if (buildStatus === BuildStatus.ERRORED) {
return GraphQLBuildStatus.Errored;
} else if (buildStatus === BuildStatus.FINISHED) {
return GraphQLBuildStatus.Finished;
} else {
return GraphQLBuildStatus.Canceled;
}
};
+24 -47
View File
@@ -1,14 +1,13 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import { HTTPError } from 'got';
import ora from 'ora';
import { apiClient } from '../../api';
import { Build } from '../../build/types';
import formatBuild from '../../build/utils/formatBuild';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import { BuildFragment } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
@@ -22,34 +21,24 @@ export default class BuildView extends Command {
const { BUILD_ID: buildId } = this.parse(BuildView).args;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const accountName = await getProjectAccountNameAsync(projectDir);
const projectName = await getProjectFullNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const spinner = ora().start('Fetching the build…');
try {
let build: Build;
let build: BuildFragment;
if (buildId == null) {
const response = await apiClient.get<{ data: { builds: Build[] } }>(
`projects/${projectId}/builds`,
{
searchParams: { limit: 1 },
responseType: 'json',
}
);
build = response.body.data.builds[0];
if (buildId) {
build = await BuildQuery.byIdAsync(buildId);
} else {
const response = await apiClient.get<{ data: Build }>(
`projects/${projectId}/builds/${buildId}`,
{
responseType: 'json',
}
);
build = response.body.data;
const builds = await BuildQuery.allForAppAsync(projectId, { limit: 1 });
if (builds.length === 0) {
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
return;
}
build = builds[0];
}
if (buildId) {
@@ -58,29 +47,17 @@ export default class BuildView extends Command {
spinner.succeed(`Showing the last build for the project ${projectName}`);
}
Log.log(`\n${formatBuild(build, { accountName })}`);
} catch (e) {
const error = e as HTTPError;
if (error.response.statusCode === 400) {
if (buildId) {
spinner.fail(
`Couldn't find a build for the project ${projectName} matching the id ${buildId}`
);
} else {
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
}
Log.log(`\n${formatGraphQLBuild(build)}`);
} catch (err) {
if (buildId) {
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
} else {
if (buildId) {
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
} else {
spinner.fail(
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
);
}
spinner.fail(
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
);
}
throw error;
throw err;
}
}
}
+31 -33
View File
@@ -4,7 +4,10 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateChannel } from '../../graphql/generated';
import {
CreateUpdateChannelOnAppMutation,
CreateUpdateChannelOnAppMutationVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
@@ -23,32 +26,21 @@ async function createUpdateChannelOnAppAsync({
appId: string;
channelName: string;
branchId: string;
}): Promise<UpdateChannel> {
}): Promise<CreateUpdateChannelOnAppMutation> {
// Point the new channel at a branch with its same name.
const branchMapping = JSON.stringify({
data: [{ branchId, branchMappingLogic: 'true' }],
version: 0,
});
const data = await withErrorHandlingAsync(
return await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateChannel: { createUpdateChannelForApp: UpdateChannel } },
{ appId: string; channelName: string; branchMapping: string }
>(
.mutation<CreateUpdateChannelOnAppMutation, CreateUpdateChannelOnAppMutationVariables>(
gql`
mutation CreateUpdateChannelForApp(
$appId: ID!
$channelName: String!
$branchMapping: String!
) {
mutation CreateUpdateChannelOnApp($appId: ID!, $name: String!, $branchMapping: String!) {
updateChannel {
createUpdateChannelForApp(
appId: $appId
channelName: $channelName
branchMapping: $branchMapping
) {
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
id
channelName
name
branchMapping
}
}
@@ -56,13 +48,12 @@ async function createUpdateChannelOnAppAsync({
`,
{
appId,
channelName,
name: channelName,
branchMapping,
}
)
.toPromise()
);
return data.updateChannel.createUpdateChannelForApp;
}
export default class ChannelCreate extends Command {
@@ -71,7 +62,7 @@ export default class ChannelCreate extends Command {
static args = [
{
name: 'channelName',
name: 'name',
required: false,
description: 'Name of the channel to create',
},
@@ -87,7 +78,7 @@ export default class ChannelCreate extends Command {
async run() {
let {
args: { channelName },
args: { name: channelName },
flags: { json: jsonFlag },
} = this.parse(ChannelCreate);
@@ -95,10 +86,9 @@ export default class ChannelCreate extends Command {
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
@@ -109,9 +99,9 @@ export default class ChannelCreate extends Command {
if (jsonFlag) {
throw new Error(validationMessage);
}
({ channelName } = await promptAsync({
({ name: channelName } = await promptAsync({
type: 'text',
name: 'channelName',
name: 'name',
message: 'Please name the channel:',
validate: value => (value ? true : validationMessage),
}));
@@ -122,32 +112,40 @@ export default class ChannelCreate extends Command {
try {
const existingBranch = await getBranchByNameAsync({
appId: projectId,
branchName: channelName,
name: channelName,
});
branchId = existingBranch.id;
branchMessage = `We found a branch with the same name`;
} catch (e) {
const newBranch = await createUpdateBranchOnAppAsync({
appId: projectId,
branchName: channelName,
name: channelName,
});
branchId = newBranch.id;
branchMessage = `We also went ahead and made a branch with the same name`;
}
const newChannel = await createUpdateChannelOnAppAsync({
const {
updateChannel: { createUpdateChannelForApp: newChannel },
} = await createUpdateChannelOnAppAsync({
appId: projectId,
channelName,
branchId,
});
if (!newChannel) {
throw new Error(
`Could not create channel with name ${channelName} on app with id ${projectId}`
);
}
if (jsonFlag) {
Log.log(newChannel);
Log.log(JSON.stringify(newChannel));
return;
}
Log.withTick(
`️Created a new channel ${chalk.bold(newChannel.channelName)} on project ${chalk.bold(
`️Created a new channel ${chalk.bold(newChannel.name)} on project ${chalk.bold(
`@${accountName}/${slug}`
)}. ${branchMessage} and have pointed the channel at it. You can now update your app by publishing!`
);
@@ -0,0 +1,198 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
GetChannelByNameToEditQuery,
GetChannelByNameToEditQueryVariables,
UpdateBranch,
UpdateChannel,
UpdateChannelBranchMappingMutation,
UpdateChannelBranchMappingMutationVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
findProjectRootAsync,
getBranchByNameAsync,
getProjectAccountNameAsync,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
async function getChannelByNameForAppAsync({
appId,
channelName,
}: GetChannelByNameToEditQueryVariables): Promise<
Pick<UpdateChannel, 'id' | 'name'> & {
updateBranches: Pick<UpdateBranch, 'id' | 'name'>[];
}
> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GetChannelByNameToEditQuery, GetChannelByNameToEditQueryVariables>(
gql`
query GetChannelByNameToEdit($appId: String!, $channelName: String!) {
app {
byId(appId: $appId) {
id
updateChannelByName(name: $channelName) {
id
name
updateBranches(offset: 0, limit: 1) {
id
name
}
}
}
}
}
`,
{ appId, channelName }
)
.toPromise()
);
const updateChannelByNameResult = data.app?.byId.updateChannelByName;
if (!updateChannelByNameResult) {
throw new Error(`Could not find a channel named ${channelName} on app with id ${appId}`);
}
return updateChannelByNameResult;
}
export async function updateChannelBranchMappingAsync({
channelId,
branchMapping,
}: UpdateChannelBranchMappingMutationVariables): Promise<{
id: string;
name: string;
branchMapping: string;
}> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
gql`
mutation UpdateChannelBranchMapping($channelId: ID!, $branchMapping: String!) {
updateChannel {
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
id
name
branchMapping
}
}
}
`,
{ channelId, branchMapping }
)
.toPromise()
);
const channel = data.updateChannel.editUpdateChannel;
if (!channel) {
throw new Error(`Could not fine channel with id ${channelId}`);
}
return data.updateChannel.editUpdateChannel!;
}
export default class ChannelEdit extends Command {
static hidden = true;
static description = 'Point a channel at a new branch.';
static args = [
{
name: 'name',
required: false,
description: 'Name of the channel to edit',
},
];
static flags = {
branch: flags.string({
description: 'Name of the branch to point to',
}),
json: flags.boolean({
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
default: false,
}),
};
async run() {
let {
args: { name: channelName },
flags: { branch: branchName, json: jsonFlag },
} = this.parse(ChannelEdit);
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 accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
if (!channelName) {
const validationMessage = 'A channel name is required to edit a specific channel.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name: channelName } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please enter the name of the channel to edit:',
validate: value => (value ? true : validationMessage),
}));
}
const existingChannel = await getChannelByNameForAppAsync({ appId: projectId, channelName });
if (existingChannel.updateBranches.length > 1) {
throw new Error(
'There is a rollout in progress. Please manage it with "channel:rollout" instead.'
);
}
const existingBranch = existingChannel.updateBranches[0];
Log.addNewLineIfNone();
Log.log(
chalk`Channel {bold ${existingChannel.name}} is currently set to branch {bold ${existingBranch.name}}`
);
Log.addNewLineIfNone();
if (!branchName) {
const validationMessage = 'branch name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name: branchName } = await promptAsync({
type: 'text',
name: 'name',
message: chalk`What branch should it change to?`,
validate: value => (value ? true : validationMessage),
}));
}
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
const channel = await updateChannelBranchMappingAsync({
channelId: existingChannel.id,
// todo: move branch mapping logic to utility
branchMapping: JSON.stringify({
data: [{ branchId: branch.id, branchMappingLogic: 'true' }],
version: 0,
}),
});
if (jsonFlag) {
Log.log(JSON.stringify(channel));
return;
}
Log.withTick(
chalk`Channel {bold ${channel.name}} is now set to branch {bold ${branch.name}}.\n`
);
Log.log(
chalk`Users with builds on channel {bold ${channel.name}} will now receive the active update on {bold ${branch.name}}.`
);
}
}
@@ -0,0 +1,113 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
GetAllChannelsForAppQuery,
GetAllChannelsForAppQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { logChannelDetails } from './view';
const CHANNEL_LIMIT = 10_000;
async function getAllUpdateChannelForAppAsync({
appId,
}: {
appId: string;
}): Promise<GetAllChannelsForAppQuery> {
return await withErrorHandlingAsync(
graphqlClient
.query<GetAllChannelsForAppQuery, GetAllChannelsForAppQueryVariables>(
gql`
query GetAllChannelsForApp($appId: String!, $offset: Int!, $limit: Int!) {
app {
byId(appId: $appId) {
id
updateChannels(offset: $offset, limit: $limit) {
id
name
branchMapping
updateBranches(offset: 0, limit: $limit) {
id
name
updates(offset: 0, limit: 1) {
id
group
message
runtimeVersion
createdAt
actor {
id
... on User {
username
}
... on Robot {
firstName
}
}
}
}
}
}
}
}
`,
{ appId, offset: 0, limit: CHANNEL_LIMIT }
)
.toPromise()
);
}
export default class ChannelList extends Command {
static hidden = true;
static description = 'List all channels on the current project.';
static flags = {
json: flags.boolean({
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
default: false,
}),
};
async run() {
const {
flags: { json: jsonFlag },
} = this.parse(ChannelList);
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 accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
const getAllUpdateChannelForAppResult = await getAllUpdateChannelForAppAsync({
appId: projectId,
});
const channels = getAllUpdateChannelForAppResult.app?.byId.updateChannels;
if (!channels) {
throw new Error(`Could not find channels on project with id ${projectId}`);
}
if (jsonFlag) {
Log.log(JSON.stringify(channels));
return;
}
Log.log(); // spacing
for (const channel of channels) {
Log.log(`Details of channel ${chalk.bold(chalk.cyan(channel.name))}:`);
logChannelDetails(channel);
}
}
}
@@ -0,0 +1,433 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import { GetChannelByNameForAppQuery, UpdateBranch } from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
findProjectRootAsync,
getBranchByNameAsync,
getProjectAccountNameAsync,
} from '../../project/projectUtils';
import { promptAsync, selectAsync } from '../../prompts';
import { updateChannelBranchMappingAsync } from './edit';
import { BranchMapping, getBranchMapping, getUpdateChannelByNameForAppAsync } from './view';
async function promptForRolloutPercentAsync({
promptMessage,
}: {
promptMessage: string;
}): Promise<number> {
const { name: rolloutPercent } = await promptAsync({
type: 'text',
name: 'name',
format: value => {
return parseInt(value, 10);
},
message: promptMessage,
initial: 0,
validate: (rolloutPercent: string): true | string => {
const floatValue = parseFloat(rolloutPercent);
return Number.isInteger(floatValue) && floatValue >= 0 && floatValue <= 100
? true
: 'The rollout percentage must be an integer between 0 and 100 inclusive.';
},
});
return rolloutPercent;
}
function getRolloutInfo(
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery
): {
newBranch: Pick<UpdateBranch, 'name' | 'id'>;
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
currentPercent: number;
} {
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(
branch => branch.id === newBranchId
)[0];
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}".`
);
}
const currentPercent = 100 * branchMapping.data[0].branchMappingLogic.operand;
return { newBranch, oldBranch, currentPercent };
}
async function startRolloutAsync({
channelName,
branchName,
percent,
jsonFlag,
projectId,
fullName,
currentBranchMapping,
getUpdateChannelByNameForAppResult,
}: {
channelName?: string;
branchName?: string;
percent?: number;
jsonFlag: boolean;
projectId: string;
fullName: string;
currentBranchMapping: BranchMapping;
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery;
}): Promise<{
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
}> {
if (!branchName) {
const validationMessage = 'A branch must be specified.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name: branchName } = await promptAsync({
type: 'text',
name: 'name',
message: `Select a branch to rollout onto ${channelName}`,
validate: value => (value ? true : validationMessage),
}));
}
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
const oldBranchId = currentBranchMapping.data[0].branchId;
if (branch.id === oldBranchId) {
throw new Error(
`channel "${channelName}" is already pointing at branch "${branchName}". Rollouts must be done with distinct branches.`
);
}
if (percent == null) {
if (jsonFlag) {
throw new Error(
'You must specify a percent with the --percent flag when initiating a rollout with the --json flag.'
);
}
const promptMessage = `What percent of users should be directed to the branch "${branchName}"?`;
percent = await promptForRolloutPercentAsync({ promptMessage });
}
const newBranchMapping = {
version: 0,
data: [
{
branchId: branch.id,
branchMappingLogic: {
operand: percent / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
},
currentBranchMapping.data[0],
],
};
const newChannelInfo = await updateChannelBranchMappingAsync({
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.id!,
branchMapping: JSON.stringify(newBranchMapping),
});
const oldBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.updateBranches.filter(
branch => branch.id === oldBranchId
)[0];
if (!oldBranch) {
throw new Error(
`Branch mapping is missing its only branch for channel "${channelName}" on app "${fullName}"`
);
}
const logMessage = `️Started a rollout of branch ${chalk.bold(
branchName
)} onto channel ${chalk.bold(channelName!)}! ${chalk.bold(
percent
)}% of users will be directed to branch ${chalk.bold(branchName)}, ${chalk.bold(
100 - percent
)}% to branch ${chalk.bold(oldBranch.name)}.`;
return { newChannelInfo, logMessage };
}
async function editRolloutAsync({
channelName,
percent,
jsonFlag,
currentBranchMapping,
getUpdateChannelByNameForAppResult,
}: {
channelName?: string;
percent?: number;
jsonFlag: boolean;
currentBranchMapping: BranchMapping;
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery;
}): Promise<{
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
}> {
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(
getUpdateChannelByNameForAppResult
);
if (percent == null) {
if (jsonFlag) {
throw new Error(
'A rollout is already in progress. If you wish to modify it you must use specify the new rollout percentage with the --percent flag.'
);
}
const promptMessage = `Currently ${currentPercent}% of all users are routed to branch ${
newBranch.name
} and ${100 - currentPercent}% of all users are routed to branch ${
oldBranch.name
}. What percent of users should be directed to the branch ${newBranch.name}?`;
percent = await promptForRolloutPercentAsync({ promptMessage });
}
const newBranchMapping = { ...currentBranchMapping };
newBranchMapping.data[0].branchMappingLogic.operand = percent / 100;
const newChannelInfo = await updateChannelBranchMappingAsync({
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.id!,
branchMapping: JSON.stringify(newBranchMapping),
});
const logMessage = `️Rollout of branch ${chalk.bold(newBranch.name)} onto channel ${chalk.bold(
channelName!
)} updated from ${chalk.bold(currentPercent)}% to ${chalk.bold(percent)}%. ${chalk.bold(
percent
)}% of users will be directed to branch ${chalk.bold(newBranch.name)}, ${chalk.bold(
100 - percent
)}% to branch ${chalk.bold(oldBranch.name)}.`;
return { newChannelInfo, logMessage };
}
async function endRolloutAsync({
channelName,
branchName,
jsonFlag,
projectId,
getUpdateChannelByNameForAppResult,
}: {
channelName?: string;
branchName?: string;
jsonFlag: boolean;
projectId: string;
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery;
}): Promise<{
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
}> {
// end rollout
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(
getUpdateChannelByNameForAppResult
);
let endOnNewBranch;
if (branchName) {
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
switch (branch.id) {
case newBranch.id:
endOnNewBranch = true;
break;
case oldBranch.id:
endOnNewBranch = false;
break;
default:
throw new Error(
`The branch "${branchName}" specified by --branch must be one of the branches involved in the rollout: "${newBranch.name}" or "${oldBranch.name}".`
);
}
} else {
if (jsonFlag) {
throw new Error(
'Branch name must be specified with the --branch flag when both the --end and --json flag are true.'
);
}
endOnNewBranch = await selectAsync<boolean>(
'Ending the rollout will send all traffic to a single branch. Which one should that be?',
[
{
title: `${newBranch.name} ${chalk.grey(`- current percent: ${100 - currentPercent}%`)}`,
value: true,
},
{
title: `${oldBranch.name} ${chalk.grey(`- current percent: ${currentPercent}%`)}`,
value: false,
},
]
);
}
if (endOnNewBranch == null) {
throw new Error('Branch to end on is undefined.');
}
const newBranchMapping = {
version: 0,
data: [
{
branchId: endOnNewBranch ? newBranch.id : oldBranch.id,
branchMappingLogic: 'true',
},
],
};
const newChannelInfo = await updateChannelBranchMappingAsync({
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.id!,
branchMapping: JSON.stringify(newBranchMapping),
});
const logMessage = `️Rollout on channel ${chalk.bold(
channelName
)} ended. All traffic is now sent to branch ${chalk.bold(
endOnNewBranch ? newBranch.name : oldBranch.name
)}`;
return { newChannelInfo, logMessage };
}
export default class ChannelRollout extends Command {
static hidden = true;
static description = 'Rollout a new branch out to a channel incrementally.';
static args = [
{
name: 'channel',
required: true,
description: 'rollout that the channel is on',
},
];
static flags = {
branch: flags.string({
description: 'branch to rollout',
required: false,
}),
percent: flags.integer({
description: 'percent of traffic to redirect to the new branch',
required: false,
}),
end: flags.boolean({
description: 'end the rollout',
default: false,
}),
json: flags.boolean({
description: 'print output as a JSON object with the new channel ID, name and branch mapping',
default: false,
}),
};
async run() {
const {
args: { channel: channelName },
flags: { json: jsonFlag, end: endFlag },
} = this.parse(ChannelRollout);
const {
flags: { branch: branchName, percent },
} = this.parse(ChannelRollout);
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 accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
const getUpdateChannelByNameForAppResult = await getUpdateChannelByNameForAppAsync({
appId: projectId,
channelName: channelName!,
});
const { branchMapping: currentBranchMapping, isRollout } = getBranchMapping(
getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.branchMapping
);
if (currentBranchMapping.data.length === 0) {
throw new Error('The channel is not pointing at any branches.');
}
if (currentBranchMapping.data.length > 2) {
throw new Error('"channel:rollout" cannot handle branch mappings with more than 2 branches.');
}
// This combination doesn't make sense. Throw an error explaining the options.
if (isRollout && branchName && !endFlag) {
throw new Error(
`There is a rollout in progress. You can only either edit the rollout percent or 'end' it.`
);
}
/**
* This if/else block has three branches:
* 1. The branch mapping is not a rollout, i.e. it is pointing to a single branch.
* 2. The branch mapping is a rollout.
* a. increase/decrease the rollout percentage.
* b. end the rollout.
*/
let rolloutMutationResult: {
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
};
if (!isRollout) {
rolloutMutationResult = await startRolloutAsync({
channelName,
branchName,
percent,
jsonFlag,
projectId,
fullName: `@${accountName}/${slug}`,
currentBranchMapping,
getUpdateChannelByNameForAppResult,
});
} else if (endFlag) {
rolloutMutationResult = await endRolloutAsync({
channelName,
branchName,
jsonFlag,
projectId,
getUpdateChannelByNameForAppResult,
});
} else {
rolloutMutationResult = await editRolloutAsync({
channelName,
percent,
jsonFlag,
currentBranchMapping,
getUpdateChannelByNameForAppResult,
});
}
if (!rolloutMutationResult) {
throw new Error('rollout result is empty');
}
const { newChannelInfo, logMessage } = rolloutMutationResult;
if (jsonFlag) {
Log.log(JSON.stringify(newChannelInfo));
return;
}
Log.withTick(logMessage);
}
}
@@ -0,0 +1,248 @@
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';
import {
GetChannelByNameForAppQuery,
GetChannelByNameForAppQueryVariables,
} from '../../graphql/generated';
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;
data: {
branchId: string;
branchMappingLogic: {
operand: number;
clientKey: string;
branchMappingOperator: string;
} & string;
}[];
};
/**
* Get the branch mapping and determine whether it is a rollout.
* Ensure that the branch mapping is properly formatted.
*/
export function getBranchMapping(
branchMappingString?: string
): { branchMapping: BranchMapping; isRollout: boolean; rolloutPercent?: number } {
if (!branchMappingString) {
throw new Error('Missing branch mapping.');
}
let branchMapping: BranchMapping;
try {
branchMapping = JSON.parse(branchMappingString);
} catch (e) {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
assert(branchMapping, 'Branch Mapping must be defined.');
if (branchMapping.version !== 0) {
throw new Error('Branch mapping must be version 0.');
}
const isRollout = branchMapping.data.length === 2;
const rolloutPercent = branchMapping.data[0].branchMappingLogic.operand;
switch (branchMapping.data.length) {
case 0:
break;
case 1:
if (branchMapping.data[0].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a single branch must be "true"');
}
break;
case 2:
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
}
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
}
if (rolloutPercent == null) {
throw new Error('Branch mapping is missing a "rolloutPercent"');
}
if (branchMapping.data[1].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
}
break;
default:
throw new Error('Branch mapping data must have length less than or equal to 2.');
}
return { branchMapping, isRollout, rolloutPercent };
}
export async function getUpdateChannelByNameForAppAsync({
appId,
channelName,
}: GetChannelByNameForAppQueryVariables): Promise<GetChannelByNameForAppQuery> {
return await withErrorHandlingAsync(
graphqlClient
.query<GetChannelByNameForAppQuery, GetChannelByNameForAppQueryVariables>(
gql`
query GetChannelByNameForApp($appId: String!, $channelName: String!) {
app {
byId(appId: $appId) {
id
updateChannelByName(name: $channelName) {
id
name
createdAt
branchMapping
updateBranches(offset: 0, limit: 1000) {
id
name
updates(offset: 0, limit: 1) {
id
group
message
runtimeVersion
createdAt
actor {
id
... on User {
username
}
... on Robot {
firstName
}
}
}
}
}
}
}
}
`,
{ appId, channelName }
)
.toPromise()
);
}
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.';
static args = [
{
name: 'name',
required: false,
description: 'Name of the channel to view',
},
];
static flags = {
json: flags.boolean({
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
default: false,
}),
};
async run() {
let {
args: { name: channelName },
flags: { json: jsonFlag },
} = this.parse(ChannelView);
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 accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
if (!channelName) {
const validationMessage = 'A channel name is required to view a specific channel.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name: channelName } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please name the channel:',
validate: value => (value ? true : validationMessage),
}));
}
const getUpdateChannelByNameForAppresult = await getUpdateChannelByNameForAppAsync({
appId: projectId,
channelName,
});
const channel = getUpdateChannelByNameForAppresult.app?.byId.updateChannelByName;
if (!channel) {
throw new Error(`Could not fine channel with name ${channelName}`);
}
if (jsonFlag) {
Log.log(JSON.stringify(channel));
return;
}
Log.withTick(
chalk`Channel: {bold ${channel.name}} on project {bold ${accountName}/${slug}}. Channel ID: {bold ${channel.id}}`
);
Log.log(
chalk`{bold Branches, pointed at by this channel, and their most recent update group:}`
);
logChannelDetails(channel);
}
}
+9 -3
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import assert from 'assert';
import chalk from 'chalk';
@@ -21,7 +22,8 @@ export default class BuildList extends Command {
let appleTeamIdentifier = this.parse(BuildList).flags['apple-team-id'];
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const accountName = await getProjectAccountNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
let spinner: ora.Ora;
@@ -42,7 +44,9 @@ export default class BuildList extends Command {
name: 'appleTeamIdentifier',
message: 'What Apple Team would you like to list devices for?',
choices: teams.map(team => ({
title: `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`,
title: team.appleTeamName
? `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`
: team.appleTeamIdentifier,
value: team.appleTeamIdentifier,
})),
});
@@ -71,7 +75,9 @@ export default class BuildList extends Command {
if (result?.appleDevices.length) {
const { appleTeamName, appleDevices } = result;
spinner.succeed(`Found ${appleDevices.length} devices for team ${appleTeamName}`);
spinner.succeed(
`Found ${appleDevices.length} devices for team ${appleTeamName ?? appleTeamIdentifier}`
);
const list = appleDevices
.map(device =>
+3 -1
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import ora from 'ora';
@@ -29,7 +30,8 @@ If you are not sure what is the UDID of the device you are looking for, run:
}
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const accountName = await getProjectAccountNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const spinner = ora().start(`Fetching device details for ${UDID}…`);
@@ -0,0 +1,155 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { findAccountByName } from '../../user/Account';
import { getActorDisplayName } from '../../user/User';
import { ensureLoggedInAsync } from '../../user/actions';
export enum EnvironmentSecretScope {
ACCOUNT = 'account',
PROJECT = 'project',
}
export default class EnvironmentSecretCreate extends Command {
static description = 'Create an environment secret on the current project or owner account.';
static flags = {
scope: flags.enum({
description: 'Scope for the secret',
options: [EnvironmentSecretScope.ACCOUNT, EnvironmentSecretScope.PROJECT],
default: EnvironmentSecretScope.PROJECT,
}),
name: flags.string({
description: 'Name of the secret',
}),
value: flags.string({
description: 'Value of the secret',
}),
};
async run() {
const actor = await ensureLoggedInAsync();
let {
flags: { name, value: secretValue, scope },
} = this.parse(EnvironmentSecretCreate);
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
if (!scope) {
const validationMessage = 'Secret scope may not be empty.';
({ scope } = await promptAsync({
type: 'select',
name: 'scope',
message: 'Where should this secret be used:',
choices: [
{ title: 'Account-wide', value: EnvironmentSecretScope.ACCOUNT },
{ title: 'Project-specific', value: EnvironmentSecretScope.PROJECT },
],
validate: value => (value ? true : validationMessage),
}));
}
if (!name) {
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: `Secret name:`,
validate: value => {
if (!value) {
return 'Secret name may not be empty.';
}
// this validation regex here is just to shorten the feedback loop
// the source of truth is in www's EnvironmentSecretValidator class
if (!value.match(/^\w+$/)) {
return 'Names may contain only letters, numbers, and underscores.';
}
return true;
},
}));
if (!name) throw new Error('Secret name may not be empty.');
}
if (!secretValue) {
const validationMessage = 'Secret value may not be empty.';
({ secretValue } = await promptAsync({
type: 'text',
name: 'secretValue',
message: 'Secret value:',
validate: value => (value ? true : validationMessage),
}));
if (!secretValue) throw new Error(validationMessage);
}
if (scope === EnvironmentSecretScope.PROJECT) {
const secret = await EnvironmentSecretMutation.createForApp(
{ name, value: secretValue },
projectId
);
if (!secret) {
throw new Error(
`Could not create secret with name ${name} on project with id ${projectId}`
);
}
Log.withTick(
`️Created a new secret ${chalk.bold(name)} on project ${chalk.bold(
`@${accountName}/${slug}`
)}.`
);
} else if (scope === EnvironmentSecretScope.ACCOUNT) {
const ownerAccount = findAccountByName(actor.accounts, accountName);
if (!ownerAccount) {
Log.warn(
`Your account (${getActorDisplayName(actor)}) doesn't have access to the ${chalk.bold(
accountName
)} account`
);
return;
}
const secret = await EnvironmentSecretMutation.createForAccount(
{ name, value: secretValue },
ownerAccount.id
);
if (!secret) {
throw new Error(
`Could not create secret with name ${name} on account with id ${ownerAccount.id}`
);
}
Log.withTick(
`️Created a new secret ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
);
}
}
}
@@ -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}".`);
}
}
@@ -0,0 +1,58 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import Table from 'cli-table3';
import dateFormat from 'dateformat';
import { EnvironmentSecretsQuery } from '../../graphql/queries/EnvironmentSecretsQuery';
import Log from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
export default class EnvironmentSecretList extends Command {
static description = 'Lists environment secrets available for your current app';
async run(): Promise<void> {
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;
}
if (!projectDir) {
throw new Error("Please run this command inside your project's directory");
}
const secrets = await EnvironmentSecretsQuery.allAsync(projectAccountName, projectFullName);
const table = new Table({
head: ['Name', 'Scope', 'ID', 'Updated at'],
wordWrap: true,
});
for (const secret of secrets) {
const { name, createdAt: updatedAt, scope, id } = secret;
table.push([name, scope, id, dateFormat(updatedAt, 'mmm dd HH:MM:ss')]);
}
Log.log(chalk`{bold Secrets for this account and project:}`);
Log.log(table.toString());
}
}
+13 -13
View File
@@ -1,16 +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 = '';
@@ -158,17 +156,19 @@ 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 projectId = await getProjectIdAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
@@ -181,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,
@@ -191,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,
@@ -211,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',
@@ -219,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,69 @@
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],
};
export function getNewAndroidApiMockWithoutCredentials() {
return {
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(),
getLegacyAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(),
createKeystoreAsync: jest.fn(),
};
}
@@ -1,20 +1,33 @@
import { AndroidCredentials } from '../android/credentials';
import { testKeystore2Base64, testKeystoreBase64 } from './fixtures-base64-data';
import { AndroidCredentials, Keystore } from '../android/credentials';
import {
testKeystore2Base64,
testKeystoreBase64,
testPKCS12KeystoreBase64,
} from './fixtures-base64-data';
import { testExperienceName, testJester2ExperienceName } from './fixtures-constants';
export const testKeystore = {
export const testKeystore: Keystore = {
keystore: testKeystoreBase64,
keystorePassword: 'ae6777e9444a436dbe533d2be46c83ba',
keyAlias: 'QHdrb3p5cmEvY3JlZGVudGlhbHMtdGVzdA==',
keyPassword: '43f760fe7ecd4e6a925779eb45bc787b',
};
export const testKeystore2 = {
export const testKeystore2: Keystore = {
keystore: testKeystore2Base64,
keystorePassword: '6faeed2326b94effadbeb731510c2378',
keyAlias: 'QHdrb3p5cmEvY3JlZGVudGlhbHMtdGVzdA==',
keyPassword: 'e4829b38057042d78f25053f390478f9',
};
// Generated by: keytool -genkey -alias test-alias -keystore keystore.p12 -storetype PKCS12 -keyalg RSA -storepass password -validity 730 -keysize 2048
// A Keystore in a PKCS#12 file, where all entries are under aliases
// This particular PKCS#12 has one key and one certificate under 'test-alias'
export const testPKCS12Keystore: Keystore = {
keystore: testPKCS12KeystoreBase64,
keystorePassword: 'password',
keyAlias: 'test-alias',
};
export const testPushCredentials = {
fcmApiKey: 'examplefcmapikey',
};
@@ -1,3 +1,11 @@
import { AuthCtx } from '../ios/appstore/authenticate';
export const testAuthCtx: AuthCtx = {
appleId: 'test-apple-id',
appleIdPassword: 'test-apple-password',
team: { id: 'test-team-id', name: 'test-team-name', inHouse: false },
};
export function getAppstoreMock() {
return {
ensureAuthenticatedAsync: jest.fn(),
@@ -4,4 +4,7 @@ export const testKeystoreBase64 =
export const testKeystore2Base64 =
'/u3+7QAAAAIAAAABAAAAAQAkcWhkcmIzcDVjbWV2eTNqbHpndnVkZ2xoYmhtdGRndnpkYT09AAABcpPsjJ8AAAUBMIIE/TAOBgorBgEEASoCEQEBBQAEggTprcWt08cZEZs02fntByaniCbvi9gWiZ7E7sNaEra/NW1fKEJv9fWG/KZryZlnxtYMTXnUtmyX0zI9pYXNd4lBc0QC0Bd2IlGA7vUwGXVTEAOuBtyT4kfENRgoSUoNgfIDU45eDS/A7qXRaQuo6nzyzD4x2je0cjmQCa6IZsRkGFIg9htoPeCdgvA966+nGE4qzlQh56V88IYfG26lLTZZlBLfzPDuOi05KDHn6Kcqg3kRcMOB3XTdtwn9W9h6Xd/M5wYvAhCTTKEu0vlFhhuo9jy8zwBQZCNoo5gLyiUtQqEVjscToBNGfePS6AzprTEDQzXkxypI+FrntiCIFw9PunUGPMyVUwUwr7t6tpjf+W2XGeOJc7Xd92k8FaZqJ0Wi2DVqK8GWorXhZSdxqQnBg0DeZ/kcW30PTH0vIcoIjmEjmwLRr9QIC/rYzdVow35OETmhLYB37+DvmI8xvNCOvz/tbmL7gcSPyfaEwNUD8Ekx7D6dDigmwrkTzmac5pXbx2eluQF8gNrstO7GDTtj0xPR/dWcudXQ+miLk1NFsGj23Y3mzOKjSRJQjHTRURIxQDMfWqLA707yvKc4YWmyM3z+fvbzA9QVJtdFRovjfbQRXEpMcIesP5gXtdi+KUh0pqbrlryX45s6NsY8GjR9LDKpiW9UUXizf58RjI20zFKklbeyCVSJV+dyo7mQi20Jeq4EcTI9QXsLFzBP1Fd/nUx39TK/xfNf2+vtvhRC3pkpG8wG4lw716Oiz6eIaqUtl+LY2Qq6Th8pHXo9OxhTHFJAjMawKF2IkITSoRR2aKBFCJJXo/xpECvfMDx4xKekX+8rJYuqFK6vk0FLob6LdF7DqETQZc3HMJswSyQbWkFR6q6t++pHjkLGNsanGkVIH62HQguXY5/8/X1WTrs3x22FotKVc48UynYnHoftpm5iG9TpSZ+O8jGXdSqonS/5WsYKwW4RWQ3z73Dm5y16ydnatzEu+uYX0DevygiBKrZXQEzGA9wOkMuzCnQ9edLAAfm9g8Ur6Ioj8PtpHnRT7T/1UBH+5mMfXV5BH6ysN3W+/fh4Himri4NU4EvOQgOzybaB24KX2dKNMGV7bA/EFTJvLxTabpcIPzdWDjlpmb/8/Ffmlp5E+zlHx9MMm29BxzN7gLaUOaA6TmxyYUMNRzoulICBSe2OCe6gzR/mwVWcspzUz9U/EU42Nayhc91zbAUzhMqt8z9RHVKZElgXS5+ERjaTMtkPFPxadkONTjesVn0cD4x6K+aQGOM0NKH2VjgQsu8CtV0mWwi55pA03azMf9ol8fOfVGO7JI4pb3HbQM80qVE+9p6NOi29B8jWnoC+J9P9lLYF9lWIUAp4pyg1CBivzOE/RAY2KmbsL0hQYLyQlLKHf2bFx1+SA1JTSL4VsW8CkV4iTzZKNPv0zQj/qCwsHyJLnHVmTO4SugJ++AGJwmUvdeb5BaeJNmNtz+fbtwPkygEwKsMq1zD42X/ss0mCWt20HHkm7r2vFgWNxX7twNsOBTuQZOiHawKswdR3W2684+dFShdDQPwMkrP6AJXzT7vY2Lq4896u5wI2z7HgWVIQemSqt1XA0q4VgIPpa9paaiOQPgiHuqN0vF+GgZt4QHyVBZWib+jF5X+spQ1fRLl1PJWtTPE7/IuPk6DSZ0ad8vpoAAAAAQAFWC41MDkAAANHMIIDQzCCAiugAwIBAgIEI+zyBzANBgkqhkiG9w0BAQsFADBSMQswCQYDVQQGEwJVUzEJMAcGA1UECBMAMQkwBwYDVQQHEwAxCTAHBgNVBAoTADEJMAcGA1UECxMAMRcwFQYDVQQDEw4tLS0tLS0tLS0tLS0tLTAeFw0yMDA2MDgxMjM0MTJaFw00NzEwMjUxMjM0MTJaMFIxCzAJBgNVBAYTAlVTMQkwBwYDVQQIEwAxCTAHBgNVBAcTADEJMAcGA1UEChMAMQkwBwYDVQQLEwAxFzAVBgNVBAMTDi0tLS0tLS0tLS0tLS0tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq3QxHjFwDlkMruqxOMNTbw74Bh6CTBPtlTmcMwLtqk+wWleuv2ZvKn7k0qurOe9/i1AlEa8vzvtxcrrU7tN8EH199/x93MZfVBpU+0SaR9NHIspji7GIGGIF1HFDinaSp5wBwO7h/qVFiAz4lFlIziWL9Bp7d/ubuLgl3ikW7I81oR/7xg9wkXIOryLrLERpFSdCpxl66h07Ir2c8mTuvbIJrJ4aAz323ngo5GRslnW5r1KK5ECLFAOYaeH98AhZ/iiG7c0NDKzmF+qs0wovxvxIDoLiG+fnajdqaYF4BsHj/5Cxn3avs20B8Ey66fa23V7b+xHPGWXYJB7C/+RQoQIDAQABoyEwHzAdBgNVHQ4EFgQU9vep9CVJiMjFH8/Gp4Aqc7VdHyQwDQYJKoZIhvcNAQELBQADggEBAFokeI/CtHVMaPlTmp/NWygwjUyi0oQif3iCOI9sBJY/uFSiqFBfEKnXnXqBeoM2I7fzNkQWMTTvsJ86GJZ8RbpsWY5rpqxH2qCHI65FZOy4+HMQ8fMe6qXi+HJw7V4ITyVnh5NXJmX5ff6MOCZaoeNh2KLo8wG4IXhrbZeY7/SC+v32f5c9vlMDdZTFIpIGOaOaxIQp7fiRFGi7OAQmx5+dpGLmljXJmpDnx9ieKTnjCyEmnHhQt9LIZe4L9NdhqCrHpWvbgONop8dLaeZuR3YjnTA/fQydRTwXxkIBaNgx9VkFNBQ2AzdYvVXz7EkneptJZG+Kf4AL+TeN69HG33ITyr7xLWRWChI0FlE6G3uvvfkVVg==';
export const testPKCS12KeystoreBase64 =
'MIIKIAIBAzCCCdoGCSqGSIb3DQEHAaCCCcsEggnHMIIJwzCCBXAGCSqGSIb3DQEHAaCCBWEEggVdMIIFWTCCBVUGCyqGSIb3DQEMCgECoIIE+jCCBPYwKAYKKoZIhvcNAQwBAzAaBBRL1ZAQAe2uDgQ/8fOeQM0bkOpjdwICBAAEggTIwbo6AGi+KXBdZL4ysxz829d7mPkEUI1ZAGJHXfJiCrEPj2qRy/fZdurAq0WUGds/kqJYu68q9ZV6x+MkOr99Fm3AB5gBfI6I8yVZl6cdoYJ9jbAXLq3Md4Yx9NE6J7Krz1st/GTRxMlcAfs+aymdAG8XbFEyMkvcFKQD3pmiwklQLbAy+I0z7cyGw4qs3FhqYDcYUCZ/AIAQEm+c10oCX6IFwi2PvmIGU8vXjf99Ee9MkMGPn4m/h2DIIYNfyHYuG7MtG65Wcb9Nc5jLXCXg5BuC4c/cnBS8MkG7MLFLhUyRY6WE5OtEQvXeq2rn8h0Eeb6GTbXqG4ZNNpXle0eRr6x8Id+VjTJGOC9rBCPwznzgLizNM2ZQmbG68XC5ZAHvoFP8r2d5rlzmNiTsGqBuIfiIKANEhdT2aAiCqHhL8WxbgOk4bnNhdQyVBFGPt5FUM9193S9fUOJK3TLMyhTnshO5vI2KxP8kFoE+ePWCltpad6w7KBio150zDRQDbUsVcf4PMgs++r1aN06AkGeqYLBsle3mT7o3IvK04k+gP243O/kPF+jvApEPDOVLVgKG5AQkZEM75L9uAQqcgZ+VidNhz87X3WvVVgBqFzjDkW4/MHFTSxwr5zI7MPjxkeGnjEeINaIkYPdsV3vGHT6RUXodZK+pW8iWaA9JlzuBvoCu6vf88Kwx9mqWKbUC/wejQS1JMscuP/IaQYoLsiCy93puopmkezoZn9EPVCtE0QULoJWdZ0xuWsr4fl/KIMpFr4ivJp+PrBP+PxmuI3RpfaGQfi3p8rMvHqwtkFWtfXFjF9DLHI0R0nUY2bcLCk96RMrqA0hcUCJpzHcxNDcgiXyBMPlACRT5JGv3E7V+1rs/SuDZaGXoijt7G6MhL2zs80OdoWNneozYJ5jMFkLvwI0zYKf8djD4vpMOGzamRnFH3xyq0ACM5RaVOGRQEWp4SFSuUSTSUQrdSi553xevd4WZwwNjCI2Kox/jPn7LzWa69wSAY1ZlgNiW8ISsMvq073IPg9wnp32ShGKAs3ivkpAGcS3HXaMHxEb0/h08kt0ee1039ldncNav0HzNdT+fuGR1spCLyvVEUJ+z3IEzFRje80QSDs2dC6ZjjnoR9CFfp5DulU97qTy04HnHI2UALBZ7VOfkbAmzqQ1+ggY12rQa0htYyw5xcJ/1zbiVyfHaOo4bM0g1kN2XK1YF3cTxnD87OhyZVktE74y22B5+D/LXPUiQP4HxmNMV4vDs4Y/km/RymKrG7U+ju8yVasIYgT6mPfQ5N2NpFtQnEW31siOMAR1PCsYDK/XYHczhuk2HF8bR5U5ev5jLc6qSEyvlAZmBQtJMJVmaLiKbvGQXeMp+v8zbqP4IuynpBts6s4ODT888sAUCrFz2dIBsodowOpxLiKmVOYlXkCpR948GwrjOYOHpBJKKMZB6ec/yT/5dgHGNvFCTnuPPhRDmrHOizmelR3n0xn3mILGEHJmBUPiXy7eZL3PjunUxPrHuvKsQo8oCvo9dJN0EjvjNVyWTjB3mKUL3d7Ce4+DToYyxeAHMv99aQ/8ZxlPkTdBYUSAhixUX2Hl6DN1thCuNNjqdqlmg91koxNmNsXrh6brd6ySPTi0R64E9MUgwIwYJKoZIhvcNAQkUMRYeFAB0AGUAcwB0AC0AYQBsAGkAYQBzMCEGCSqGSIb3DQEJFTEUBBJUaW1lIDE2MDIxNTU3ODIzMzAwggRLBgkqhkiG9w0BBwagggQ8MIIEOAIBADCCBDEGCSqGSIb3DQEHATAoBgoqhkiG9w0BDAEGMBoEFF/lSFudPAx/ZJ2iptxeZUA7a0yOAgIEAICCA/gE1nqwzU9Cw+6I7LHhFi2QZzaK2D5YMgr8xfFP/dxOD0ZUHFia7IJVJIQu22gygm5JB++L6yoptSrFWHVDvm93fQSMo3VW871+NpCqDLSMRk7pVSrwsSOP1HjSVpOQk+QoqjPf1N647SotAGdF3UxU4zQNteuoxN+vryXtA74z2TfQq2QYhLfFMb2vC/bevr2SIECR+b52NjIKoTxni07tzLKF7TOGwZh0HutdpQWS0iBnwHfKU5t0R6Mv8dB3bp9QmhMqqfM4Hj4CtnxzfAtPbfXUJSsx5j42Mhm18rnTFUL0vYQOiWLx6go5kAORSEUa6q64+ga7Zt59e028F1Q8oVviaaeUnJooVhs3kQW+/qT1OqTBWUmichzXbrA1pEedXVzVoQIEVUgbwV29jFjKfX8D9EvQv6P4bUHf9nIQyD66evzYEgNssfe3OT42IyCN/N3f+lzRQAKE3ExBeBIL/f1v3e69V/FygMmEuH6M9jTzcuV5FyC/rCArMLJZ56Zx5dVZcZ7LxTfWPb95atFJ/89FVWrpxhS6xvjOVjWFRc/SjdEju/qMN2NortcNNnnKSDms+BuEmb9gqwj/uuaHY3Q08tQCPwYeZ+4IlWxDR7O7UiEY5KY1I0ath4tWt0t/m6v7juydIOpX+rVdPCHbhZIWmuqMhU6zHb7yH+CzhIhv1E41pt75UPNRPrRCOtZgCCeS1ARp2HkB+D4TrPLKHiII1wLGCFGv/CfYm1feq8fHRItqWW7oCWdXaD234Zhc7vpqZQSjonSpk+6hVNngqzBfj2ZP9ZM/Ot5mkFm3ws0Wtp5IDQ0FXIYdksFzVIY0nRvNcBOlJzu0CmFq/WQQ6CO+Q/ApfV8ybnLSu98t44404NRu0ZNhkXIZ1pdLqb6+k6mzIw9+N/+hxPOvTf33aNnTOb1tEOPYII3t0MFkudox5c7n92+xW/SnijdEiPE6+K8iKPR/DUr2YxSUH24mIy355xgZaVRyViG+JCF1do7Bxx052UYmawTPKyS2RbqE9iXZf7SedAT2ALLwXC39rblRpwl7P+cLeu5if/qoTLDisfmgwlKXEVn8NFOR1vFzFnCZxHDrZ+EH5l9zqkeDtwHfnyeqrDj83VVTPVe4Hr9QXAqDi4kevcjWLW4VBUGsa0+L6021c1aHmWa/gzm//MrDKVS2RZPPfX5f4KUUNX/rYSvnr7vKHZ2FrpiP96w/HaGnuT3A/Nye1UqfPIYYN26L7Vr1dYolsvo2GAdX4x7EjJGpVUt8RPTSj4fcLNqXN6LdpMfrwMlkpl3wSBCoDjAn3jMmeyhic3rIGEYqlf3DpodpUNC4p4Q57rck21A4jjBycCTBZTA9MCEwCQYFKw4DAhoFAAQU3TeAb7mBYAHCMdzxMG9pfxvCLlsEFHO4kStrckWktQa7mPq269mrTHk3AgIEAA==';
export const testProvisioningProfileBase64 = `MIIdDQYJKoZIhvcNAQcCoIIc/jCCHPoCAQExCzAJBgUrDgMCGgUAMIINoAYJKoZIhvcNAQcBoIINkQSCDY08P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJVVEYtOCI/Pgo8IURPQ1RZUEUgcGxpc3QgUFVCTElDICItLy9BcHBsZS8vRFREIFBMSVNUIDEuMC8vRU4iICJodHRwOi8vd3d3LmFwcGxlLmNvbS9EVERzL1Byb3BlcnR5TGlzdC0xLjAuZHRkIj4KPHBsaXN0IHZlcnNpb249IjEuMCI+CjxkaWN0PgoJPGtleT5BcHBJRE5hbWU8L2tleT4KCTxzdHJpbmc+dGVzdGFwcCA8L3N0cmluZz4KCTxrZXk+QXBwbGljYXRpb25JZGVudGlmaWVyUHJlZml4PC9rZXk+Cgk8YXJyYXk+Cgk8c3RyaW5nPlFMNzZYWUg3M1A8L3N0cmluZz4KCTwvYXJyYXk+Cgk8a2V5PkNyZWF0aW9uRGF0ZTwva2V5PgoJPGRhdGU+MjAyMC0wNy0xM1QxMjozNzowM1o8L2RhdGU+Cgk8a2V5PlBsYXRmb3JtPC9rZXk+Cgk8YXJyYXk+CgkJPHN0cmluZz5pT1M8L3N0cmluZz4KCTwvYXJyYXk+Cgk8a2V5PklzWGNvZGVNYW5hZ2VkPC9rZXk+Cgk8ZmFsc2UvPgoJPGtleT5EZXZlbG9wZXJDZXJ0aWZpY2F0ZXM8L2tleT4KCTxhcnJheT4KCQk8ZGF0YT5NSUlGb1RDQ0JJbWdBd0lCQWdJSU9UWW9sUmhEaTlrd0RRWUpLb1pJaHZjTkFRRUxCUUF3Z1pZeEN6QUpCZ05WQkFZVEFsVlRNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVN3d0tnWURWUVFMRENOQmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3pGRU1FSUdBMVVFQXd3N1FYQndiR1VnVjI5eWJHUjNhV1JsSUVSbGRtVnNiM0JsY2lCU1pXeGhkR2x2Ym5NZ1EyVnlkR2xtYVdOaGRHbHZiaUJCZFhSb2IzSnBkSGt3SGhjTk1qQXdOekV6TVRJeU5qRTJXaGNOTWpFd056RXpNVEl5TmpFMldqQ0JsREVhTUJnR0NnbVNKb21UOGl4a0FRRU1DbEZNTnpaWVdVZzNNMUF4T2pBNEJnTlZCQU1NTVdsUWFHOXVaU0JFYVhOMGNtbGlkWFJwYjI0NklFRnNhV05xWVNCWFlYSmphR0hGZ2lBb1VVdzNObGhaU0RjelVDa3hFekFSQmdOVkJBc01DbEZNTnpaWVdVZzNNMUF4R0RBV0JnTlZCQW9NRDBGc2FXTnFZU0JYWVhKamFHSEZnakVMTUFrR0ExVUVCaE1DVlZNd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUUNuTUNPT2VqWjEvUjBBTTJ3Sis0cjBDc2F3N0x3N2xFenBTREQ5OUd4TjA2ZkZ3cW1HRnBWdFhsRzhWYUN0cW1sZzl5MzJiMVltd1pJcXJRR3FteVY1cUk4RDJvTzh2QjVvRDJVT3IrUHgzOTQ0YnQyZFFZaUlHQTNlZi81SktZa1E3VDhXWXFQczg3Q1NORnZ5MHhJa1dHbFBpa0dhd0xXcmtzZmJUNFdkMU90dVhiYUtoTEZGNmp5alU0ZCtYN2NoNGtQOFFvdHlkWk5vWmltQTVTYWNtVmRidUM0YTlvQStPL3dscUliaXdiTUtGQlE2Q29tRTFKelpQaWtXMDFoZ1V2T2kvSEtKb0Nsdnp5SmZMLzhZbm43dDRSdUs5bmxHaHNIanJ5b3hFRDdiTGZpcUlKRTllcWlvNTBTK2cycFllWS9tNUh6SW04V1lOZ0RXcjFsTkFnTUJBQUdqZ2dIeE1JSUI3VEFNQmdOVkhSTUJBZjhFQWpBQU1COEdBMVVkSXdRWU1CYUFGSWduRndtcHRoaGdpK3pydXZaSFdjVlNWS08zTUQ4R0NDc0dBUVVGQndFQkJETXdNVEF2QmdnckJnRUZCUWN3QVlZamFIUjBjRG92TDI5amMzQXVZWEJ3YkdVdVkyOXRMMjlqYzNBd015MTNkMlJ5TVRFd2dnRWRCZ05WSFNBRWdnRVVNSUlCRURDQ0FRd0dDU3FHU0liM1kyUUZBVENCL2pDQnd3WUlLd1lCQlFVSEFnSXdnYllNZ2JOU1pXeHBZVzVqWlNCdmJpQjBhR2x6SUdObGNuUnBabWxqWVhSbElHSjVJR0Z1ZVNCd1lYSjBlU0JoYzNOMWJXVnpJR0ZqWTJWd2RHRnVZMlVnYjJZZ2RHaGxJSFJvWlc0Z1lYQndiR2xqWVdKc1pTQnpkR0Z1WkdGeVpDQjBaWEp0Y3lCaGJtUWdZMjl1WkdsMGFXOXVjeUJ2WmlCMWMyVXNJR05sY25ScFptbGpZWFJsSUhCdmJHbGplU0JoYm1RZ1kyVnlkR2xtYVdOaGRHbHZiaUJ3Y21GamRHbGpaU0J6ZEdGMFpXMWxiblJ6TGpBMkJnZ3JCZ0VGQlFjQ0FSWXFhSFIwY0RvdkwzZDNkeTVoY0hCc1pTNWpiMjB2WTJWeWRHbG1hV05oZEdWaGRYUm9iM0pwZEhrdk1CWUdBMVVkSlFFQi93UU1NQW9HQ0NzR0FRVUZCd01ETUIwR0ExVWREZ1FXQkJUTGhSdGRhRVRjRmx4Q0grZnRGQ3d4U3UvZmlUQU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WUtLb1pJaHZkalpBWUJCQUVCL3dRQ0JRQXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBSEJxL2VSeitIYTRtemZ0cHZ0SGQ4TTFNb2d0c01UaVNNVDFZK0VGbUQ5UWZYdXdCM3F1ckhQbHFiMjFvV01CWU9TaWcwRDlFZG9Ta29STXAwdU5kaUltVnMycVc0bEgvQ25zL0w1QkhqU2NQc3kwbXUrSitlQjZXZzNXTDduNm1pM3BlOCtxZ2dxak41RVU2YW1Ca04wMmU3Z0pMZnlkcHRSRlBOaklOWFh6bDkxOVNDeUo2VXl3OGFUblBrZFc5eTI1UHgrUHJBSmNDVm1hbFRlYkJvNWxnbUNKejlKUkU3Q3pQd2xmaVBRbUZRMlJVTEpONTBrMmRGSmtESk1jUHd1OFdvUmNBMFE3M21NU256OVNiS21UcHhzeUNEUUo2WHJ5Sm1vKy9aQmEvM0k0dE15dkNBUVQ1alNLWjFrUXJ4c2xFdGJHZE5Bb0xUaUJ6dHY3Wjk0PTwvZGF0YT4KCTwvYXJyYXk+CgoJCQkJCQkJCQkJCgk8a2V5PkVudGl0bGVtZW50czwva2V5PgoJPGRpY3Q+CgkJPGtleT5iZXRhLXJlcG9ydHMtYWN0aXZlPC9rZXk+CgkJPHRydWUvPgoJCQkJCgkJCQk8a2V5PmFwcGxpY2F0aW9uLWlkZW50aWZpZXI8L2tleT4KCQk8c3RyaW5nPlFMNzZYWUg3M1Aub3JnLnJlYWN0anMubmF0aXZlLmV4YW1wbGUudGVzdGFwcC50dXJ0bGV2Mjwvc3RyaW5nPgoJCQkJCgkJCQk8a2V5PmtleWNoYWluLWFjY2Vzcy1ncm91cHM8L2tleT4KCQk8YXJyYXk+CgkJCQk8c3RyaW5nPlFMNzZYWUg3M1AuKjwvc3RyaW5nPgoJCQkJPHN0cmluZz5jb20uYXBwbGUudG9rZW48L3N0cmluZz4KCQk8L2FycmF5PgoJCQkJCgkJCQk8a2V5PmdldC10YXNrLWFsbG93PC9rZXk+CgkJPGZhbHNlLz4KCQkJCQoJCQkJPGtleT5jb20uYXBwbGUuZGV2ZWxvcGVyLnRlYW0taWRlbnRpZmllcjwva2V5PgoJCTxzdHJpbmc+UUw3NlhZSDczUDwvc3RyaW5nPgoJCQkJCgkJCQk8a2V5PmFwcy1lbnZpcm9ubWVudDwva2V5PgoJCTxzdHJpbmc+cHJvZHVjdGlvbjwvc3RyaW5nPgoKCTwvZGljdD4KCTxrZXk+RXhwaXJhdGlvbkRhdGU8L2tleT4KCTxkYXRlPjIwMjEtMDctMTNUMTI6MjY6MTZaPC9kYXRlPgoJPGtleT5OYW1lPC9rZXk+Cgk8c3RyaW5nPm9yZy5yZWFjdGpzLm5hdGl2ZS5leGFtcGxlLnRlc3RhcHAudHVydGxldjIgcHJvZmlsPC9zdHJpbmc+Cgk8a2V5PlRlYW1JZGVudGlmaWVyPC9rZXk+Cgk8YXJyYXk+CgkJPHN0cmluZz5RTDc2WFlINzNQPC9zdHJpbmc+Cgk8L2FycmF5PgoJPGtleT5UZWFtTmFtZTwva2V5PgoJPHN0cmluZz5BbGljamEgV2FyY2hhxYI8L3N0cmluZz4KCTxrZXk+VGltZVRvTGl2ZTwva2V5PgoJPGludGVnZXI+MzY0PC9pbnRlZ2VyPgoJPGtleT5VVUlEPC9rZXk+Cgk8c3RyaW5nPjQ1NjBiNTU1LWFhODMtNDRiNy1iYjMxLTAwMGIwMDZjNDM2ZDwvc3RyaW5nPgoJPGtleT5WZXJzaW9uPC9rZXk+Cgk8aW50ZWdlcj4xPC9pbnRlZ2VyPgo8L2RpY3Q+CjwvcGxpc3Q+oIIMsjCCA/MwggLboAMCAQICARcwDQYJKoZIhvcNAQEFBQAwYjELMAkGA1UEBhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsTHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBSb290IENBMB4XDTA3MDQxMjE3NDMyOFoXDTIyMDQxMjE3NDMyOFoweTELMAkGA1UEBhMCVVMxE Line truncated
@@ -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 = {
@@ -3,17 +3,18 @@ import merge from 'lodash/merge';
import { CredentialsManager } from '../CredentialsManager';
import { Context } from '../context';
import { getAndroidApiMockWithoutCredentials } from './fixtures-android';
import { getNewAndroidApiMockWithoutCredentials } from './fixtures-android-new';
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(),
newAndroid: getNewAndroidApiMockWithoutCredentials(),
appStore: getAppstoreMock(),
bestEffortAppStoreAuthenticateAsync: jest.fn(),
ensureAppleCtx: jest.fn(),
ensureProjectContext: jest.fn(),
user: {
@@ -37,5 +38,5 @@ export function createManagerMock(mockOverride: Record<string, any> = {}): Crede
}),
},
mockOverride
);
) as any;
}
@@ -2,43 +2,27 @@ import {
AppFragment,
AppleAppIdentifierFragment,
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',
@@ -56,6 +40,17 @@ export const testAppFragment: AppFragment = {
slug: 'testapp',
};
export const testProvisioningProfileFragment: AppleProvisioningProfileFragment = {
id: 'test-prov-prof-id-1',
expiration: new Date(),
developerPortalIdentifier: 'test-developer-identifier',
provisioningProfile: testProvisioningProfileBase64,
updatedAt: new Date(),
status: 'Active',
appleTeam: { ...testAppleTeamFragment },
appleDevices: [],
};
export const testDistCertFragmentNoDependencies: AppleDistributionCertificateFragment = {
id: 'test-dist-cert-id-1',
certificateP12: 'Y2VydHAxMg==',
@@ -95,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',
@@ -106,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',
@@ -125,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,
@@ -155,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: 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 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,8 +21,8 @@ interface Options {
skipCredentialsCheck?: boolean;
}
export default class AndroidCredentialsProvider implements CredentialsProvider {
public readonly platform = Platform.Android;
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,31 @@
import { AndroidKeystoreFragment } from '../../../../graphql/generated';
import Log from '../../../../log';
import { Account } from '../../../../user/Account';
import { Context } from '../../../context';
import { askForUserProvidedAsync } from '../../../utils/promptForCredentials';
import { KeystoreWithType, keystoreSchema } from '../../credentials';
import { generateRandomKeystoreAsync } from '../../utils/keystore';
import { getKeystoreWithType, validateKeystore } from '../../utils/keystoreNew';
export class CreateKeystore {
constructor(private account: Account) {}
public async runAsync(ctx: Context): Promise<AndroidKeystoreFragment> {
if (ctx.nonInteractive) {
throw new Error(`New keystore cannot be created in non-interactive mode.`);
}
const keystore = await this.provideOrGenerateAsync();
const keystoreFragment = await ctx.newAndroid.createKeystoreAsync(this.account, keystore);
Log.succeed('Created keystore');
return keystoreFragment;
}
private async provideOrGenerateAsync(): Promise<KeystoreWithType> {
const providedKeystore = await askForUserProvidedAsync(keystoreSchema);
if (providedKeystore) {
const providedKeystoreWithType = getKeystoreWithType(providedKeystore);
validateKeystore(providedKeystoreWithType);
}
return await generateRandomKeystoreAsync();
}
}
@@ -0,0 +1,27 @@
import { confirmAsync } from '../../../../../prompts';
import { createCtxMock } from '../../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContext } from '../../BuildCredentialsUtils';
import { CreateKeystore } from '../CreateKeystore';
jest.mock('../../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
describe('CreateKeystore', () => {
it('creates a keystore in Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: false });
const appLookupParams = getAppLookupParamsFromContext(ctx);
const createKeystoreAction = new CreateKeystore(appLookupParams.account);
await createKeystoreAction.runAsync(ctx);
// expect keystore to be created on expo servers
expect(ctx.newAndroid.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
});
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
const appLookupParams = getAppLookupParamsFromContext(ctx);
const createKeystoreAction = new CreateKeystore(appLookupParams.account);
// fail if users are running in non-interactive mode
await expect(createKeystoreAction.runAsync(ctx)).rejects.toThrowError();
});
});
@@ -0,0 +1,60 @@
import {
AndroidKeystoreFragment,
CommonAndroidAppCredentialsFragment,
} from '../../../graphql/generated';
import { Account } from '../../../user/Account';
import { KeystoreWithType } from '../credentials';
import { AndroidKeystoreMutation } from './graphql/mutations/AndroidKeystoreMutation';
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,
}
);
}
export async function createKeystoreAsync(
account: Account,
keystore: KeystoreWithType
): Promise<AndroidKeystoreFragment> {
return await AndroidKeystoreMutation.createAndroidKeystore(
{
base64EncodedKeystore: keystore.keystore,
keystorePassword: keystore.keystorePassword,
keyAlias: keystore.keyAlias,
keyPassword: keystore.keyPassword,
type: keystore.type,
},
account.id
);
}
const formatProjectFullName = ({ account, projectName }: AppLookupParams): string =>
`@${account.name}/${projectName}`;
@@ -0,0 +1,59 @@
import assert from 'assert';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AndroidKeystoreFragment,
AndroidKeystoreType,
CreateAndroidKeystoreMutation,
} from '../../../../../graphql/generated';
import { AndroidKeystoreFragmentNode } from '../../../../../graphql/types/credentials/AndroidKeystore';
const AndroidKeystoreMutation = {
async createAndroidKeystore(
androidKeystoreInput: {
base64EncodedKeystore: string;
keystorePassword: string;
keyAlias: string;
keyPassword?: string;
type: AndroidKeystoreType;
},
accountId: string
): Promise<AndroidKeystoreFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateAndroidKeystoreMutation>(
gql`
mutation CreateAndroidKeystoreMutation(
$androidKeystoreInput: AndroidKeystoreInput!
$accountId: ID!
) {
androidKeystore {
createAndroidKeystore(
androidKeystoreInput: $androidKeystoreInput
accountId: $accountId
) {
id
...AndroidKeystoreFragment
}
}
}
${print(AndroidKeystoreFragmentNode)}
`,
{
androidKeystoreInput,
accountId,
}
)
.toPromise()
);
assert(
data.androidKeystore.createAndroidKeystore,
'GraphQL: `createAndroidKeystore` not defined in server response'
);
return data.androidKeystore.createAndroidKeystore;
},
};
export { AndroidKeystoreMutation };
@@ -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 };
@@ -1,3 +1,4 @@
import { AndroidKeystoreType } from '../../graphql/generated';
import { CredentialSchema } from '../utils/promptForCredentials';
export interface FcmCredentials {
@@ -8,9 +9,13 @@ export interface Keystore {
keystore: string;
keystorePassword: string;
keyAlias: string;
keyPassword: string;
keyPassword?: string;
}
export type KeystoreWithType = Keystore & {
type: AndroidKeystoreType;
};
export type AndroidCredentials = {
experienceName: string;
keystore: Keystore | null;
@@ -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,64 @@
import { AndroidKeystoreType } from '../../../../graphql/generated';
import { testKeystore, testPKCS12Keystore } from '../../../__tests__/fixtures-android';
import { getKeystoreType, getKeystoreWithType, validateKeystore } from '../keystoreNew';
describe('getKeystoreType', () => {
it('identifies a valid jks keystore', async () => {
const type = getKeystoreType(testKeystore);
expect(type).toBe(AndroidKeystoreType.Jks);
});
it('identifies a valid pkcs12 keystore', async () => {
const type = getKeystoreType(testPKCS12Keystore);
expect(type).toBe(AndroidKeystoreType.Pkcs12);
});
it('identifies a jks keystore with bad password as unknown', async () => {
const keystoreWithType = getKeystoreType({
...testKeystore,
keystorePassword: 'not-the-password',
});
expect(keystoreWithType).toBe(AndroidKeystoreType.Unknown);
});
it('identifies a pkcs keystore with bad password as unknown', async () => {
const keystoreWithType = getKeystoreType({
...testPKCS12Keystore,
keystorePassword: 'not-the-password',
});
expect(keystoreWithType).toBe(AndroidKeystoreType.Unknown);
});
it('identifies a bogus keystore as unknown', async () => {
const type = getKeystoreType({ ...testKeystore, keystore: 'sdfsdfsdfdsfsfdsf' });
expect(type).toBe(AndroidKeystoreType.Unknown);
});
});
describe('validateKeystore', () => {
it('validates a correctly formatted jks keystore', async () => {
const keystoreWithType = getKeystoreWithType(testKeystore);
expect(() => validateKeystore(keystoreWithType)).not.toThrow();
});
it('doesnt validate a jks keystore with wrong alias', async () => {
const keystoreWithType = getKeystoreWithType({
...testKeystore,
keyAlias: 'non-existent-alias',
});
expect(() => validateKeystore(keystoreWithType)).toThrow();
});
it('doesnt validate a jks keystore with wrong key password', async () => {
const keystoreWithType = getKeystoreWithType({
...testKeystore,
keyPassword: 'not-the-password',
});
expect(() => validateKeystore(keystoreWithType)).toThrow();
});
it('validates a correctly formatted pkcs 12 keystore', async () => {
const keystoreWithType = getKeystoreWithType(testPKCS12Keystore);
expect(() => validateKeystore(keystoreWithType)).not.toThrow();
});
it('doesnt validate a PKCS 12 keystore with wrong alias', async () => {
const keystoreWithType = getKeystoreWithType({
...testPKCS12Keystore,
keyAlias: 'non-existent-alias',
});
expect(() => validateKeystore(keystoreWithType)).toThrow();
});
});
@@ -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();
});
});
@@ -5,9 +5,10 @@ import path from 'path';
import tempy from 'tempy';
import { v4 as uuidv4 } from 'uuid';
import { AndroidKeystoreType } from '../../../graphql/generated';
import Log from '../../../log';
import { getTmpDirectory } from '../../../utils/paths';
import { Keystore } from '../credentials';
import { Keystore, KeystoreWithType } from '../credentials';
export async function keytoolCommandExistsAsync(): Promise<boolean> {
try {
@@ -82,7 +83,7 @@ async function createKeystoreAsync(credentials: {
keystorePassword: string;
keyAlias: string;
keyPassword: string;
}): Promise<Keystore> {
}): Promise<KeystoreWithType> {
await ensureKeytoolCommandExistsAsync();
await fs.mkdirp(getTmpDirectory());
const keystorePath = path.join(getTmpDirectory(), `${uuidv4()}-keystore.jks`);
@@ -112,13 +113,14 @@ async function createKeystoreAsync(credentials: {
return {
...credentials,
keystore: await fs.readFile(keystorePath, 'base64'),
type: AndroidKeystoreType.Jks,
};
} finally {
await fs.remove(keystorePath);
}
}
export async function generateRandomKeystoreAsync(): Promise<Keystore> {
export async function generateRandomKeystoreAsync(): Promise<KeystoreWithType> {
const keystoreData = {
keystorePassword: crypto.randomBytes(16).toString('hex'),
keyPassword: crypto.randomBytes(16).toString('hex'),
@@ -0,0 +1,91 @@
import { getX509Asn1ByFriendlyName, parsePKCS12 } from '@expo/pkcs12';
import jks from 'jks-js';
import { asn1 } from 'node-forge';
import { AndroidKeystoreType } from '../../../graphql/generated';
import Log from '../../../log';
import { Keystore, KeystoreWithType } from '../credentials';
export function getKeystoreWithType(keystore: Keystore): KeystoreWithType {
const type = getKeystoreType(keystore);
return { ...keystore, type };
}
export function getKeystoreType(keystore: Keystore): AndroidKeystoreType {
if (isPKCSKeystore(keystore)) {
return AndroidKeystoreType.Pkcs12;
} else if (isJKSKeystore(keystore)) {
return AndroidKeystoreType.Jks;
}
return AndroidKeystoreType.Unknown;
}
function isPKCSKeystore(keystore: Keystore): boolean {
try {
parsePKCS12(keystore.keystore, keystore.keystorePassword);
return true;
} catch (e) {
return false;
}
}
function isJKSKeystore(keystore: Keystore): boolean {
try {
jks.parseJks(Buffer.from(keystore.keystore, 'base64'), keystore.keystorePassword);
return true;
} catch (e) {
return false;
}
}
export function validateKeystore(keystore: KeystoreWithType): void {
if (keystore.type === AndroidKeystoreType.Jks) {
getPemFromJksKeystore(keystore);
} else if (keystore.type === AndroidKeystoreType.Pkcs12) {
getX509Asn1FromPKCS12Keystore(keystore.keystore, keystore.keystorePassword, keystore.keyAlias);
} else if (keystore.type === AndroidKeystoreType.Unknown) {
Log.warn('Unknown keystore type, skipping validation.');
} else {
Log.warn(`Unsupported keystore type: ${keystore.type}, skipping validation.`);
}
}
function getPemFromJksKeystore(keystore: Keystore): string {
const keystoreEntries = jks.parseJks(
Buffer.from(keystore.keystore, 'base64'),
keystore.keystorePassword
);
// keystore entries are case insensitive in both keytool and jks-js implementations
const keystoreEntry = keystoreEntries.find(
(entry: any) => entry.alias.toLowerCase() === keystore.keyAlias.toLowerCase()
);
if (!keystoreEntry) {
throw new Error(`JKS Keystore does not contain alias: ${keystore.keyAlias}`);
}
const { protectedPrivateKey } = keystoreEntry;
try {
return jks.decrypt(protectedPrivateKey, keystore.keyPassword);
} catch (e) {
throw new Error(`${e.message}: Check that your key password is correct`);
}
}
function getX509Asn1FromPKCS12Keystore(
p12BufferOrBase64String: Buffer | string,
maybePassword: string | null,
keyAlias: string
): asn1.Asn1 {
let x509Asn1;
try {
const p12 = parsePKCS12(p12BufferOrBase64String, maybePassword);
x509Asn1 = getX509Asn1ByFriendlyName(p12, keyAlias);
} catch (e) {
throw new Error(`Invalid PKCS#12 (.p12) keystore: ${e.message}`);
}
if (!x509Asn1) {
throw new Error(
`PKCS#12 keystore: Unable to get certificate under alias: ${keyAlias}. Run this to find the available aliases: keytool -list -v -keystore [your-keystore-path]`
);
}
return x509Asn1;
}
@@ -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);
}
}
+7 -7
View File
@@ -5,10 +5,10 @@ import pick from 'lodash/pick';
import Log from '../log';
import { getProjectAccountName } from '../project/projectUtils';
import { confirmAsync } from '../prompts';
import { Actor } from '../user/User';
import { ensureLoggedInAsync, getActorDisplayName } from '../user/actions';
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;
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more