Compare commits

...
336 Commits
Author SHA1 Message Date
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
Dominik Sokal ced2e192eb v0.4.0 2021-02-16 15:08:05 +01:00
Dominik Sokal ee91612c07 [eas-cli][eas-json] add schemeBuildConfiguration to eas.json (#234)
* [eas-cli] use newer @expo/config-plugins

* [eas-cli][eas-json] add buildConfiguration to eas.json

* address PR feedback

* upgrade @expo/eas-build-job and @expo/config-plugins

* [eas-json] reuse type from @expo/eas-build-job

* [eas-json] fix exports
2021-02-16 14:40:57 +01:00
Quinlan Jung 4d815c1fd9 [eas-cli] tests for credential removal (#236)
* [eas-cli] tests for credential removal

* fix tsc
2021-02-15 18:11:51 -08:00
Quinlan Jung d2687a55b7 [eas-cli] basic RemoveDistCert (and RemoveProvisioningProfile) (#221)
* [www] remove dist cert and prov profile

* pr feedback

* fix tests

* cleanup removal code

* fix tsc
2021-02-15 17:50:24 -08:00
jkh 41142d33dc [eas-cli] remove trailing whitespace from default branch name (#235) 2021-02-15 13:12:52 -08:00
jkh 52ab6107c5 [eas-cli] rename EAS release to branch (#233) 2021-02-15 12:41:12 -08:00
Wojciech Kozyra 56ac348ed4 [eas-cli] configurable builder environment (#230) 2021-02-15 08:55:07 +01:00
Dominik Sokal 1ac0af53ab [ENG-9] [eas-cli] implement automatic version bumping for ios builds (#231)
* [eas-cli] implement automatic version bumping for ios builds

* [eas-cli] autoincrement -> autoIncrement

* [eas-cli] code style fix

* [eas-cli][eas-json] address PR feedback

* more pr feedback
2021-02-12 14:37:19 +01:00
Wojciech Kozyra f9361b471c [eas-cli] fix running builds from subdirectory (#229) 2021-02-09 11:08:38 +01:00
Paul Ridgway 8f39e0dcde Allow wait to be set to false for eas build #225 (#226) 2021-02-08 13:23:31 +01:00
Wojciech Kozyra 81de402d2d [eas-cli] add build:cancel command (#219)
* [eas-cli] add build:cancel command

* review feedback
2021-02-08 13:23:08 +01:00
Wojciech Kozyra 9d6e3f52d8 [eas-cli] Fix missing android.package error (#224) 2021-02-08 13:20:22 +01:00
Will Schurman 6a0a9bbaaa Fix potential broken this binding when passing logger function as argument (#227) 2021-02-04 09:38:02 -08:00
Will Schurman 24bc29b3cd Refactor log module so that weird __esModule mocks are not needed (#223) 2021-02-03 16:26:58 -08:00
Brent Vatne aac5ebff73 v0.3.1 2021-01-25 12:05:30 -08:00
Wojciech KozyraandDominik Sokal 61cc5b0a4a [eas-cli] support canceled build status (#217)
* [eas-cli] support `canceled` build status

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

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

Co-authored-by: Dominik Sokal <[email protected]>
2021-01-22 11:00:56 +01:00
Wojciech Kozyra 1383783c33 [eas-cli] check if tty is atched on prompt (#216) 2021-01-21 15:24:25 +01:00
Dominik Sokal 971129be6b [eas-cli] improve monorepo support - fix configuring expo-updates (#214)
* [eas-cli] improve monorepo support - fix configuring expo-updates

* [eas-cli] bump @expo/config-plugins to 1.0.15-alpha.0
2021-01-21 12:08:30 +01:00
Quinlan Jung f4fb10483a [eas-cli] filter stale profiles (#215)
* [eas-cli] filter stale profiles

* pr feedback
2021-01-20 15:30:54 -08:00
Wojciech Kozyra 256a0e1f22 [eas-cli] return non-zero exit code if prompt is displayed on CI (#208) 2021-01-20 14:57:08 +01:00
William Schurman b82f0b04fe v0.3.0 2021-01-19 11:57:24 -08:00
Quinlan Jung 9714420f90 [eas-cli] select dist certs (#205)
* [eas-cli] select dist certs

* remove validity unknown

* pr feedback

* tests
2021-01-19 11:50:02 -08:00
Dominik Sokal 0054f0bc26 update release instructions (#212) 2021-01-19 20:42:54 +01:00
Will Schurman 38f565d849 [eas-cli] 2FA support (#204) 2021-01-19 09:37:45 -08:00
Dominik Sokal 1aff7ae2a3 fix typo 2021-01-19 16:22:20 +01:00
Dominik Sokal 31020047ca v0.2.1 2021-01-19 11:52:08 +01:00
Dominik Sokal 26778ec52b [eas-cli] hide release:publish command (#211) 2021-01-19 11:49:19 +01:00
Dominik Sokal b27590f4f5 v0.2.0 2021-01-19 11:42:09 +01:00
Quinlan Jung ea2af5af04 [eas-cli] fix current tests (#210) 2021-01-18 19:17:35 -08:00
jkh 3f1a7a0da0 [eas-cli] rework expected bundle format for eas publish (#196) 2021-01-18 13:00:11 -08:00
Quinlan Jung 8184a13bf3 [eas-cli] tests for initial credentials fetch (#203)
* [eas-cli] test for initial credentials fetch

* fix date

* fix date

* fix date
2021-01-15 12:49:47 -08:00
Dominik Sokal 2f7be56bef [eas-cli] add support for building multi-target ios projects via credentials.json (#202)
* [eas-cli] add support for building multi-target ios projects via credentials.json

* [eas-cli] address PR feedback

* [eas-cli] bump @expo/config-plugins and @expo/eas-build-job
2021-01-15 10:49:30 +01:00
Dominik Sokal 5c166bb3b2 nit: Expo servers -> EAS servers (#206) 2021-01-15 09:50:03 +01:00
Wojciech Kozyra 4e76526843 [eas-cli] create tarbal with shallow clone fo repository (#195) 2021-01-14 11:17:18 +01:00
Wojciech Kozyra 0e6fae0632 simplify credentials manager (#200)
* simplify credentials manager

* rebase + fix ManageIosBeta
2021-01-14 10:51:38 +01:00
Wojciech KozyraandDominik Sokal 62a8fd1b02 [eas-cli] fix message for deprecation errors (#201)
* [eas-cli] fix message for deprecation errors

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

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

Co-authored-by: Dominik Sokal <[email protected]>
2021-01-14 10:21:13 +01:00
Quinlan Jung 5a8e252790 [eas-cli] initial fetch for credentials manager (#193)
* [eas-cli] initial credentials fetch for manager

* fix tsc

* oops
2021-01-13 14:32:18 -08:00
Quinlan Jung 78613cd7c0 [eas-cli] use autogen query types - credentials (#199)
* [eas-cli] use autogen query types - credentials

* pr feedback
2021-01-12 15:37:29 -08:00
Quinlan Jung 8f6f7f7e1c [eas-cli] use autogen query types (#198) 2021-01-12 15:04:35 -08:00
Quinlan Jung f46ad6b898 [eas-cli] autogen credential types, ensure unique identifiers 2021-01-11 16:20:44 -08:00
Quinlan Jung f153b08a1e [eas-cli] use more accurrate graphql types (#185)
* [eas-cli] use more accurrate gql types

* fix tsc and merge conflicts
2021-01-11 15:39:20 -08:00
Quinlan Jung 6b57f00d7d [eas-cli] apply caps name rule (#191) 2021-01-11 14:07:04 -08:00
Quinlan Jung de65ab702b [eas-cli] require id field rule (#192) 2021-01-11 14:01:24 -08:00
Quinlan Jung a87b735832 [eas-cli] apply named operations rule (#189) 2021-01-11 13:56:54 -08:00
Quinlan Jung afc6022529 [eas-cli] 2/n apply template literals rule (#188)
* [eas-cli] apply template literals rule

append doc to frag names

* pr feedback
2021-01-11 13:52:11 -08:00
Quinlan Jung 2d3bb21f60 [eas-cli] setup eslint for graphql (#187) 2021-01-11 12:37:28 -08:00
Dominik Sokal c26f478b45 [eas-cli] make test output cleaner (#194) 2021-01-11 14:32:20 +01:00
Wojciech Kozyra f958a0db3e [eas-cli] fix deprecation message expo-cli -> eas-cli 2021-01-11 12:14:24 +01:00
Dominik Sokal 6df1130140 [eas-cli] better wording 2021-01-11 11:25:37 +01:00
Quinlan Jung 260a771070 [eas-cli] gql nits (#186) 2021-01-08 16:53:48 -08:00
Quinlan Jung 6659ed97ca [eas-cli] generate fragment types (#184)
* [eas-cli] generate fragment types

* pr feedback
2021-01-08 12:44:27 -08:00
Quinlan Jung 4eae49fe58 [eas-cli] more gql type cleanup (#183) 2021-01-07 10:46:33 -08:00
Quinlan Jung 41fef5c0e4 [eas-cli] cleanup pesky Maybe types (#182) 2021-01-05 10:59:58 -08:00
jkh 8c4b45a439 [eas-cli] add release:publish 4/4 (#146) 2021-01-04 06:10:34 -08:00
Evan BaconandBrent Vatne e35b5e78d2 [eas cli] Surface Apple contract messages (#176)
* Added support for showing contract messages in CLI

* Update contractMessages.ts

* assert contract messages in more thoughtful places

* Update contractMessages-test.ts

* fix tests

* use wrap-ansi instead of wordwrap

* Update yarn.lock

* updated comments

* Apply suggestions from code review

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

* prevent false positives

Co-authored-by: Brent Vatne <[email protected]>
2020-12-23 13:50:06 -08:00
Evan Bacon b04c1a2c20 use wrap-ansi instead of wordwrap (#177) 2020-12-23 12:40:53 -08:00
Evan Bacon 25d75b6f94 add terminal link support (#178) 2020-12-23 12:40:42 -08:00
Ville Immonen a2adacf5a3 Add release:list command (#91) 2020-12-22 17:32:13 +02:00
Cedric van Putten ca718d8da9 v0.1.2 2020-12-21 13:59:59 +01:00
Cedric van Putten 3e7b849ad1 [eas-cli] Force update plist library to fix missing invariant (#174) 2020-12-21 13:47:24 +01:00
Dominik Sokal 2ec646659d [eas-cli] fix exiting 'eas build' when there are other pending builds 2020-12-21 11:10:45 +01:00
Dominik Sokal c33e3d8a4c [eas-cli] eas build - print an error when there's another pending build (#169) 2020-12-21 10:51:01 +01:00
jkh d65c180746 [eas-cli] add publish.ts 3/4 (#147) 2020-12-18 08:43:34 -08:00
jkh 754af79e0c [expo-cli] set noImplicitReturns to true (#166) 2020-12-17 16:51:34 -08:00
Bartłomiej Klocek 3733cc4cf8 Fix no-newline typo 2020-12-16 11:56:43 +01:00
jkh 9e5c1740b4 [eas-cli] add graphql calls 2/4 (#149) 2020-12-15 18:01:21 -08:00
Brent Vatne 58919d37ab v0.1.1 2020-12-14 20:37:37 -08:00
Evan Bacon 2ec4279ac7 [submit] Unify app creation code (#158) 2020-12-14 20:36:12 -08:00
Evan Bacon 3052276c3b fix tests (#161)
* await test rejection

* fix tests
2020-12-14 16:07:16 -08:00
Evan Bacon 65a9238d48 [eas-cli] Simplify the prompts for auto generation (#156) 2020-12-14 16:03:31 -08:00
Evan Bacon 5a1bb94563 Improve logging (#154)
* Update configure.ts

* Update build.ts

* remove Successfully, fix casing

* improve credentials logging

* updated logging

* more minor changes

* Update RemoveKeystore.ts

* change log treatment

* fix tests

* fix lint
2020-12-14 15:41:41 -08:00
Bartłomiej Klocek 1094d3f9f7 [eas submit] Add missing iOS error messages (#125)
* Handle iOS missing app icon error
* Handle invalid signature error
2020-12-14 15:37:00 -08:00
Wojciech Kozyra d8452ce6f8 [eas-cli] Improve keytool detection (#152) 2020-12-14 15:23:03 -08:00
Evan Bacon a5be039fd2 Show tar logging if it takes a while (#155)
* Update repository.ts

* Update repository.ts
2020-12-14 15:22:39 -08:00
Evan Bacon 6178a54292 Simplify table logs (#157) 2020-12-14 15:22:23 -08:00
Brent Vatne 3a2e9c42ab v0.1.0 2020-12-14 14:16:58 -08:00
Brent Vatne 8bfb97aa1c [yarn.lock] Update lockfile 2020-12-14 14:16:17 -08:00
Cedric van Putten 4c32b9bdec [eas-cli] Use project account name in prepare managed job (#159) 2020-12-14 23:14:29 +01:00
Evan Bacon 6dcb29caa9 Update authenticate.ts 2020-12-14 13:56:06 -08:00
Evan Bacon 917a46eea5 Update credentials.ts 2020-12-14 13:18:30 -08:00
Cedric van PuttenandVille Immonen 2be9d92268 [eas-cli] Add support for Robot users (#108)
* [eas-cli] Add robot user integration

* [eas-cli] Reimplement robots integration with GraphQL types

* [eas-cli] Fix resolving credentials context account owner

* [eas-cli] Remove isExpoAdmin from user query

* [eas-cli] Add exp stub to @expo/config mock

* [eas-cli] Use passive account name resolver in credentials

* [eas-cli] Refactor to  passive account name in more areas

* [eas-cli] Add unit tests to user methods

* [eas-cli] Regenerate graphql types

* [eas-cli] Add project utils unit tests

* [eas-cli] Add strict return type to project account name helper

Co-authored-by: Ville Immonen <[email protected]>

* [eas-cli] Simplify expo config mock

Co-authored-by: Ville Immonen <[email protected]>

* [eas-cli] Emphasize on account instead of user type

Co-authored-by: Ville Immonen <[email protected]>

* [eas-cli] Add strict return type to actor username helper

Co-authored-by: Ville Immonen <[email protected]>

* [eas-cli] Add ensureAccounts helper to workaround graphql typing issue

* [eas-cli] Remove extraneous type check in credentials context

Co-authored-by: Ville Immonen <[email protected]>

Co-authored-by: Ville Immonen <[email protected]>
2020-12-14 21:50:06 +01:00
Evan Bacon 29e644ab1e Implement new progress logs (#153)
* Implement new loader

* added debug logging
2020-12-14 12:35:38 -08:00
Wojciech Kozyra 843a2fd10a [eas-cli] private bucket support for project archive (#142) 2020-12-14 15:22:00 +01:00
jkh 4d8e6cc53a [eas-cli] add helper functions for release:publish 1/4 (#148) 2020-12-13 16:12:26 -08:00
Brent Vatne a52e443a44 v0.1.0-alpha.23 2020-12-11 19:44:07 -08:00
Brent Vatne 70eb4a1b64 [eas-cli] Add warning if update is available 2020-12-11 19:36:12 -08:00
Brent Vatne 19b5cb9b86 Update README 2020-12-11 12:31:45 -08:00
Brent Vatne 5d026ea462 v0.1.0-alpha.22 2020-12-11 12:30:59 -08:00
Brent Vatne 9c972430bb [eas-cli] Only configure eas.json for the requested platform (#151)
* [eas-cli] Improve app is ready message when picking just one platform

* [eas-cli] Only configure eas.json for given platform, update if eas.json already exists
2020-12-11 10:19:41 -08:00
Satyajit Sahoo cff9a7a1d0 [eas-cli] Add device:list and device:view commands
This adds commands to view registered devices for an account.
2020-12-11 14:08:06 +01:00
Ville Immonen 3cbc670d90 [eas-cli] Update GraphQL types 2020-12-11 12:57:27 +02:00
Brent Vatne 5b39620619 [eas-cli] Handle case where expo object is not defined on app config for bundleIdentifier 2020-12-10 20:20:32 -08:00
Brent Vatne 8498547377 Commit generated README updates 2020-12-10 19:49:57 -08:00
Brent Vatne 175103ed32 v0.1.0-alpha.21 2020-12-10 17:57:09 -08:00
Brent Vatne e177b94ca1 [eas-cli] Slightly easier to read output when build is completed (#150) 2020-12-10 16:35:32 -08:00
jkh 193da3cf46 [eas-cli] fix mutation should be a query (#107) 2020-12-10 07:15:19 -08:00
Satyajit Sahoo f29dd0b89a [eas-cli] Add build:list and build:view commands
This commit adds build:list and build:view commands to show status of builds.

When running build:list, it shows last 10 builds, but it can be configured by passing status, platform and limit filters.

When running build:view, it shows the last build. An id param can be passed to view a specific build.
2020-12-10 13:35:09 +01:00
Ville Immonen 14cd1835dd Add size compare action (#139) 2020-12-10 14:10:14 +02:00
Wojciech Kozyra 01f82bb873 [eas-cli] fix apple auth on linux (#141) 2020-12-10 10:46:05 +01:00
Dominik Sokal 844eddf127 [eas-cli] add app name and app identifier to build metadata (to fix internal distribution for unpublished projects) (#140) 2020-12-10 10:44:52 +01:00
Brent Vatne bdb7bf8475 [eas-cli] Account for no expo.android property existing in app.json. Fixes #143 2020-12-09 19:56:30 -08:00
jkh 04b687a050 [eas-cli] add release:view (#96) 2020-12-09 05:06:10 -08:00
Ville Immonen a67bf6877a Add build:tarballs:linux to package.json
This is needed for the size compare action (#124)
2020-12-09 11:36:20 +02:00
Brent Vatne ea8fd7d4ff v0.1.0-alpha.20 2020-12-08 16:20:43 -08:00
Dominik Sokal bc69ef34ff [eas-cli] print better message when internal distribution build completes (#130) 2020-12-08 16:16:40 -08:00
Evan Bacon 38a0ef8b1e fetch all certificates, and filter locally (#135)
* filter certs locally

* remove unused
2020-12-08 12:41:40 -08:00
jkh fd21811e85 [eas-cli] add channel:create (#98) 2020-12-08 12:34:28 -08:00
Ville Immonen 17d4fbbf45 Fix the release action (#134)
* Fix the release build workflow

* v0.1.0-alpha.19
2020-12-08 22:02:50 +02:00
Brent Vatne 8f455237c7 [eas-cli] Prevent importing extraneous dependencies 2020-12-08 09:58:58 -08:00
Brent Vatne fd340c2bb5 v0.1.0-alpha.18 2020-12-08 09:39:16 -08:00
Brent Vatne 6e2accf00a [eas-cli] Move tempy to regular dependency 2020-12-08 09:38:55 -08:00
Wojciech KozyraandDominik Sokal 26e0ac7318 [eas-cli] detect missing android or ios directories (#123)
* [eas-cli] detect missing android or ios directories

Co-authored-by: Dominik Sokal <[email protected]>
2020-12-08 12:31:12 +01:00
Evan Bacon f58dc5db08 Update AppProduce.ts 2020-12-07 21:55:00 -08:00
Brent Vatne aba6b31bbd v0.1.0-alpha.17 2020-12-07 21:51:34 -08:00
Evan Bacon 5db237afd3 extract auth prompts out of apple-utils (#113)
* extract auth prompts out of apple-utils

* Update authCredentials.ts

* Update authCredentials.ts

* clean up

* remove unused
2020-12-07 21:47:48 -08:00
Evan Bacon 45815e2dc6 Improve error messages for two new cases (#126) 2020-12-07 21:47:30 -08:00
Evan Bacon d827a1dad9 typos and formatting (#128) 2020-12-07 21:46:54 -08:00
Evan Bacon 5a94367073 fix log alignment 2020-12-07 15:48:16 -08:00
Evan Bacon eab7c24d53 Updating -> syncing 2020-12-07 15:41:46 -08:00
Evan Bacon 24b4b9b59f Update SetupProvisioningProfile.ts 2020-12-07 15:33:39 -08:00
Evan Bacon f79d9b2f56 terminate silently on abort (#112) 2020-12-07 13:13:56 -08:00
Evan Bacon 0237c51f48 Implement auto resolver (#117) 2020-12-07 08:38:04 -08:00
Wojciech KozyraandDominik Sokal c49f6cce18 [eas-cli] Fix support for non-interactive and skip-credentials-check (#122)
* [eas-cli] Fix support for non-interactive and skip-credentials-check

* Apply suggestions from code review

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

* fix for internal distribution

Co-authored-by: Dominik Sokal <[email protected]>
2020-12-07 16:08:24 +01:00
Brent Vatne 61a3c01e26 [eas-cli] Remove v2 from the build details path 2020-12-05 15:38:06 -08:00
Brent Vatne 625e7b588a [eas-cli] Update "Logs url" to "Build details" 2020-12-05 15:37:54 -08:00
Brent Vatne 0817cb36b8 v0.1.0-alpha.16 2020-12-05 12:54:25 -08:00
Brent Vatne 46220028ab [eas-cli] Move getSessionSecret and getAccessToken to sessionStorage to avoid dependency cycle. Also add eslint rule to ensure it does not happen again 2020-12-05 12:53:35 -08:00
Brent Vatne b546716582 v0.1.0-alpha.15 2020-12-05 11:53:03 -08:00
Brent Vatne 46342c8cb1 [eas-cli] Bump config/config-plugins 2020-12-05 11:52:21 -08:00
Brent Vatne a281b500d4 [eas-cli] Fix package.json import path to eas-cli rather than monorepo root 2020-12-05 11:45:47 -08:00
Brent Vatne 101fda5ee3 v0.1.0-alpha.14 2020-12-05 11:32:04 -08:00
Brent Vatne 67fed5bcc1 [eas-cli] Bump @expo/config-plugins to fix missing invariant dependency 2020-12-05 11:31:45 -08:00
Brent Vatne aec4de430d v0.1.0-alpha.13 2020-12-05 11:17:28 -08:00
Brent Vatne e8ac985b89 [eas-cli] Move graphql from devDependencies to dependencies 2020-12-05 11:17:08 -08:00
Brent Vatne c937987772 [eas-cli][eas-json] Remove __test__ from build 2020-12-05 11:14:03 -08:00
Brent Vatne 03f4ab3e8b v0.1.0-alpha.12 2020-12-05 11:09:17 -08:00
Brent Vatne afeb8df13e [eas-json] Point main to build rather than src 2020-12-05 11:08:53 -08:00
Evan Bacon bc73db7656 Update IosSubmitter.ts 2020-12-04 18:59:26 -08:00
Brent Vatne 511b3f3e4a [cli] Update README from publish 2020-12-04 11:23:34 -08:00
Brent Vatne 0633cfefd0 [eas-cli] Add yarn before calling oclif-dev so it resolves from local install 2020-12-04 11:23:21 -08:00
Brent Vatne 77ea5a60a5 v0.1.0-alpha.11 2020-12-04 11:20:27 -08:00
Wojciech Kozyra 8a8c4b4382 [eas-cli] build configure and create improvments (#105) 2020-12-04 11:35:08 +01:00
Evan Bacon 75e3563e75 Fix typos 2020-12-03 17:40:21 -08:00
Evan Bacon 119dd2180c Update ServiceAccountSource.ts 2020-12-03 15:26:31 -08:00
Evan Bacon 12e98beec7 clean up spinners and logs (#111)
* Unify push spinners

* clean up spinner logs

* Update ConfigureProvisioningProfile.ts

* Update validateProvisioningProfile.ts

* Update ensureAppExists.ts

* Update ensureAppExists.ts

* Update create.ts

* Update provisioningProfileAdhoc.ts
2020-12-03 15:24:05 -08:00
Evan Bacon b829e00b5b cleaner logging + typos (#110)
* use shorter expo project server log

* "App" instead of "Artificat url"

* fix typos

* fix error

* typo

* typo

* EAS Account -> Expo Account

* Update ensureProjectExists.ts

* Update ensureProjectExists.ts
2020-12-03 14:39:37 -08:00
Evan Bacon fd56a19156 unify learn more url's format and clean up logs (#109)
* unify learn more url's format

* Downgrade error log to warning

* Update create.ts

* add underline to logs url

* unify eas warning
2020-12-03 10:59:13 -08:00
Wojciech Kozyra 2607430fa8 [eas-cli] validate bundleIdentifer and applicationId (#106)
* [eas-cli] validate bundleIdentifer and applicationId

* review feedback
2020-12-03 18:37:11 +01:00
Evan Bacon a449413a3e Fix certificate bug -- upgrade apple-utils to 11 2020-12-03 09:17:07 -08:00
Ville Immonen 15bb9f9b24 Hide work-in-progress commands from --help (#102)
✨
2020-12-03 05:26:16 -08:00
Brent Vatne dd2ef27115 [eas-cli] Improve wording around archiving and uploading project (#104)
* [eas-cli] Improve wording around archiving and uploading project

* [eas-cli] Build servers sounds a bit better
2020-12-03 11:25:20 +01:00
Dominik Sokal 250b257507 add PR template (#101) 2020-12-03 09:48:04 +01:00
Dominik SokalandWojciech Kozyra b32955136c [eas-cli] improve internal distribution on android (#100)
* [eas-cli] improve internal distribution on android

* [eas-cli] address feedback

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

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

* [eas-cli] add prompt

Co-authored-by: Wojciech Kozyra <[email protected]>
2020-12-03 09:45:20 +01:00
Wojciech Kozyra 8b73152765 [eas-cli] configure app identifier for iOS and Android (#93)
* [eas-cli] configure app identifier for iOS and Android

* review feedback + some fixes
2020-12-02 10:38:54 +01:00
Dominik SokalandVille Immonen 4c8181fa82 [eas-cli] disable eas build & submit for non-paying users (#87)
* [eas-cli] disable eas build & submit for non-paying users

* [eas-cli] better 404 check

* [eas-cli] add link to eas landing page

* Update packages/eas-cli/src/project/isEasEnabledForProject.ts

Co-authored-by: Ville Immonen <[email protected]>

* [eas-cli] don't use _.once for getProjectIdAsync

Co-authored-by: Ville Immonen <[email protected]>
2020-12-01 16:41:18 +01:00
Wojciech Kozyra aa54eca4ee Fail CI checks if there are lint warnings (#95)
* Fail CI checks if there are lint warnings

* fix lint warning
2020-12-01 12:39:21 +01:00
Bartłomiej Klocek 8a0a5a249e Fix typos 2020-11-30 21:14:01 +01:00
jkh 77e6f42161 [eas-cli] add release:rename (#77) 2020-11-30 08:28:41 -08:00
Ville Immonen 7c8603138b Update generated GraphQL types
This code was generated by running
    yarn generate-graphql-code
2020-11-30 17:50:33 +02:00
Wojciech Kozyra ebade7523e [eas-cli] commit changes when updating version (#94) 2020-11-30 15:36:38 +01:00
Ville Immonen 406605b509 Add publishConfig for lerna 2020-11-27 17:29:38 +02:00
Ville Immonen 77f881af67 v0.1.0-alpha.10 2020-11-27 17:24:53 +02:00
Evan Bacon 4714ddc8d6 Upgrade expo/config (#90) 2020-11-27 17:14:24 +02:00
Ville Immonen a94b187da5 Replace hand-written GraphQL schema types with generated types (#92)
* Rename generated.tsx to generated.ts

Importing from tsx resulted in an error and the file doesn't use any tsx features (JSX)

* Remove hand-written GraphQL types, update imports, rename enums

* Fix remaining type errors

* Correct generated.ts rule in .eslintignore
2020-11-27 17:09:48 +02:00
Wojciech Kozyra 636a1f110a remove duplicated dependecies from package.json 2020-11-27 16:03:12 +01:00
Wojciech Kozyra e6e8bbcaf6 improve user interaction for build and configure + cleanup types (#84) 2020-11-27 12:27:32 +01:00
Evan Bacon 4f49135c1f Improve error/warning messages (#88)
* Improve messages

* Update AppProduce.ts
2020-11-26 19:52:46 +01:00
Ville Immonen 10cc68357e v0.1.0-alpha.9 2020-11-26 17:12:09 +02:00
Ville Immonen 932f641ae7 Remove traveling-fastlane (#86) 2020-11-26 17:05:57 +02:00
Ville Immonen a139571ef5 v0.1.0-alpha.8 2020-11-26 16:31:39 +02:00
Bartłomiej Klocek bf067f99a9 [eas-cli] Support for App Store API in iOS submissions (#82)
* [ios submit] Initial support for App Store API

* Update language support

* Fixes

* Language list source and tests
2020-11-26 13:06:30 +01:00
Evan Bacon a6d6593cd3 prompt team once (#85)
* Update authenticate.ts

* Update authenticate.ts
2020-11-25 22:14:41 +01:00
Ville Immonen f485e4b6ef Fix sorting of dependencies 2020-11-25 18:06:13 +02:00
Ville Immonen df80eb1bd9 Rename @eas/config to @expo/eas-json (#83)
* Rename @eas/config to @expo/eas-json

* Fix eslint warnings

* Add missing newline at the end of file
2020-11-25 15:42:39 +02:00
Ville Immonen 2a67e0206b Add tsc to CI pipeline (#78) 2020-11-25 15:22:57 +02:00
Bartłomiej Klocek 2b5d91b622 Fix tsc error 2020-11-25 13:12:15 +01:00
Evan Bacon 1c5a6aae76 upgraded apple utils to support request contexts (#79)
* upgraded apple utils to support request contexts

* bump apple-utils

* Update ensureAppExists.ts

* refactor

* upgrade
2020-11-25 12:32:59 +01:00
Bartłomiej Klocek 2ae64e70ad [eas-cli] Add AppStore app creation to submission command (#65)
* Remove --app-specific-password flag

* Add fastlane utility

* Fastlane-based app store create

* Update flags description

* Password prompt type

* Update help

* Address some feedback

* Move to `eas submit`, bundleId, fyi, --asc-app-id

* Apply suggestions + improvements
2020-11-25 11:56:54 +01:00
Ville Immonen a302978636 Fix a typo in provisioningProfileAdhoc.ts (#80) 2020-11-24 17:15:32 +01:00
Dominik SokalandWojciech Kozyra 1ebf16cf08 [eas-cli] build - implement internal distribution (#59)
* [eas-cli] upgrade prompts to 2.0.9

* [config] add 'internal' key to build profiles

* [eas-cli] add support for generating credentials for internal distribution

* [config] fix tests

* [eas-cli] make sure internal distribution works fine with apple-utils

* [eas-cli] fix lint warning

* Update packages/eas-cli/src/credentials/manager/ManageIos.ts

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

* [eas-cli] address review feedback - part 1

* [eas-cli] address feedback - change 'internal: true' to 'distribution: internal'

* [eas-cli] upgrade traveling-fastlane to 1.15.3

* [eas-cli] fix lint warning

Co-authored-by: Wojciech Kozyra <[email protected]>
2020-11-24 13:59:13 +01:00
Ville Immonen 9a7e8953c7 Add support for grouping flags in --help (#76)
* Add support for grouping flags in --help

* Fix linter warnings

* Fix typo
2020-11-20 21:41:16 +02:00
Evan Bacon 047b8e278e Update provisioningProfileAdhoc.ts (#75) 2020-11-20 16:50:44 +01:00
Dominik Sokal 9878c07c09 [eas-cli] fix fixtures 2020-11-20 15:06:37 +01:00
Dominik Sokal acb07aa72a [eas-cli] add missing dep: figures 2020-11-20 15:06:25 +01:00
Evan Bacon e889347710 [eas-cli] Implement apple utils (#64)
* Implement apple api

* Update ensureAppExists.ts

* added dev types packages

* fix loading indicators

* refactor names

* Update provisioningProfileAdhoc.ts

* Update provisioningProfileAdhoc.ts

* Update provisioningProfileAdhoc.ts

* Update provisioningProfile.ts

* move method

* refactor

* Update authenticate.ts

* Properly throw AppleTooManyCertsError error

* refactor names

* Update provisioningProfile.ts

* batch

* Update distributionCertificate.ts

* Update bundleId.ts

* Update provisioningProfileAdhoc.ts

* Integrate auth state changes

* Update authenticate.ts

* remove extra auth check

* bump

* Update ensureAppExists.ts
2020-11-19 15:33:58 +01:00
Wojciech KozyraandDominik Sokal 3ab974856f [eas-cli] standalone credentials manager error handling (#74)
* [eas-cli] standalone credentials manager error handling

* Apply suggestions from code review

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

Co-authored-by: Dominik Sokal <[email protected]>
2020-11-19 14:40:02 +01:00
Quinlan JungandJuwan Wheatley f431feacf8 [eas-cli] graphql codegen (#70)
* graphql codegen

* Update packages/eas-cli/annotate-graphql-codegen.sh

Co-authored-by: Juwan Wheatley <[email protected]>

* pr feedback

* update annotation

Co-authored-by: Juwan Wheatley <[email protected]>
2020-11-18 13:21:58 -08:00
jkh 791e80b9fc [eas-cli] add command release:create (#71) 2020-11-18 10:24:26 -08:00
jkh 12138832a0 [eas-cli] relocate getProjectIdAsync (#73) 2020-11-18 08:45:32 -08:00
Wojciech Kozyra 302a94a9d8 [eas-cli] fix ignored errors in CredentialsManager 2020-11-18 17:42:34 +01:00
Dominik Sokal 03e754e0f6 [eas-cli] add graphql infra for new credentials (#69)
* [eas-cli] add graphql infra for new credentials

* [eas-cli] move queries/mutations for credentials to credentials/ios/api/graphql

* [eas-cli] address feedback

* [eas-cli] fix tests

* [eas-cli] address more feedback
2020-11-18 14:14:19 +01:00
Wojciech Kozyra 925cb44819 credentials.json support + fixes for managed (#66)
* [eas-cli] credentials.json support

* credentials.json support + fixes for managed workflow

* review feedback
2020-11-18 09:41:11 +01:00
Jonathan 小林 Hales ed798057a3 Revert "[eas-cli] add command release:create"
This reverts commit f08e1727bd.
2020-11-17 17:19:41 -08:00
Jonathan 小林 Hales f08e1727bd [eas-cli] add command release:create 2020-11-17 17:19:19 -08:00
Ville Immonen c8c5cb589a Fix NO_COLOR: unbound variable 2020-11-12 23:41:36 +02:00
Ville Immonen fe1b34559e Build macOS tarballs on macOS (#62) 2020-11-12 21:37:37 +02:00
Ville Immonen 4df45dd8c2 Consistently use log.ts for all logging (#61) 2020-11-11 11:54:57 +02:00
Ville Immonen fa6e894bfb Align account command names with the style guide (#60) 2020-11-11 11:26:43 +02:00
Cedric van Putten 9ed15d2b5d Add codecov commit status checks to monitor coverage (#26)
* Add code coverage paths and disable by default
* Add codecov configuration and integrate in workflow
2020-11-10 17:23:22 +02:00
Ville Immonen 07d78b31f4 Update readme (generated with oclif-dev readme) 2020-11-10 12:59:49 +02:00
Bartłomiej Klocek f342c87cf0 [eas-cli] Basic Submission command for iOS (#56)
* iOS archive type

* Extract promptForAppleId in credentials

* iOS upload core functionality

* Revert extracting "promptForAppleId" from credentials

Discussed with @wkozyra95

* Minor renames and suggestions

* Improve infering archive source type

* iOS submit command test

* Extract common code, print summary (#58)

* Submitter class hierarchy

* Extract commons from SubmitCommand

* Submission Summary

* Remove note about storing passwords

* Suggestions and fixes
2020-11-10 07:59:56 +01:00
Ville Immonen ed8d7229d0 Add Bash installer (#34) 2020-11-09 18:08:11 +02:00
Ville Immonen 2f9d388543 Add release workflow (#54) 2020-11-09 17:02:07 +02:00
Wojciech Kozyra 20a3324aab [expo-cli] configure EAS Build (#57) 2020-11-04 12:33:07 +01:00
Wojciech Kozyra 0d3008e8de use fixed @xpo/eas-build-cli package 2020-11-04 09:29:02 +01:00
Dominik Sokal 78e0732440 [eas-cli] device:create - implement typing in UDIDs (#40) 2020-10-27 16:59:33 +02:00
Bartłomiej Klocek 91e87afa74 [eas-cli] Move all GraphQL to one directory (#38)
* Move all graphql, no magic yet

* Error handling

* GraphQL variables & naming suggestions
2020-10-26 16:25:36 +01:00
Bartłomiej Klocek 3461815e74 [eas-cli][tests] Remove console logs from submission tests (#39)
* Remove console logs from submission tests

* Extract ora mock to __mocks__
2020-10-23 16:13:03 +02:00
Wojciech Kozyra 9b807bb2da [eas-cli] add build:create command (#36)
* [eas-cli] add `build:create` command

* review feedback
2020-10-22 14:16:00 +02:00
Satyajit Sahoo 8b53ca7d2f [config] Add 'releaseChannel' to generic build schema (#37)
Backports the changes from https://github.com/expo/expo-cli/pull/2765
2020-10-22 14:13:30 +02:00
Ville Immonen 943eb7087f Add tsc build to test GH action (#35) 2020-10-19 21:36:44 +03:00
Bartłomiej Klocek 9a14346b0b [eas-cli] move android submission command from expo-cli (#30)
* Add command and flags

* Copy Android submission-service code from Expo-CLI

* Delete all occurences of offline submission mode

* Fix prompts

* Remove all XDL usages

* Turtle v2 builds and fix upload

* Refactor exports

* Fixes and suggestions

* Latest build graphql

* Apply suggestions

* Remove validator lib

* Revert iOS archive type

* UUID validate signature
2020-10-16 15:52:48 +02:00
Dominik Sokal 56fb45f242 [eas-cli] device:create - implement generating registration URL (#33)
* [eas-cli] device:create - implement generating registration URL

* [eas-cli] address pr feedback
2020-10-15 16:23:17 +02:00
353 changed files with 53584 additions and 3351 deletions

No files matched your search

+6 -1
View File
@@ -1,2 +1,7 @@
build
**/build/**
!packages/eas-cli/src/commands/build/**
!packages/eas-cli/src/build/**
packages/eas-cli/src/graphql/generated.ts
node_modules
+17
View File
@@ -0,0 +1,17 @@
<!-- 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.
# How
How did you build this feature or fix this bug and why?
# Test Plan
Please describe how you tested this change and how a reviewer could reproduce your test, especially if this PR does not include automated tests! If possible, please also provide terminal output and/or screenshots demonstrating your test/reproduction.
+18
View File
@@ -0,0 +1,18 @@
name: Installer tests
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize]
jobs:
test:
name: Run the installer
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v2
- run: bash bin/install.sh
- run: eas --version
+115
View File
@@ -0,0 +1,115 @@
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
name: Release workflow
jobs:
release:
name: Create a release
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ github.ref }}
draft: true
build-linux:
name: Build for Linux
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14'
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: yarn oclif-dev pack --targets linux-x64,linux-arm
- name: Upload linux-x64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/eas-linux-x64.tar.gz
asset_name: eas-linux-x64.tar.gz
asset_content_type: application/gzip
- name: Upload linux-arm tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/eas-linux-arm.tar.gz
asset_name: eas-linux-arm.tar.gz
asset_content_type: application/gzip
build-mac:
name: Build for macOS
# Use macOS to get the correct platform specific dependencies
runs-on: macos-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14'
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: yarn oclif-dev pack --targets darwin-x64
- name: Upload darwin-x64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/eas-darwin-x64.tar.gz
asset_name: eas-darwin-x64.tar.gz
asset_content_type: application/gzip
build-windows:
name: Build for Windows
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14'
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
- run: yarn oclif-dev pack:win
working-directory: ${{ github.workspace }}/packages/eas-cli
- id: x64
run: echo "::set-output name=filename::$(ls eas-*-x64.exe)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win
- id: x86
run: echo "::set-output name=filename::$(ls eas-*-x86.exe)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win
- name: Upload x64 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload x86 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win/${{ steps.x86.outputs.filename }}
asset_name: ${{ steps.x86.outputs.filename }}
asset_content_type: application/gzip
+18
View File
@@ -0,0 +1,18 @@
name: Size
on:
pull_request:
types: [synchronize, opened]
jobs:
compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14'
- uses: preactjs/compressed-size-action@v2
with:
build-script: 'build:tarballs:linux'
repo-token: '${{ secrets.GITHUB_TOKEN }}'
pattern: './packages/eas-cli/dist/*.tar.gz'
compression: 'none'
+13 -3
View File
@@ -11,15 +11,25 @@ jobs:
strategy:
matrix:
node: ['12', '14']
include:
- node: '14'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 1
- name: Setup node
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- run: yarn install --frozen-lockfile --check-files
- run: yarn typecheck
- run: yarn test
- run: yarn lint
if: ${{ !matrix.coverage }}
- run: yarn test --coverage
if: ${{ matrix.coverage }}
- run: yarn lint --max-warnings=0
- run: shellcheck bin/*.sh
- uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
+6 -1
View File
@@ -1,8 +1,13 @@
*-debug.log
*-error.log
**/build/**
!packages/eas-cli/src/commands/build/**
!packages/eas-cli/src/build/**
dist
build
/package-lock.json
tmp
node_modules
coverage
.history
+165
View File
@@ -0,0 +1,165 @@
# Changelog
This is the log of notable changes to EAS CLI and related packages.
## main
### 🛠 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))
+10
View File
@@ -0,0 +1,10 @@
## Releasing a new version
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.
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.
+131
View File
@@ -0,0 +1,131 @@
#!/bin/bash
# This script downloads the latest release version of EAS CLI from GitHub and
# installs it in /usr/local/bin
{
# All code is inside a block to ensure the script executes only when
# downloaded completely.
set -euo pipefail
prefix="${EAS_PREFIX:-/usr/local}"
temp_dir="$(mktemp -d)"
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]
then
# Use colors when stdout is a terminal and NO_COLOR isn't set.
text_bold=$'\033[1m'
text_normal=$'\033[22m'
text_red=$'\033[31m'
text_default_color=$'\033[39m'
else
# Disable colors
text_bold=""
text_normal=""
text_red=""
text_default_color=""
fi
abort() {
echo -e "$text_bold$text_red$1$text_normal$text_default_color"
exit 1
}
install() {
needs_sudo=""
# Make sure path exists and can be written to by the current user.
if ! mkdir -p "$prefix/lib" || [[ ! -w "$prefix/lib" ]]
then
needs_sudo=true
fi
if ! mkdir -p "$prefix/bin" || [[ ! -w "$prefix/bin" ]]
then
needs_sudo=true
fi
if ! rm -rf "$prefix/lib/eas"
then
needs_sudo=true
fi
# Run commands with sudo if necessary.
if [ -n "$needs_sudo" ]
then
if [ "$EUID" -ne 0 ]
then
echo "Installing requires superuser access."
echo "The sudo command will prompt for your password."
fi
sudo rm -rf "$prefix/lib/eas"
sudo mv "$temp_dir/eas" "$prefix/lib/eas"
sudo ln -fs "$prefix/lib/eas/bin/eas" "$prefix/bin/eas"
else
mv "$temp_dir/eas" "$prefix/lib/eas"
ln -fs "$prefix/lib/eas/bin/eas" "$prefix/bin/eas"
fi
}
if [[ ! ":$PATH:" == *":$prefix/bin:"* ]]; then
abort "Your path is missing $prefix/bin, you need to add this to \$PATH use this installer."
fi
hardware="$(uname -m)"
case "$hardware" in
x86_64)
arch="x64"
;;
arm*)
arch="arm"
;;
*)
abort "Unsupported CPU architecture $hardware."
;;
esac
case "$(uname -s)" in
Darwin*)
platform="darwin"
if [[ "$arch" != "x64" ]]; then
abort "Unsupported CPU architecture $hardware on macOS."
fi
;;
Linux*)
platform="linux"
;;
*)
abort "This installer is supported only on macOS, Linux, and Windows Subsystem for Linux."
;;
esac
url="https://github.com/expo/eas-cli/releases/latest/download/eas-$platform-$arch.tar.gz"
echo "Installing EAS CLI from $url"
mkdir -p "$temp_dir"
cd "$temp_dir"
echo
echo "Downloading..."
if [ "$(command -v curl)" ]
then
curl --location --progress-bar "$url" | tar xz
else
wget -O- "$url" | tar xz
fi
# Move the installation over and link the executable (may require sudo)
install
# Test the bundled node binary and if it doesn't work remove it (fall back to
# node installed in path).
"$prefix/lib/eas/bin/node" -v > /dev/null || {
rm "$prefix/lib/eas/bin/node"
}
# Test the CLI
executable=$(command -v eas) || abort "Could not run eas after installation."
echo
echo -e "EAS CLI is installed to ${executable}"
eas --version
echo
echo -e "Type ${text_bold}eas --help${text_normal} to get started."
}
+18
View File
@@ -0,0 +1,18 @@
comment: false
coverage:
round: up
precision: 2
range: 30..100
status:
patch: false
project:
default:
informational: true
eas-cli:
informational: true
paths:
- packages/eas-cli
eas-json:
informational: true
paths:
- packages/eas-json
+1 -1
View File
@@ -1,7 +1,7 @@
module.exports = {
projects: [
require('./packages/eas-cli/jest.config.js'),
require('./packages/config/jest.config.js'),
require('./packages/eas-json/jest.config.js'),
],
testPathIgnorePatterns: ['.*'],
};
+1 -6
View File
@@ -1,11 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
// Only use this when running tests
env: {
test: {
presets: ['@expo/babel-preset-cli'],
},
},
presets: ['@expo/babel-preset-cli'],
};
};
+1
View File
@@ -5,4 +5,5 @@ module.exports = {
'^.+\\.[jt]sx?$': ['babel-jest', { configFile: require.resolve('./babel.config.js') }],
},
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
};
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.0.0"
"version": "0.10.0"
}
+8
View File
@@ -4,14 +4,22 @@
"packages/*"
],
"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",
"test": "jest"
},
"devDependencies": {
"@expo/oclif-dev-cli": "1.24.0-expo",
"@types/jest": "^26.0.13",
"eslint": "^6.6.0",
"eslint-config-universe": "^4.0.0",
"eslint-plugin-graphql": "^4.0.0",
"jest": "^26.4.2",
"jest-watch-typeahead": "^0.6.1",
"lerna": "^3.22.1",
-53
View File
@@ -1,53 +0,0 @@
// Workflow is representing different value than BuildType from @expo/build-tools
// Each workflow has a set of BuildTypes available
// - Generic workflow allows to build 'generic' and 'generic-client'
// - Managed workflow allows to build 'managed' and 'managed-client'
export enum Workflow {
Generic = 'generic',
Managed = 'managed',
}
export enum CredentialsSource {
LOCAL = 'local',
REMOTE = 'remote',
AUTO = 'auto',
}
export interface AndroidManagedBuildProfile {
workflow: Workflow.Managed;
credentialsSource: CredentialsSource;
buildType?: 'apk' | 'app-bundle';
}
export interface AndroidGenericBuildProfile {
workflow: Workflow.Generic;
credentialsSource: CredentialsSource;
gradleCommand?: string;
artifactPath?: string;
withoutCredentials?: boolean;
}
export interface iOSManagedBuildProfile {
workflow: Workflow.Managed;
credentialsSource: CredentialsSource;
buildType?: 'archive' | 'simulator';
}
export interface iOSGenericBuildProfile {
workflow: Workflow.Generic;
credentialsSource: CredentialsSource;
scheme?: string;
artifactPath?: string;
}
export type AndroidBuildProfile = AndroidManagedBuildProfile | AndroidGenericBuildProfile;
export type iOSBuildProfile = iOSManagedBuildProfile | iOSGenericBuildProfile;
export type BuildProfile = AndroidBuildProfile | iOSBuildProfile;
// EasConfig represents eas.json with one specific profile
export interface EasConfig {
builds: {
android?: AndroidManagedBuildProfile | AndroidGenericBuildProfile;
ios?: iOSManagedBuildProfile | iOSGenericBuildProfile;
};
}
-94
View File
@@ -1,94 +0,0 @@
import { Platform } from '@expo/build-tools';
import fs from 'fs-extra';
import path from 'path';
import {
AndroidBuildProfile,
BuildProfile,
EasConfig,
Workflow,
iOSBuildProfile,
} from './Config.types';
import { EasJsonSchema, schemaBuildProfileMap } from './EasJsonSchema';
interface EasJson {
builds: {
android?: { [key: string]: AndroidBuildProfile };
ios?: { [key: string]: iOSBuildProfile };
};
}
interface BuildProfilePreValidation {
workflow: Workflow;
}
export class EasJsonReader {
constructor(private projectDir: string, private platform: 'android' | 'ios' | 'all') {}
public async readAsync(buildProfileName: string): Promise<EasConfig> {
const easJson = await this.readFile();
let androidConfig;
if (['android', 'all'].includes(this.platform)) {
androidConfig = this.validateBuildProfile<AndroidBuildProfile>(
Platform.Android,
buildProfileName,
easJson.builds?.android?.[buildProfileName]
);
}
let iosConfig;
if (['ios', 'all'].includes(this.platform)) {
iosConfig = this.validateBuildProfile<iOSBuildProfile>(
Platform.iOS,
buildProfileName,
easJson.builds?.ios?.[buildProfileName]
);
}
return {
builds: {
...(androidConfig ? { android: androidConfig } : {}),
...(iosConfig ? { ios: iosConfig } : {}),
},
};
}
private validateBuildProfile<T extends BuildProfile>(
platform: 'android' | 'ios' | 'all',
buildProfileName: string,
buildProfile?: BuildProfilePreValidation
): T {
if (!buildProfile) {
throw new Error(`There is no profile named ${buildProfileName} for platform ${platform}`);
}
const schema = schemaBuildProfileMap[platform][buildProfile?.workflow];
if (!schema) {
throw new Error('invalid workflow'); // this should be validated earlier
}
const { value, error } = schema.validate(buildProfile, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw new Error(
`Object "${platform}.${buildProfileName}" in eas.json is not valid [${error.toString()}]`
);
}
return value;
}
private async readFile(): Promise<EasJson> {
const rawFile = await fs.readFile(path.join(this.projectDir, 'eas.json'), 'utf-8');
const json = JSON.parse(rawFile);
const { value, error } = EasJsonSchema.validate(json, {
abortEarly: false,
});
if (error) {
throw new Error(`eas.json is not valid [${error.toString()}]`);
}
return value;
}
}
-58
View File
@@ -1,58 +0,0 @@
import Joi from '@hapi/joi';
const AndroidGenericSchema = Joi.object({
workflow: Joi.string().valid('generic').required(),
credentialsSource: Joi.string().valid('local', 'remote', 'auto').default('auto'),
gradleCommand: Joi.string(),
artifactPath: Joi.string(),
withoutCredentials: Joi.boolean(),
});
const AndroidManagedSchema = Joi.object({
workflow: Joi.string().valid('managed').required(),
credentialsSource: Joi.string().valid('local', 'remote', 'auto').default('auto'),
buildType: Joi.string().valid('apk', 'app-bundle').default('app-bundle'),
});
const iOSGenericSchema = Joi.object({
workflow: Joi.string().valid('generic').required(),
credentialsSource: Joi.string().valid('local', 'remote', 'auto').default('auto'),
scheme: Joi.string(),
artifactPath: Joi.string(),
});
const iOSManagedSchema = Joi.object({
workflow: Joi.string().valid('managed').required(),
credentialsSource: Joi.string().valid('local', 'remote', 'auto').default('auto'),
buildType: Joi.string().valid('archive', 'simulator'),
});
const schemaBuildProfileMap: Record<string, Record<string, Joi.Schema>> = {
android: {
generic: AndroidGenericSchema,
managed: AndroidManagedSchema,
},
ios: {
managed: iOSManagedSchema,
generic: iOSGenericSchema,
},
};
const EasJsonSchema = Joi.object({
builds: Joi.object({
android: Joi.object().pattern(
Joi.string(),
Joi.object({
workflow: Joi.string().valid('generic', 'managed').required(),
}).unknown(true) // profile is validated further only if build is for that platform
),
ios: Joi.object().pattern(
Joi.string(),
Joi.object({
workflow: Joi.string().valid('generic', 'managed').required(),
}).unknown(true) // profile is validated further only if build is for that platform
),
}),
});
export { EasJsonSchema, schemaBuildProfileMap };
@@ -1,191 +0,0 @@
import fs from 'fs-extra';
import { vol } from 'memfs';
import { EasJsonReader } from '../EasJsonReader';
jest.mock('fs');
beforeEach(async () => {
vol.reset();
await fs.mkdirp('/project');
});
test('minimal valid android eas.json', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
android: {
release: { workflow: 'generic' },
},
},
});
const reader = new EasJsonReader('/project', 'android');
const easJson = await reader.readAsync('release');
expect({
builds: {
android: {
workflow: 'generic',
credentialsSource: 'auto',
},
},
}).toEqual(easJson);
});
test('minimal valid ios eas.json', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
ios: {
release: { workflow: 'generic' },
},
},
});
const reader = new EasJsonReader('/project', 'ios');
const easJson = await reader.readAsync('release');
expect({
builds: {
ios: {
credentialsSource: 'auto',
workflow: 'generic',
},
},
}).toEqual(easJson);
});
test('minimal valid eas.json for both platforms', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
android: {
release: { workflow: 'generic' },
},
ios: {
release: { workflow: 'generic' },
},
},
});
const reader = new EasJsonReader('/project', 'all');
const easJson = await reader.readAsync('release');
expect({
builds: {
android: { workflow: 'generic', credentialsSource: 'auto' },
ios: { workflow: 'generic', credentialsSource: 'auto' },
},
}).toEqual(easJson);
});
test('valid eas.json with both platform, but reading only android', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
ios: {
release: { workflow: 'generic' },
},
android: {
release: { workflow: 'generic' },
},
},
});
const reader = new EasJsonReader('/project', 'android');
const easJson = await reader.readAsync('release');
expect({
builds: {
android: { workflow: 'generic', credentialsSource: 'auto' },
},
}).toEqual(easJson);
});
test('valid eas.json for debug builds', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
ios: {
release: { workflow: 'managed' },
debug: { workflow: 'managed', buildType: 'simulator' },
},
android: {
release: { workflow: 'generic' },
debug: {
workflow: 'generic',
gradleCommand: ':app:assembleDebug',
withoutCredentials: true,
},
},
},
});
const reader = new EasJsonReader('/project', 'all');
const easJson = await reader.readAsync('debug');
expect({
builds: {
android: {
credentialsSource: 'auto',
workflow: 'generic',
gradleCommand: ':app:assembleDebug',
withoutCredentials: true,
},
ios: {
credentialsSource: 'auto',
workflow: 'managed',
buildType: 'simulator',
},
},
}).toEqual(easJson);
});
test('invalid eas.json with missing preset', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
android: {
release: { workflow: 'generic' },
},
},
});
const reader = new EasJsonReader('/project', 'android');
const promise = reader.readAsync('debug');
await expect(promise).rejects.toThrowError(
'There is no profile named debug for platform android'
);
});
test('invalid eas.json when using buildType for wrong platform', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
android: {
release: { workflow: 'managed', buildType: 'archive' },
},
},
});
const reader = new EasJsonReader('/project', 'android');
const promise = reader.readAsync('release');
await expect(promise).rejects.toThrowError(
'Object "android.release" in eas.json is not valid [ValidationError: "buildType" must be one of [apk, app-bundle]]'
);
});
test('invalid eas.json when missing workflow', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
android: {
release: { buildType: 'apk' },
},
},
});
const reader = new EasJsonReader('/project', 'android');
const promise = reader.readAsync('release');
await expect(promise).rejects.toThrowError(
'eas.json is not valid [ValidationError: "builds.android.release.workflow" is required]'
);
});
test('empty json', async () => {
await fs.writeJson('/project/eas.json', {});
const reader = new EasJsonReader('/project', 'android');
const promise = reader.readAsync('release');
await expect(promise).rejects.toThrowError(
'There is no profile named release for platform android'
);
});
+37
View File
@@ -0,0 +1,37 @@
module.exports = {
rules: {
'import/no-cycle': 'error',
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: ['**/__tests__/**/*', '**/__mocks__/**/*'] },
],
'graphql/template-strings': [
'error',
{
env: 'apollo',
schemaJson: require('./graphql.schema.json'),
},
],
'graphql/named-operations': [
'error',
{
schemaJson: require('./graphql.schema.json'),
},
],
'graphql/required-fields': [
'error',
{
env: 'apollo',
schemaJson: require('./graphql.schema.json'),
requiredFields: ['id'],
},
],
'graphql/capitalized-type-name': [
'error',
{
schemaJson: require('./graphql.schema.json'),
},
],
},
plugins: ['graphql'],
};
+363 -58
View File
@@ -7,69 +7,228 @@ 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 -->
* [Installation](#installation)
* [Usage](#usage)
* [Commands](#commands)
- [eas-cli](#eas-cli)
- [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.0.0 darwin-x64 node-v12.13.0
$ 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
<!-- commands -->
* [`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)
* [`eas build:list`](#eas-buildlist)
* [`eas build:view [BUILD_ID]`](#eas-buildview-build_id)
* [`eas credentials`](#eas-credentials)
* [`eas device:create`](#eas-devicecreate)
* [`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 secrets:list`](#eas-secretslist)
* [`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 build`](#eas-build)
- [`eas build:status`](#eas-buildstatus)
- [`eas help [COMMAND]`](#eas-help-command)
- [`eas login`](#eas-login)
- [`eas logout`](#eas-logout)
- [`eas update`](#eas-update)
- [`eas update:show`](#eas-updateshow)
- [`eas whoami`](#eas-whoami)
## `eas account:login`
log in with your Expo account
```
USAGE
$ eas account:login
ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/account/login.ts)_
## `eas account:logout`
log out
```
USAGE
$ eas account:logout
ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/account/logout.ts)_
## `eas account:view`
show the username you are logged in as
```
USAGE
$ eas account:view
ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.10.0/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.10.0/build/commands/analytics.ts)_
## `eas build`
build an app binary for your project
Start a build
```
USAGE
$ eas build
OPTIONS
-p, --platform=(android|ios|all)
--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
--skip-project-configuration Skip project configuration
--[no-]wait Wait for build(s) to complete
```
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/build/index.ts)_
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/build/index.ts)_
## `eas build:status`
## `eas build:cancel [BUILD_ID]`
get the status of the latest builds for your project
Cancel a build.
```
USAGE
$ eas build:status
OPTIONS
--platform=(all|android|ios)
--status=(in-queue|in-progress|errored|finished)
$ eas build:cancel [BUILD_ID]
```
_See code: [src/commands/build/status.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/build/status.ts)_
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/build/cancel.ts)_
## `eas build:configure`
Configure the project to support EAS Build.
```
USAGE
$ eas build:configure
OPTIONS
-p, --platform=(android|ios|all) Platform to configure
--allow-experimental Enable experimental configuration steps.
```
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/build/configure.ts)_
## `eas build:list`
list all builds for your project
```
USAGE
$ eas build:list
OPTIONS
--limit=limit
--platform=(all|android|ios)
--status=(in-queue|in-progress|errored|finished|canceled)
```
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/build/list.ts)_
## `eas build:view [BUILD_ID]`
view a build for your project
```
USAGE
$ eas build:view [BUILD_ID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/build/view.ts)_
## `eas credentials`
Manage your credentials
```
USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/credentials.ts)_
## `eas device:create`
register new Apple Devices to use for internal distribution
```
USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/device/create.ts)_
## `eas device:list`
list all registered devices for your account
```
USAGE
$ eas device:list
OPTIONS
--apple-team-id=apple-team-id
```
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/device/list.ts)_
## `eas device:view [UDID]`
view a device for your project
```
USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -88,62 +247,208 @@ OPTIONS
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
## `eas login`
## `eas secret:create`
log in with your EAS account
Create an environment secret on the current project or owner account.
```
USAGE
$ eas login
$ 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: [src/commands/login.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/login.ts)_
_See code: [build/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/secret/create.ts)_
## `eas logout`
## `eas secret:delete`
log out
Delete an environment secret by ID.
```
USAGE
$ eas logout
$ 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: [src/commands/logout.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/logout.ts)_
_See code: [build/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/secret/delete.ts)_
## `eas update`
## `eas secrets:list`
create a revision for given channel
Lists environment secrets available for your current app
```
USAGE
$ eas update
$ eas secrets:list
```
_See code: [build/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/secret/list.ts)_
## `eas submit --platform=(android|ios)`
Submits build artifact to app store
```
USAGE
$ eas submit --platform=(android|ios)
OPTIONS
-p, --platform=(android|ios) For which platform you want to submit a build
Android specific options=android-package Android package name (default: expo.android.package
from app config)
iOS specific options=app-name The name of your app as it will appear on the App
Store (default: expo.name from app config)
iOS specific options=apple-id Your Apple ID username (you can also set
EXPO_APPLE_ID env variable)
iOS specific options=apple-team-id Your Apple Developer Team ID
iOS specific options=asc-app-id App Store Connect unique application Apple ID
number. Providing this param results in skipping
app creation step. Learn more:
https://expo.fyi/asc-app-id
iOS specific options=bundle-identifier Your iOS Bundle Identifier (default:
expo.ios.bundleIdentifier from app config)
iOS specific options=company-name The name of your company, needed only for the first
upload of any app to App Store
--id=id ID of the build to submit
Android specific options=key Path to the JSON key used to authenticate with
Google Play
iOS specific options=language [default: en-US] Primary language (e.g. English,
German, ...)
--latest Submit the latest build for specified platform
--path=path Path to the .apk/.aab file
Android specific options=(completed|draft|halted|inProgress) [default: completed] Release status (used when
uploading new APKs/AABs)
iOS specific options=sku An unique ID for your app that is not visible on
the App Store, will be generated unless provided
Android specific options=(production|beta|alpha|internal|rollout) [default: internal] The track of the application to
use
Android specific options=(apk|aab) Android archive type
--url=url App archive url
--verbose Always print logs from Submission Service
ALIASES
$ eas update:publish
$ eas build:submit
EXAMPLES
$ eas submit --platform=ios
- Fully interactive iOS submission
$ eas submit --platform=android
- Fully interactive Android submission
$ eas submit -p android --latest --key=/path/to/google-services.json
- Minimal non-interactive Android submission, however it can ask you for other params if not specified
$ EXPO_APPLE_APP_SPECIFIC_PASSWORD=xxx eas submit -p ios --latest [email protected] --asc-app-id=1234567890,
- Minimal non-interactive iOS submission, assuming you already have an app in App Store Connect
and provide its App ID
```
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/update/index.ts)_
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/submit.ts)_
## `eas update:show`
## `eas webhook:create`
details about a particular revision
Create a webhook on the current project.
```
USAGE
$ eas update:show
$ 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: [src/commands/update/show.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/update/show.ts)_
_See code: [build/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/webhook/create.ts)_
## `eas whoami`
## `eas webhook:delete [ID]`
show the username you are logged in as
Delete a webhook on the current project.
```
USAGE
$ eas whoami
$ eas webhook:delete [ID]
ARGUMENTS
ID ID of the webhook to delete
```
_See code: [src/commands/whoami.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/whoami.ts)_
_See code: [build/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/webhook/delete.ts)_
## `eas webhook:list`
List webhooks on the current project.
```
USAGE
$ eas webhook:list
OPTIONS
--event=(BUILD) Event type that triggers the webhook
```
_See code: [build/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/webhook/list.ts)_
## `eas webhook:update`
Create a webhook on the current project.
```
USAGE
$ eas webhook:update
OPTIONS
--event=(BUILD) [default: BUILD] Event type that triggers the webhook
--id=id (required) Webhook ID
--secret=secret Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature'
header.
--url=url Webhook URL
```
_See code: [build/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/webhook/update.ts)_
## `eas webhook:view ID`
View a webhook on the current project.
```
USAGE
$ eas webhook:view ID
ARGUMENTS
ID ID of the webhook to view
```
_See code: [build/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v0.10.0/build/commands/webhook/view.ts)_
<!-- commandsstop -->
@@ -1,3 +0,0 @@
const getConfig = jest.fn();
export { getConfig };
+7
View File
@@ -0,0 +1,7 @@
export default jest.fn().mockReturnValue({
start: jest.fn().mockReturnValue({
fail: jest.fn(),
succeed: jest.fn(),
}),
succeed: jest.fn(),
});
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
OUTFILE="src/graphql/generated.ts"
content="/**
* This file was generated using GraphQL Codegen
* Command: yarn generate-graphql-code
* Run this during development for automatic type generation when editing GraphQL documents
* For more info and docs, visit https://graphql-code-generator.com/
*/
"
printf '%s\n%s\n' "$content" "$(cat $OUTFILE)" > $OUTFILE
+3
View File
@@ -0,0 +1,3 @@
module.exports = async () => {
process.env.TZ = 'UTC';
};
+19
View File
@@ -0,0 +1,19 @@
overwrite: true
schema: 'https://staging.exp.host/--/graphql'
documents:
- 'src/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/commands/**/*.ts'
generates:
src/graphql/generated.ts:
plugins:
- 'typescript'
- 'typescript-operations'
config:
dedupeOperationSuffix: true
hooks:
afterOneFileWrite:
- ./annotate-graphql-codegen.sh
./graphql.schema.json:
plugins:
- 'introspection'
File diff suppressed because it is too large. Load diff
+1
View File
@@ -2,4 +2,5 @@ module.exports = {
...require('../../jest/jest.config.js'),
rootDir: __dirname,
roots: ['src', '__mocks__'],
globalSetup: './global-setup.ts',
};
+81 -20
View File
@@ -1,74 +1,106 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.0.0",
"version": "0.10.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@eas/config": "0.0.1",
"@expo/config": "^3.3.9",
"@amplitude/identify": "^1.5.0",
"@amplitude/node": "^1.5.0",
"@expo/apple-utils": "0.0.0-alpha.17",
"@expo/config": "~3.3.19",
"@expo/config-plugins": "1.0.24",
"@expo/eas-build-job": "0.2.22",
"@expo/eas-json": "^0.10.0",
"@expo/json-file": "^8.2.24",
"@expo/plist": "^0.0.10",
"@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",
"env-paths": "^2.2.0",
"fast-glob": "^3.2.4",
"figures": "^3.2.0",
"form-data": "^3.0.0",
"fs-extra": "^9.0.1",
"getenv": "^1.0.0",
"got": "^11.5.2",
"graphql": "^15.3.0",
"graphql-tag": "^2.11.0",
"indent-string": "^4.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",
"node-forge": "^0.10.0",
"nullthrows": "^1.1.1",
"ora": "^5.1.0",
"pkg-dir": "^4.2.0",
"progress": "^2.0.3",
"prompts": "^2.3.2",
"qrcode-terminal": "^0.12.0",
"semver": "^7.3.4",
"strip-ansi": "^6.0.0",
"tar": "^6.0.5",
"tempy": "^0.7.0",
"terminal-link": "^2.1.1",
"timeago.js": "^4.0.2",
"tslib": "^1",
"turndown": "^7.0.0",
"untildify": "^4.0.0",
"uuid": "^8.3.0",
"wordwrap": "^1.0.0"
"wrap-ansi": "^7.0.0"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-env": "^7.11.5",
"@babel/preset-typescript": "^7.10.4",
"@expo/babel-preset-cli": "^0.2.17",
"@expo/oclif-dev-cli": "1.23.0-expo",
"@graphql-codegen/cli": "^1.19.2",
"@graphql-codegen/introspection": "^1.18.0",
"@graphql-codegen/typescript": "^1.17.11",
"@graphql-codegen/typescript-operations": "^1.17.10",
"@types/color-string": "^1.5.0",
"@types/dateformat": "^3.0.1",
"@types/form-data": "^2.5.0",
"@types/fs-extra": "^9.0.1",
"@types/getenv": "^1.0.0",
"@types/lodash": "^4.14.161",
"@types/mime": "^2.0.3",
"@types/node": "^12",
"@types/node-fetch": "^2.5.7",
"@types/node-forge": "^0.9.5",
"@types/progress": "^2.0.3",
"@types/prompts": "^2.0.8",
"@types/prompts": "^2.0.9",
"@types/semver": "^7.3.4",
"@types/tar": "^4.0.3",
"@types/tough-cookie": "^4.0.0",
"@types/uuid": "^8.3.0",
"@types/wordwrap": "^1.0.0",
"@types/wrap-ansi": "^3.0.0",
"@vercel/ncc": "^0.23.0",
"axios": "^0.21.0",
"babel-jest": "^26.3.0",
"graphql": "^15.3.0",
"memfs": "^3.2.0",
"tempy": "^0.7.0",
"mockdate": "^3.0.2",
"nock": "^13.0.5",
"ts-node": "^8",
"typescript": "^3.9.7",
"wonka": "^4.0.14"
},
"optionalDependencies": {
"@expo/traveling-fastlane-darwin": "1.15.1",
"@expo/traveling-fastlane-linux": "1.15.1"
},
"engines": {
"node": ">=8.0.0"
},
@@ -89,26 +121,55 @@
"bin": "eas",
"commands": "./build/commands",
"plugins": [
"@oclif/plugin-help"
"@oclif/plugin-help",
"@expo/plugin-warn-if-update-available"
],
"helpClass": "./build/help",
"topics": {
"build": {
"description": "build app binaries"
},
"channel": {
"description": "manage update channels"
},
"release": {
"description": "manage update releases"
},
"update": {
"description": "roll out changes"
"description": "manage individual updates"
},
"webhook": {
"description": "manage webhooks"
}
},
"warn-if-update-available": {
"timeoutInDays": 0.5,
"message": "<%= chalk.bold('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available, please upgrade.') %>\n<%= chalk.dim('Proceeding with outdated version') %>\n"
},
"update": {
"node": {
"version": "12.13.0"
},
"s3": {
"templates": {
"target": {
"unversioned": "<%- bin %>-<%- platform %>-<%- arch %><%- ext %>",
"versioned": "<%- bin %>-<%- platform %>-<%- arch %><%- ext %>"
}
}
}
}
},
"repository": "expo/eas-cli",
"scripts": {
"postpack": "rm -f oclif.manifest.json",
"prepack": "rm -rf build && tsc -b && oclif-dev manifest && oclif-dev readme",
"prepack": "rm -rf build && yarn build && yarn oclif-dev manifest && yarn oclif-dev readme",
"build": "tsc --project tsconfig.build.json",
"watch": "yarn build --watch --preserveWatchOutput",
"typecheck": "tsc",
"test": "jest",
"version": "oclif-dev readme && git add README.md"
"version": "yarn oclif-dev readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml"
},
"types": "build/index.d.ts"
}
+27
View File
@@ -0,0 +1,27 @@
import { JSONValue } from '@expo/json-file';
import { HTTPError, RequestError } from 'got/dist/source';
export default class ApiV2Error extends RequestError {
readonly name = 'ApiV2Error';
readonly expoApiV2ErrorCode: string;
readonly expoApiV2ErrorDetails?: JSONValue;
readonly expoApiV2ErrorServerStack?: string;
readonly expoApiV2ErrorMetadata?: object;
constructor(
originalError: HTTPError,
response: {
message: string;
code: string;
stack?: string;
details?: JSONValue;
metadata?: object;
}
) {
super(response.message, originalError, originalError.request);
this.expoApiV2ErrorCode = response.code;
this.expoApiV2ErrorDetails = response.details;
this.expoApiV2ErrorServerStack = response.stack;
this.expoApiV2ErrorMetadata = response.metadata;
}
}
@@ -0,0 +1,53 @@
import assert from 'assert';
import { RequestError } from 'got/dist/source';
import nock from 'nock';
import ApiV2Error from '../ApiV2Error';
import { apiClient, getExpoApiBaseUrl } from '../api';
describe('apiClient', () => {
it('converts Expo APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl())
.post('/--/api/v2/test')
.reply(400, {
errors: [
{
message: 'hellomessage',
code: 'TEST_CODE',
stack: 'line 1: hello',
details: { who: 'world' },
metadata: { an: 'object' },
},
],
});
let error: Error | null = null;
try {
await apiClient.post('test');
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(ApiV2Error);
assert(error instanceof ApiV2Error);
expect(error.message).toEqual('hellomessage');
expect(error.expoApiV2ErrorCode).toEqual('TEST_CODE');
expect(error.expoApiV2ErrorDetails).toEqual({ who: 'world' });
expect(error.expoApiV2ErrorMetadata).toEqual({ an: 'object' });
expect(error.expoApiV2ErrorServerStack).toEqual('line 1: hello');
});
it('does not convert non-APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl()).post('/--/api/v2/test').reply(500, 'Something went wrong');
let error: Error | null = null;
try {
await apiClient.post('test');
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(RequestError);
expect(error).not.toBeInstanceOf(ApiV2Error);
});
});
@@ -0,0 +1,62 @@
import { Actor } from '../user/User';
interface MockProject {
projectRoot: string;
projectTree: Record<string, string>;
appJSON: AppJSON;
packageJSON: PackageJSON;
}
interface PackageJSON {
name: string;
version: string;
description: string;
main: string;
}
interface AppJSON {
expo: {
name: string;
version: string;
slug: string;
sdkVersion: string;
owner?: string;
[key: string]: any;
};
}
function createTestProject(
user: Actor,
appJSONExtraData?: Record<string, object | string>
): MockProject {
const projectRoot = '/test-project';
const packageJSON: PackageJSON = {
name: 'testing123',
version: '0.1.0',
description: 'fake description',
main: 'index.js',
};
const owner = appJSONExtraData?.owner as string | undefined;
const appJSON: AppJSON = {
expo: {
name: 'testing 123',
version: '0.1.0',
slug: 'testing-123',
sdkVersion: '33.0.0',
owner: owner ?? (user.__typename === 'User' ? user.username : undefined),
...appJSONExtraData,
},
};
return {
appJSON,
packageJSON,
projectRoot,
projectTree: {
[projectRoot + '/package.json']: JSON.stringify(packageJSON, null, 2),
[projectRoot + '/app.json']: JSON.stringify(appJSON, null, 2),
},
};
}
export { createTestProject };
+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,
};
}
+30 -38
View File
@@ -1,16 +1,7 @@
import { createClient as createUrqlClient } from '@urql/core';
import got, { NormalizedOptions } from 'got';
import fetch from 'node-fetch';
import got, { HTTPError, NormalizedOptions, RequestError } from 'got';
import { getAccessToken, getSessionSecret } from './user/User';
type AccessTokenHeaders = {
authorization: string;
};
type SessionHeaders = {
'expo-session': string;
};
import ApiV2Error from './ApiV2Error';
import { getAccessToken, getSessionSecret } from './user/sessionStorage';
export const apiClient = got.extend({
prefixUrl: getExpoApiBaseUrl() + '/--/api/v2/',
@@ -28,31 +19,22 @@ export const apiClient = got.extend({
}
},
],
},
});
export const graphqlClient = createUrqlClient({
url: getExpoApiBaseUrl() + '/--/graphql',
// @ts-expect-error Type 'typeof fetch' is not assignable to type '(input: RequestInfo, init?: RequestInit | undefined) => Promise<Response>'.
fetch,
fetchOptions: (): { headers?: AccessTokenHeaders | SessionHeaders } => {
const token = getAccessToken();
if (token) {
return {
headers: {
authorization: `Bearer ${token}`,
},
};
}
const sessionSecret = getSessionSecret();
if (sessionSecret) {
return {
headers: {
'expo-session': sessionSecret,
},
};
}
return {};
beforeError: [
(error: RequestError): RequestError => {
if (error instanceof HTTPError) {
let result: { [key: string]: any };
try {
result = JSON.parse(error.response.body as string);
} catch (e2) {
return error;
}
if (result.errors?.length) {
return new ApiV2Error(error, result.errors[0]);
}
}
return error;
},
],
},
});
@@ -60,8 +42,18 @@ export function getExpoApiBaseUrl(): string {
if (process.env.EXPO_STAGING) {
return `https://staging.exp.host`;
} else if (process.env.EXPO_LOCAL) {
return `http://exp.test`;
return `http://127.0.0.1:3000`;
} else {
return `https://exp.host`;
}
}
export function getExpoWebsiteBaseUrl(): string {
if (process.env.EXPO_STAGING) {
return `https://staging.expo.io`;
} else if (process.env.EXPO_LOCAL) {
return `http://expo.test`;
} else {
return `https://expo.io`;
}
}
@@ -0,0 +1,294 @@
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');
});
});
});
@@ -0,0 +1,110 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import fs from 'fs-extra';
import Log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { ensureValidVersions } from '../utils/updates';
export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
if (!AndroidConfig.Updates.isBuildGradleConfigured(projectDir, buildGradleContents)) {
const updatedBuildGradleContents = AndroidConfig.Updates.ensureBuildGradleContainsConfigurationScript(
projectDir,
buildGradleContents
);
await fs.writeFile(buildGradlePath, updatedBuildGradleContents);
}
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
);
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
const result = AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, result);
}
}
export async function syncUpdatesConfigurationAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
try {
await ensureUpdatesConfiguredAsync(projectDir, exp);
} catch (error) {
Log.error(
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
);
throw error;
}
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
let androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(androidManifestPath);
if (!AndroidConfig.Updates.areVersionsSynced(exp, androidManifest)) {
androidManifest = AndroidConfig.Updates.setVersionsConfig(exp, androidManifest);
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, androidManifest);
}
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
Log.warn(
'Native project configuration is not synced with values present in your app.json, run "eas build:configure" to make sure all values are applied in the native project'
);
}
}
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
if (!AndroidConfig.Updates.isBuildGradleConfigured(projectDir, buildGradleContents)) {
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir);
throw new Error(`Missing ${gradleScriptApply} in ${buildGradlePath}`);
}
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
);
if (!AndroidConfig.Updates.isMainApplicationMetaDataSet(androidManifest)) {
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;
}
}
@@ -0,0 +1,191 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import assert from 'assert';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import Log from '../../log';
import {
ensureAppIdentifierIsDefinedAsync,
getAndroidApplicationIdAsync,
getProjectConfigDescription,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { gitAddAsync } from '../../utils/git';
import { Platform } from '../types';
enum ApplicationIdSource {
AndroidProject,
AppJson,
}
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, exp: ExpoConfig) {
const applicationId = await ensureAppIdentifierIsDefinedAsync({
projectDir,
platform: Platform.ANDROID,
exp,
});
if (!isApplicationIdValid(applicationId)) {
const configDescription = getProjectConfigDescription(projectDir);
Log.error(
`Invalid format of Android applicationId. Only alphanumeric characters, '.' and '_' are allowed, and each '.' must be followed by a letter.`
);
Log.error(`Update "android.package" in ${configDescription} and run this command again.`);
throw new Error('Invalid applicationId');
}
}
export async function configureApplicationIdAsync(
projectDir: string,
exp: ExpoConfig,
allowExperimental: boolean
): Promise<void> {
const configDescription = getProjectConfigDescription(projectDir);
const applicationIdFromConfig = AndroidConfig.Package.getPackage(exp);
const applicationIdFromAndroidProject = await getAndroidApplicationIdAsync(projectDir);
if (applicationIdFromAndroidProject && applicationIdFromConfig) {
if (applicationIdFromConfig !== applicationIdFromAndroidProject) {
Log.newLine();
Log.warn(
`We detected that your Android project is configured with a different application id than the one defined in ${configDescription}.`
);
const hasApplicationIdInStaticConfig = await hasApplicationIdInStaticConfigAsync(
projectDir,
exp
);
if (!hasApplicationIdInStaticConfig) {
Log.log(`If you choose the one defined in ${configDescription} we'll automatically configure your Android project with it.
However, if you choose the one defined in the Android project you'll have to update ${configDescription} on your own.`);
}
Log.newLine();
const { applicationIdSource } = await promptAsync({
type: 'select',
name: 'applicationIdSource',
message: 'Which application id should we use?',
choices: [
{
title: `${chalk.bold(
applicationIdFromAndroidProject
)} - In build.gradle in Android project`,
value: ApplicationIdSource.AndroidProject,
},
{
title: `${chalk.bold(applicationIdFromConfig)} - In your ${configDescription}`,
value: ApplicationIdSource.AppJson,
},
],
});
switch (applicationIdSource) {
case ApplicationIdSource.AppJson: {
await updateApplicationIdInBuildGradleAsync(projectDir, exp);
break;
}
case ApplicationIdSource.AndroidProject: {
if (hasApplicationIdInStaticConfig) {
await updateAppJsonConfigAsync(projectDir, exp, applicationIdFromAndroidProject);
} else {
throw new Error(missingPackageMessage(configDescription));
}
break;
}
}
}
} else if (!applicationIdFromAndroidProject && !applicationIdFromConfig) {
throw new Error(missingPackageMessage(configDescription));
} else if (applicationIdFromAndroidProject && !applicationIdFromConfig) {
if (getConfigFilePaths(projectDir).staticConfigPath) {
await updateAppJsonConfigAsync(projectDir, exp, applicationIdFromAndroidProject);
} else {
throw new Error(missingPackageMessage(configDescription));
}
} else if (!applicationIdFromAndroidProject && applicationIdFromConfig) {
// This should never happen, adding warning just in case
Log.warn(
'applicationId is not specified in your ./android/app/build.gradle file. Make sure your project is configured correctly before building.'
);
return;
}
if (allowExperimental) {
// this step is optional, the Play store is using applicationId value from build.gradle
// to identify the app, so package name does not need to be updated
await updatePackageNameAsync(projectDir, exp);
await gitAddAsync(path.join(projectDir, 'android'), { intentToAdd: true });
}
}
function missingPackageMessage(configDescription: string): string {
return `Please define "android.package" in ${configDescription} and run "eas build:configure" again.`;
}
async function updateAppJsonConfigAsync(
projectDir: string,
exp: ExpoConfig,
newApplicationId: string
): Promise<void> {
const paths = getConfigFilePaths(projectDir);
assert(paths.staticConfigPath, "can't update dynamic configs");
const rawStaticConfig = await fs.readJSON(paths.staticConfigPath);
rawStaticConfig.expo = {
...rawStaticConfig.expo,
android: { ...rawStaticConfig.expo?.android, package: newApplicationId },
};
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
exp.android = { ...exp.android, package: newApplicationId };
}
/**
* Check if static config exists and if expo.android.package is defined there.
* It will return false if the value in static config is different than "android.package" in ExpoConfig
*/
async function hasApplicationIdInStaticConfigAsync(
projectDir: string,
exp: ExpoConfig
): Promise<boolean> {
if (!exp.android?.package) {
return false;
}
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath) {
return false;
}
const rawStaticConfig = await fs.readJson(paths.staticConfigPath);
return rawStaticConfig?.expo?.android?.package === exp.android.package;
}
async function updateApplicationIdInBuildGradleAsync(projectDir: string, exp: ExpoConfig) {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
const updatedBuildGradleContents = AndroidConfig.Package.setPackageInBuildGradle(
exp,
buildGradleContents
);
await fs.writeFile(buildGradlePath, updatedBuildGradleContents);
}
async function updatePackageNameAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
await AndroidConfig.Package.renamePackageOnDisk(exp, projectDir);
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
);
const updatedAndroidManifest = AndroidConfig.Package.setPackageInAndroidManifest(
exp,
androidManifest
);
await AndroidConfig.Manifest.writeAndroidManifestAsync(
androidManifestPath,
updatedAndroidManifest
);
}
+138
View File
@@ -0,0 +1,138 @@
import { Android, Metadata, Workflow } from '@expo/eas-build-job';
import { EasConfig } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
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 { 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 | undefined>> {
const buildCtx = createBuildContext<Platform.ANDROID>({
commandCtx,
platform: Platform.ANDROID,
easConfig,
});
const { buildProfile } = buildCtx;
if (
buildProfile.workflow === Workflow.GENERIC &&
!(await fs.pathExists(path.join(commandCtx.projectDir, 'android')))
) {
throw new Error(
`"android" directory not found. If you're trying to build a managed project, set ${chalk.bold(
`builds.android.${commandCtx.profile}.workflow`
)} in "eas.json" to "managed".`
);
}
if (
buildProfile.workflow === Workflow.GENERIC &&
buildProfile.distribution === 'internal' &&
buildProfile.gradleCommand?.match(/bundle/)
) {
Log.addNewLineIfNone();
Log.warn(
`You're building your Android app for internal distribution. However, we've detected that the Gradle command you defined (${chalk.underline(
buildProfile.gradleCommand
)}) includes string 'bundle'.
This means that it will most likely produce an AAB and you will not be able to install it on your Android devices straight from the Expo website.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Would you like to proceed?' });
if (!confirmed) {
Log.error('Please update eas.json and come back again.');
process.exit(1);
}
}
await ensureApplicationIdIsValidAsync(commandCtx.projectDir, commandCtx.exp);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {},
ensureCredentialsAsync: ensureAndroidCredentialsAsync,
ensureProjectConfiguredAsync: async () => {
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 {
return (
ctx.buildProfile.workflow === Workflow.MANAGED ||
(ctx.buildProfile.workflow === Workflow.GENERIC && !ctx.buildProfile.withoutCredentials)
);
}
async function ensureAndroidCredentialsAsync(
ctx: BuildContext<Platform.ANDROID>
): Promise<CredentialsResult<AndroidCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
}
const provider = new AndroidCredentialsProvider(
await createCredentialsContextAsync(ctx.commandCtx.projectDir, {
nonInteractive: ctx.commandCtx.nonInteractive,
}),
{
app: {
projectName: ctx.commandCtx.projectName,
accountName: ctx.commandCtx.accountName,
},
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
}
);
const credentialsSource = await ensureCredentialsAsync(
provider,
ctx.buildProfile.workflow,
ctx.buildProfile.credentialsSource,
ctx.commandCtx.nonInteractive
);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
source: credentialsSource,
};
}
@@ -0,0 +1,50 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import Log from '../../log';
import { getAndroidApplicationIdAsync } from '../../project/projectUtils';
import { gitAddAsync } from '../../utils/git';
import { ConfigureContext } from '../context';
import { isExpoUpdatesInstalled } from '../utils/updates';
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
import { configureApplicationIdAsync, ensureApplicationIdIsValidAsync } from './applicationId';
export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasAndroidNativeProject) {
return;
}
await AndroidConfig.EasBuild.configureEasBuildAsync(ctx.projectDir);
await configureApplicationIdAsync(ctx.projectDir, ctx.exp, ctx.allowExperimental);
await ensureApplicationIdIsValidAsync(ctx.projectDir, ctx.exp);
const easGradlePath = AndroidConfig.EasBuild.getEasBuildGradlePath(ctx.projectDir);
await gitAddAsync(easGradlePath, { intentToAdd: true });
if (isExpoUpdatesInstalled(ctx.projectDir)) {
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
}
Log.withTick('Android project configured');
}
export async function validateAndSyncProjectConfigurationAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
const applicationIdFromAndroidProject = await getAndroidApplicationIdAsync(projectDir);
const packageNameFromConfig = AndroidConfig.Package.getPackage(exp);
const isApplicationIdConfigured =
applicationIdFromAndroidProject && applicationIdFromAndroidProject === packageNameFromConfig;
const isEasBuildGradleConfigured = await AndroidConfig.EasBuild.isEasBuildGradleConfiguredAsync(
projectDir
);
if (!isEasBuildGradleConfigured || !isApplicationIdConfigured) {
throw new Error(
'Project is not configured. Please run "eas build:configure" to configure the project.'
);
}
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
}
}
@@ -0,0 +1,29 @@
import { Android } from '@expo/eas-build-job';
import { AndroidGenericJobInput, 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,
};
}
@@ -0,0 +1,115 @@
import { Android, ArchiveSource, Cache, Job, Workflow, sanitizeJob } from '@expo/eas-build-job';
import { AndroidGenericBuildProfile, AndroidManagedBuildProfile } from '@expo/eas-json';
import path from 'path';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { readEnvironmentSecretsAsync } from '../../credentials/credentialsJson/read';
import { getProjectAccountNameAsync } from '../../project/projectUtils';
import { gitRootDirectoryAsync } from '../../utils/git';
import { BuildContext } from '../context';
import { Platform } from '../types';
interface JobData {
projectArchive: ArchiveSource;
credentials?: AndroidCredentials;
}
export async function prepareJobAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Job> {
if (ctx.buildProfile.workflow === Workflow.GENERIC) {
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else if (ctx.buildProfile.workflow === Workflow.MANAGED) {
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else {
throw new Error("Unknown workflow. Shouldn't happen");
}
}
interface CommonJobProperties {
platform: Platform.ANDROID;
projectArchive: ArchiveSource;
builderEnvironment: Android.BuilderEnvironment;
cache: Cache;
secrets: {
buildCredentials?: {
keystore: Android.Keystore;
};
environmentSecrets?: Record<string, string>;
};
}
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Partial<CommonJobProperties>> {
const environmentSecrets = await readEnvironmentSecretsAsync(ctx.commandCtx.projectDir);
const credentials = jobData.credentials;
const buildCredentials = credentials
? {
buildCredentials: {
keystore: {
dataBase64: credentials.keystore.keystore,
keystorePassword: credentials.keystore.keystorePassword,
keyAlias: credentials.keystore.keyAlias,
keyPassword: credentials.keystore.keyPassword,
},
},
}
: {};
return {
platform: Platform.ANDROID,
projectArchive: jobData.projectArchive,
builderEnvironment: {
image: ctx.buildProfile.image,
node: ctx.buildProfile.node,
yarn: ctx.buildProfile.yarn,
ndk: ctx.buildProfile.ndk,
env: ctx.buildProfile.env,
},
cache: ctx.buildProfile.cache,
secrets: {
...(environmentSecrets ? { environmentSecrets } : {}),
...buildCredentials,
},
};
}
async function prepareGenericJobAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData,
buildProfile: AndroidGenericBuildProfile
): Promise<Partial<Android.GenericJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.GENERIC,
gradleCommand: buildProfile.gradleCommand,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
};
}
async function prepareManagedJobAsync(
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.exp);
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.MANAGED,
username: accountName,
buildType: buildProfile.buildType,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
};
}
+265
View File
@@ -0,0 +1,265 @@
import { ArchiveSource, ArchiveSourceType, Job, Metadata } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import fs from 'fs-extra';
import { UploadSessionType } from '../graphql/generated';
import { BuildResult } from '../graphql/mutations/BuildMutation';
import Log from '../log';
import { promptAsync } from '../prompts';
import { uploadAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import { requestedPlatformDisplayNames } from './constants';
import { BuildContext } from './context';
import { runLocalBuildAsync } from './local';
import { collectMetadata } from './metadata';
import { Platform, TrackingContext } from './types';
import Analytics, { Event } from './utils/analytics';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
commitChangedFilesAsync,
commitPromptAsync,
isGitStatusCleanAsync,
makeProjectTarballAsync,
showDiffAsync,
} from './utils/repository';
export interface CredentialsResult<Credentials> {
source: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
credentials: Credentials;
}
interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration, TJob extends Job> {
ctx: BuildContext<TPlatform>;
projectConfiguration: ProjectConfiguration;
ensureCredentialsAsync(
ctx: BuildContext<TPlatform>
): Promise<CredentialsResult<Credentials> | undefined>;
ensureProjectConfiguredAsync(ctx: BuildContext<TPlatform>): Promise<void>;
prepareJobAsync(
ctx: BuildContext<TPlatform>,
jobData: {
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,
TJob extends Job
>(
builder: Builder<TPlatform, Credentials, ProjectConfiguration, TJob>
): Promise<() => Promise<string | undefined>> {
const credentialsResult = await withAnalyticsAsync(
async () => await builder.ensureCredentialsAsync(builder.ctx),
{
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: Event.CONFIGURE_PROJECT_SUCCESS,
failureEvent: Event.CONFIGURE_PROJECT_FAIL,
trackingCtx: builder.ctx.trackingCtx,
});
}
if (!(await isGitStatusCleanAsync())) {
Log.addNewLineIfNone();
await reviewAndCommitChangesAsync(
`[EAS Build] Run EAS Build for ${
requestedPlatformDisplayNames[builder.ctx.platform as Platform]
}`,
{
nonInteractive: builder.ctx.commandCtx.nonInteractive,
}
);
}
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, {
projectArchive,
credentials: credentialsResult?.credentials,
projectConfiguration: builder.projectConfiguration,
});
return async () => {
if (builder.ctx.commandCtx.local) {
await runLocalBuildAsync(job);
return undefined;
} else {
try {
return sendBuildRequestAsync(builder, job, metadata);
} catch (error) {
if (error?.expoApiV2ErrorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli');
}
throw error;
}
}
};
}
async function uploadProjectAsync<TPlatform extends Platform>(
ctx: BuildContext<TPlatform>
): Promise<string> {
let projectTarballPath;
try {
return await withAnalyticsAsync(
async () => {
const projectTarball = await makeProjectTarballAsync();
projectTarballPath = projectTarball.path;
const { bucketKey } = await uploadAsync(
UploadSessionType.EasBuildProjectSources,
projectTarball.path,
createProgressTracker({
total: projectTarball.size,
message: ratio =>
`Uploading to EAS Build (${formatBytes(projectTarball.size * ratio)} / ${formatBytes(
projectTarball.size
)})`,
completedMessage: `Uploaded to EAS`,
})
);
return bucketKey;
},
{
successEvent: Event.PROJECT_UPLOAD_SUCCESS,
failureEvent: Event.PROJECT_UPLOAD_FAIL,
trackingCtx: ctx.trackingCtx,
}
);
} finally {
if (projectTarballPath) {
await fs.remove(projectTarballPath);
}
}
}
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: Event;
failureEvent: Event;
trackingCtx: TrackingContext;
}
): Promise<Result> {
try {
const result = await fn();
Analytics.logEvent(analytics.successEvent, analytics.trackingCtx);
return result;
} catch (error) {
Analytics.logEvent(analytics.failureEvent, {
...analytics.trackingCtx,
reason: error.message,
});
throw error;
}
}
enum ShouldCommitChanges {
Yes,
ShowDiffFirst,
Abort,
}
async function reviewAndCommitChangesAsync(
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 or set EAS_BUILD_AUTOCOMMIT=1 in your environment.'
);
}
const { selected } = await promptAsync({
type: 'select',
name: 'selected',
message: 'Can we commit these changes to git for you?',
choices: [
{ title: 'Yes', value: ShouldCommitChanges.Yes },
...(askedFirstTime
? [{ title: 'Show the diff and ask me again', value: ShouldCommitChanges.ShowDiffFirst }]
: []),
{
title: 'Abort build process',
value: ShouldCommitChanges.Abort,
},
],
});
if (selected === ShouldCommitChanges.Abort) {
throw new Error(
"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({ initialCommitMessage });
Log.withTick('Committed changes.');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await showDiffAsync();
await reviewAndCommitChangesAsync(initialCommitMessage, {
nonInteractive,
askedFirstTime: false,
});
}
}
+161
View File
@@ -0,0 +1,161 @@
import { getConfig } from '@expo/config';
import { EasJsonReader } from '@expo/eas-json';
import fs from 'fs-extra';
import path from 'path';
import Log from '../log';
import { promptAsync } from '../prompts';
import { ensureLoggedInAsync } from '../user/actions';
import { gitAddAsync } from '../utils/git';
import { configureAndroidAsync } from './android/configure';
import { ConfigureContext } from './context';
import { configureIosAsync } from './ios/configure';
import { RequestedPlatform } from './types';
import {
commitPromptAsync,
ensureGitRepoExistsAsync,
isGitStatusCleanAsync,
maybeBailOnGitStatusAsync,
showDiffAsync,
} from './utils/repository';
const configureCommitMessage = {
[RequestedPlatform.Android]: 'Configure EAS Build for Android',
[RequestedPlatform.Ios]: 'Configure EAS Build for iOS',
[RequestedPlatform.All]: 'Configure EAS Build',
};
export async function configureAsync(options: {
platform: RequestedPlatform;
projectDir: string;
allowExperimental: boolean;
}): Promise<void> {
await ensureGitRepoExistsAsync();
await maybeBailOnGitStatusAsync();
const { exp } = getConfig(options.projectDir, { skipSDKVersionRequirement: true });
const ctx: ConfigureContext = {
user: await ensureLoggedInAsync(),
projectDir: options.projectDir,
exp,
allowExperimental: options.allowExperimental,
requestedPlatform: options.platform,
shouldConfigureAndroid: [RequestedPlatform.All, RequestedPlatform.Android].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')),
};
Log.newLine();
await ensureEasJsonExistsAsync(ctx);
if (ctx.shouldConfigureAndroid) {
await configureAndroidAsync(ctx);
}
if (ctx.shouldConfigureIos) {
await configureIosAsync(ctx);
}
if (!(await isGitStatusCleanAsync())) {
Log.newLine();
await reviewAndCommitChangesAsync(configureCommitMessage[options.platform]);
} else {
Log.newLine();
Log.withTick('No changes were necessary, the project is already configured correctly.');
}
}
export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<void> {
const easJsonPath = path.join(ctx.projectDir, 'eas.json');
let existingEasJson;
if (await fs.pathExists(easJsonPath)) {
const reader = new EasJsonReader(ctx.projectDir, ctx.requestedPlatform);
await reader.validateAsync();
existingEasJson = await reader.readRawAsync();
Log.withTick('Validated eas.json');
// If we have already populated eas.json with the default fields for the
// platform then proceed
if (ctx.requestedPlatform !== 'all' && existingEasJson.builds[ctx.requestedPlatform]) {
return;
} else if (
ctx.requestedPlatform === 'all' &&
existingEasJson.builds.ios &&
existingEasJson.builds.android
) {
return;
}
}
const shouldInitIOS =
['all', 'ios'].includes(ctx.requestedPlatform) && !existingEasJson?.builds.ios;
const shouldInitAndroid =
['all', 'android'].includes(ctx.requestedPlatform) && !existingEasJson?.builds.android;
const easJson = {
builds: {
...existingEasJson,
...(shouldInitAndroid
? {
android: {
release: {
workflow: ctx.hasAndroidNativeProject ? 'generic' : 'managed',
},
},
}
: null),
...(shouldInitIOS
? {
ios: {
release: {
workflow: ctx.hasIosNativeProject ? 'generic' : 'managed',
},
},
}
: null),
},
};
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
await gitAddAsync(easJsonPath, { intentToAdd: true });
Log.withTick(`${existingEasJson ? 'Updated' : 'Generated'} eas.json`);
}
enum ShouldCommitChanges {
Yes,
ShowDiffFirst,
Skip,
}
async function reviewAndCommitChangesAsync(
initialCommitMessage: string,
askedFirstTime: boolean = true
): Promise<void> {
const { selected } = await promptAsync({
type: 'select',
name: 'selected',
message: 'Can we commit these changes to git for you?',
choices: [
{ title: 'Yes', value: ShouldCommitChanges.Yes },
...(askedFirstTime
? [{ title: 'Show the diff and ask me again', value: ShouldCommitChanges.ShowDiffFirst }]
: []),
{
title: 'Skip committing changes, I will do it later on my own',
value: ShouldCommitChanges.Skip,
},
],
});
if (selected === ShouldCommitChanges.Yes) {
await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await showDiffAsync();
await reviewAndCommitChangesAsync(initialCommitMessage, false);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { AppPlatform } from '../graphql/generated';
import { RequestedPlatform } from './types';
export const requestedPlatformDisplayNames: Record<RequestedPlatform, string> = {
[RequestedPlatform.Ios]: 'iOS',
[RequestedPlatform.Android]: 'Android',
[RequestedPlatform.All]: 'Android and iOS',
};
export const appPlatformDisplayNames: Record<AppPlatform, string> = {
[AppPlatform.Android]: 'Android',
[AppPlatform.Ios]: 'iOS',
};
export const appPlatformEmojis = {
[AppPlatform.Ios]: '🍎',
[AppPlatform.Android]: '🤖',
};
+125
View File
@@ -0,0 +1,125 @@
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;
profile: string;
projectDir: string;
projectId: string;
user: Actor;
accountName: string;
projectName: string;
exp: ExpoConfig;
nonInteractive: boolean;
local: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
}
export async function createCommandContextAsync({
requestedPlatform,
profile,
exp,
projectDir,
projectId,
nonInteractive = false,
local,
skipCredentialsCheck = false,
skipProjectConfiguration = false,
waitForBuildEnd,
}: {
requestedPlatform: RequestedPlatform;
profile: string;
exp: ExpoConfig;
projectId: string;
projectDir: string;
nonInteractive: boolean;
local: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
}): Promise<CommandContext> {
const user = await ensureLoggedInAsync();
const accountName = getProjectAccountName(exp, user);
const projectName = exp.slug;
return {
requestedPlatform,
profile,
projectDir,
projectId,
user,
accountName,
projectName,
exp,
nonInteractive,
local,
skipCredentialsCheck,
skipProjectConfiguration,
waitForBuildEnd,
};
}
export interface ConfigureContext {
user: Actor;
projectDir: string;
exp: ExpoConfig;
allowExperimental: boolean;
requestedPlatform: RequestedPlatform;
shouldConfigureAndroid: boolean;
shouldConfigureIos: boolean;
hasAndroidNativeProject: boolean;
hasIosNativeProject: boolean;
}
type PlatformBuildProfile<T extends Platform> = T extends Platform.ANDROID
? AndroidBuildProfile
: IosBuildProfile;
export interface BuildContext<T extends Platform> {
commandCtx: CommandContext;
trackingCtx: TrackingContext;
platform: T;
buildProfile: PlatformBuildProfile<T>;
}
export function createBuildContext<T extends Platform>({
platform,
easConfig,
commandCtx,
}: {
platform: T;
easConfig: EasConfig;
commandCtx: CommandContext;
}): BuildContext<T> {
const buildProfile = easConfig.builds[platform] as PlatformBuildProfile<T> | undefined;
if (!buildProfile) {
throw new Error(`${platform} build profile does not exist`);
}
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,
platform,
buildProfile,
};
}
+167
View File
@@ -0,0 +1,167 @@
import { EasJsonReader } from '@expo/eas-json';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import ora from 'ora';
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 { Platform, RequestedPlatform } from './types';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureGitRepoExistsAsync, ensureGitStatusIsCleanAsync } from './utils/repository';
export async function buildAsync(commandCtx: CommandContext): Promise<void> {
await ensureGitRepoExistsAsync();
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(scheduledBuilds.map(i => i.buildId));
printBuildResults(commandCtx.accountName, builds);
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
}
}
async function startBuildsAsync(
commandCtx: CommandContext
): Promise<{ platform: Platform; buildId: string }[]> {
const shouldBuildAndroid = [RequestedPlatform.Android, RequestedPlatform.All].includes(
commandCtx.requestedPlatform
);
const shouldBuildIos = [RequestedPlatform.Ios, RequestedPlatform.All].includes(
commandCtx.requestedPlatform
);
const easConfig = await new EasJsonReader(
commandCtx.projectDir,
commandCtx.requestedPlatform
).readAsync(commandCtx.profile);
const builds: {
platform: Platform;
sendBuildRequestAsync: () => Promise<string | undefined>;
}[] = [];
if (shouldBuildAndroid) {
const sendBuildRequestAsync = await prepareAndroidBuildAsync(commandCtx, easConfig);
builds.push({ platform: Platform.ANDROID, sendBuildRequestAsync });
}
if (shouldBuildIos) {
const sendBuildRequestAsync = await prepareIosBuildAsync(commandCtx, easConfig);
builds.push({ platform: Platform.IOS, sendBuildRequestAsync });
}
return (
await Promise.all(
builds.map(async ({ platform, sendBuildRequestAsync }) => ({
platform,
buildId: await sendBuildRequestAsync(),
}))
)
).filter((build): build is { platform: Platform; buildId: string } => !!build.buildId);
}
async function waitForBuildEndAsync(
buildIds: string[],
{ timeoutSec = 1800, intervalSec = 30 } = {}
): 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: (BuildFragment | null)[] = await Promise.all(
buildIds.map(async buildId => {
try {
return await BuildQuery.byIdAsync(buildId, { useCache: false });
} catch (err) {
return null;
}
})
);
if (builds.length === 1) {
const build = nullthrows(builds[0]);
switch (build.status) {
case BuildStatus.Finished:
spinner.succeed('Build finished');
return builds;
case BuildStatus.InQueue:
spinner.text = 'Build queued...';
break;
case BuildStatus.Canceled:
spinner.text = 'Build canceled';
spinner.stopAndPersist();
return builds;
case BuildStatus.InProgress:
spinner.text = 'Build in progress...';
break;
case BuildStatus.Errored:
spinner.fail('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) {
spinner.succeed('All builds have finished');
return builds;
} else if (
builds.filter(build =>
build?.status
? [BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(
build.status
)
: false
).length === builds.length
) {
spinner.fail('Some of the builds were canceled or failed.');
return builds;
} else {
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}`,
inProgress && `Builds in progress: ${inProgress}`,
canceled && `Builds canceled: ${canceled}`,
errored && chalk.red(`Builds failed: ${errored}`),
finished && chalk.green(`Builds finished: ${finished}`),
unknownState && chalk.red(`Builds in unknown state: ${unknownState}`),
]
.filter(i => i)
.join('\t');
}
}
time = new Date().getTime();
await sleep(intervalSec * 1000);
}
spinner.warn('Timed out');
throw new Error(
'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
@@ -0,0 +1,104 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import chalk from 'chalk';
import { CredentialsProvider } from '../credentials/CredentialsProvider';
import Log from '../log';
import { confirmAsync, promptAsync } from '../prompts';
import { requestedPlatformDisplayNames } from './constants';
function logCredentials(target: 'local' | 'remote', platform: Platform) {
let message = `Using ${target} ${requestedPlatformDisplayNames[platform]} credentials`;
if (target === 'local') message += ` ${chalk.dim('(credentials.json)')}`;
if (target === 'remote') message += ` ${chalk.dim('(Expo server)')}`;
Log.succeed(message);
}
async function ensureCredentialsAutoAsync(
provider: CredentialsProvider,
workflow: Workflow,
nonInteractive: boolean
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
const platform = requestedPlatformDisplayNames[provider.platform];
switch (workflow) {
case Workflow.MANAGED:
if (await provider.hasLocalAsync()) {
return CredentialsSource.LOCAL;
} else {
return CredentialsSource.REMOTE;
}
case Workflow.GENERIC: {
const hasLocal = await provider.hasLocalAsync();
const hasRemote = await provider.hasRemoteAsync();
if (hasRemote && hasLocal) {
if (!(await provider.isLocalSyncedAsync())) {
if (nonInteractive) {
throw new Error(
`Contents of your local credentials.json for ${platform} are not the same as credentials on EAS servers. To use the desired credentials, set the "builds.${platform}.{profile}.credentialsSource" field in the credentials.json file to one of the following: "local", "remote".`
);
} else {
Log.log(
`Contents of your local credentials.json for ${platform} are not the same as credentials on EAS servers`
);
}
const { select } = await promptAsync({
type: 'select',
name: 'select',
message: 'Which credentials do you want to use for this build?',
choices: [
{ title: 'Local credentials.json', value: CredentialsSource.LOCAL },
{ title: 'Credentials stored on EAS servers.', value: CredentialsSource.REMOTE },
],
});
return select;
} else {
return CredentialsSource.LOCAL;
}
} else if (hasLocal) {
logCredentials('local', provider.platform);
return CredentialsSource.LOCAL;
} else if (hasRemote) {
logCredentials('remote', provider.platform);
return CredentialsSource.REMOTE;
} else {
if (nonInteractive) {
throw new Error(
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}. Either configure credentials.json, or launch the build without "--non-interactive" flag to get a prompt to generate credentials automatically.`
);
} else if (Log.isDebug) {
Log.warn(
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}`
);
}
const confirm = await confirmAsync({
message: `${platform} credentials not found, generate now?`,
});
if (confirm) {
return CredentialsSource.REMOTE;
} else {
throw new Error(`Aborting build process, credentials are not configured for ${platform}`);
}
}
}
}
}
export async function ensureCredentialsAsync(
provider: CredentialsProvider,
workflow: Workflow,
src: CredentialsSource,
nonInteractive: boolean
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
switch (src) {
case CredentialsSource.LOCAL:
logCredentials('local', provider.platform);
return CredentialsSource.LOCAL;
case CredentialsSource.REMOTE:
logCredentials('remote', provider.platform);
return CredentialsSource.REMOTE;
case CredentialsSource.AUTO:
return await ensureCredentialsAutoAsync(provider, workflow, nonInteractive);
}
}
+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;
}
}
@@ -0,0 +1,97 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import fs from 'fs-extra';
import Log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { gitAddAsync } from '../../utils/git';
import { ensureValidVersions } from '../utils/updates';
import { readPlistAsync, writePlistAsync } from './plist';
export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
let xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, xcodeProject)) {
xcodeProject = IOSConfig.Updates.ensureBundleReactNativePhaseContainsConfigurationScript(
projectDir,
xcodeProject
);
await fs.writeFile(IOSConfig.Paths.getPBXProjectPath(projectDir), xcodeProject.writeSync());
}
let expoPlist = await readExpoPlistAsync(projectDir);
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
expoPlist = IOSConfig.Updates.setUpdatesConfig(exp, expoPlist, accountName);
await writeExpoPlistAsync(projectDir, expoPlist);
}
// TODO: ensure ExpoPlist in pbxproj
}
export async function syncUpdatesConfigurationAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
try {
await ensureUpdatesConfiguredAsync(projectDir, exp);
} catch (error) {
Log.error(
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
);
throw error;
}
let expoPlist = await readExpoPlistAsync(projectDir);
if (!IOSConfig.Updates.isPlistVersionConfigurationSynced(exp, expoPlist)) {
expoPlist = IOSConfig.Updates.setVersionsConfig(exp, expoPlist);
await writeExpoPlistAsync(projectDir, expoPlist);
}
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
Log.warn(
'Native project configuration is not synced with values present in your app.json, run "eas build:configure" to make sure all values are applied in the native project'
);
}
}
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
const xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, xcodeProject)) {
const script = 'expo-updates/scripts/create-manifest-ios.sh';
const buildPhase = '"Bundle React Native code and images"';
throw new Error(`Path to ${script} is missing in a ${buildPhase} build phase.`);
}
const expoPlist = await readExpoPlistAsync(projectDir);
if (!IOSConfig.Updates.isPlistConfigurationSet(expoPlist)) {
throw new Error('Missing values in Expo.plist');
}
}
async function readExpoPlistAsync(projectDir: string): Promise<IOSConfig.ExpoPlist> {
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
return await readPlistAsync(expoPlistPath);
}
async function writeExpoPlistAsync(
projectDir: string,
expoPlist: IOSConfig.ExpoPlist
): Promise<void> {
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
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;
}
}
@@ -0,0 +1,207 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import { readPlistAsync } from '../plist';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from '../version';
jest.mock('fs');
const originalConsoleLog = console.log;
beforeAll(() => {
console.log = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
// do not remove the following line
// this fixes a weird error with tempy in @expo/image-utils
fs.removeSync(os.tmpdir());
});
beforeEach(() => {
vol.reset();
// do not remove the following line
// this fixes a weird error with tempy in @expo/image-utils
fs.mkdirpSync(os.tmpdir());
});
// generic workflow
describe(bumpVersionAsync, () => {
it('bumps expo.ios.buildNumber and CFBundleVersion when strategy = BumpStrategy.BUILD_NUMBER', async () => {
const fakeExp = initGenericProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
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(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('2');
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.0');
expect(infoPlist['CFBundleVersion']).toBe('2');
});
it('bumps expo.version and CFBundleShortVersionString when strategy = BumpStrategy.SHORT_VERSION', async () => {
const fakeExp = initGenericProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.SHORT_VERSION,
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
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(appJSON.expo.version).toBe('1.0.1');
expect(appJSON.expo.ios.buildNumber).toBe('1');
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.1');
expect(infoPlist['CFBundleVersion']).toBe('1');
});
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
const fakeExp = initGenericProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
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(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('1');
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.0');
expect(infoPlist['CFBundleVersion']).toBe('1');
});
});
// managed workflow
describe(bumpVersionInAppJsonAsync, () => {
it('bumps expo.ios.buildNumber when strategy = BumpStrategy.BUILD_NUMBER', async () => {
const fakeExp = initManagedProject();
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('2');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('2');
});
it('bumps expo.version when strategy = BumpStrategy.SHORT_VERSION', async () => {
const fakeExp = initManagedProject();
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.SHORT_VERSION,
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.1');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.1');
expect(appJSON.expo.ios.buildNumber).toBe('1');
});
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
const fakeExp = initManagedProject();
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('1');
});
});
function initGenericProject(): ExpoConfig {
vol.fromJSON(
{
'./app.json': JSON.stringify({
expo: {
version: '1.0.0',
ios: {
buildNumber: '1',
},
},
}),
'./ios/myproject/Info.plist': `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>`,
},
'/repo'
);
const fakeExp: ExpoConfig = {
name: 'myproject',
slug: 'myproject',
version: '1.0.0',
ios: {
buildNumber: '1',
},
};
return fakeExp;
}
function initManagedProject(): ExpoConfig {
vol.fromJSON(
{
'./app.json': JSON.stringify({
expo: {
version: '1.0.0',
ios: {
buildNumber: '1',
},
},
}),
},
'/repo'
);
const fakeExp: ExpoConfig = {
name: 'myproject',
slug: 'myproject',
version: '1.0.0',
ios: {
buildNumber: '1',
},
};
return fakeExp;
}
+137
View File
@@ -0,0 +1,137 @@
import { IOSConfig } from '@expo/config-plugins';
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 { transformGenericJob, transformManagedJob } from './graphql';
import { prepareJobAsync, sanitizedTargetName } from './prepareJob';
export async function prepareIosBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig
): Promise<() => Promise<string | undefined>> {
const buildCtx = createBuildContext<Platform.IOS>({
commandCtx,
platform: Platform.IOS,
easConfig,
});
if (
buildCtx.buildProfile.workflow === Workflow.GENERIC &&
!(await fs.pathExists(path.join(commandCtx.projectDir, 'ios')))
) {
throw new Error(
`"ios" directory not found. If you're trying to build a managed project, set ${chalk.bold(
`builds.ios.${commandCtx.profile}.workflow`
)} in "eas.json" to "managed".`
);
}
let iosBuildScheme: string | undefined;
let iosApplicationTarget: string | undefined;
if (buildCtx.buildProfile.workflow === Workflow.GENERIC) {
iosBuildScheme = buildCtx.buildProfile.scheme ?? (await resolveSchemeAsync(buildCtx));
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, commandCtx.exp);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {
iosBuildScheme,
iosApplicationTarget,
},
ensureCredentialsAsync: ensureIosCredentialsAsync,
ensureProjectConfiguredAsync: async () => {
await validateAndSyncProjectConfigurationAsync({
projectDir: commandCtx.projectDir,
exp: commandCtx.exp,
buildProfile: buildCtx.buildProfile,
});
},
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.BuildScheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
if (schemes.length === 1) {
return schemes[0];
}
const sortedSchemes = sortBy(schemes);
Log.newLine();
Log.log(
`We've found multiple schemes in your Xcode project: ${chalk.bold(sortedSchemes.join(', '))}`
);
Log.log(
`You can specify the scheme you want to build at ${chalk.bold(
`builds.ios.${ctx.commandCtx.profile}.scheme`
)} in eas.json.`
);
if (ctx.commandCtx.nonInteractive) {
const withoutTvOS = sortedSchemes.filter(i => !i.includes('tvOS'));
const scheme = withoutTvOS.length > 0 ? withoutTvOS[0] : sortedSchemes[0];
Log.log(
`You've run Expo CLI in non-interactive mode, choosing the ${chalk.bold(scheme)} scheme.`
);
Log.newLine();
return scheme;
} else {
const { selectedScheme } = await promptAsync({
type: 'select',
name: 'selectedScheme',
message: 'Which scheme would you like to build now?',
choices: sortedSchemes.map(scheme => ({ title: scheme, value: scheme })),
});
Log.newLine();
return selectedScheme as string;
}
}
@@ -0,0 +1,138 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import chalk from 'chalk';
import fs from 'fs-extra';
import Log from '../../log';
import {
ensureAppIdentifierIsDefinedAsync,
getProjectConfigDescription,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { Platform } from '../types';
import { updateAppJsonConfigAsync } from '../utils/appJson';
enum BundleIdentiferSource {
XcodeProject,
AppJson,
}
function isBundleIdentifierValid(bundleIdentifier: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(bundleIdentifier);
}
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(
`Invalid format of iOS bundleId. Only alphanumeric characters, '.' and '-' are allowed, and each '.' must be followed by a letter.`
);
Log.error(`Update "ios.bundleIdentifier" in ${configDescription} and run this command again.`);
throw new Error('Invalid bundleIdentifier');
}
}
export async function configureBundleIdentifierAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
const configDescription = getProjectConfigDescription(projectDir);
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(
projectDir
);
const bundleIdentifierFromConfig = IOSConfig.BundleIdentifier.getBundleIdentifier(exp);
if (bundleIdentifierFromPbxproj && bundleIdentifierFromConfig) {
if (bundleIdentifierFromPbxproj !== bundleIdentifierFromConfig) {
Log.addNewLineIfNone();
Log.warn(
`We detected that your Xcode project is configured with a different bundle identifier than the one defined in ${configDescription}.`
);
const hasBundleIdentifierInStaticConfig = await hasBundleIdentifierInStaticConfigAsync(
projectDir,
exp
);
if (!hasBundleIdentifierInStaticConfig) {
Log.log(`If you choose the one defined in ${configDescription} we'll automatically configure your Xcode project with it.
However, if you choose the one defined in the Xcode project you'll have to update ${configDescription} on your own.`);
}
Log.newLine();
const { bundleIdentifierSource } = await promptAsync({
type: 'select',
name: 'bundleIdentifierSource',
message: 'Which bundle identifier should we use?',
choices: [
{
title: `${chalk.bold(bundleIdentifierFromPbxproj)} - In Xcode project`,
value: BundleIdentiferSource.XcodeProject,
},
{
title: `${chalk.bold(bundleIdentifierFromConfig)} - In your ${configDescription}`,
value: BundleIdentiferSource.AppJson,
},
],
});
switch (bundleIdentifierSource) {
case BundleIdentiferSource.AppJson: {
IOSConfig.BundleIdentifier.setBundleIdentifierForPbxproj(
projectDir,
bundleIdentifierFromConfig
);
break;
}
case BundleIdentiferSource.XcodeProject: {
if (hasBundleIdentifierInStaticConfig) {
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.ios = { ...config.ios, bundleIdentifier: bundleIdentifierFromPbxproj };
});
} else {
throw new Error(missingBundleIdentifierMessage(configDescription));
}
break;
}
}
}
} else if (!bundleIdentifierFromPbxproj && !bundleIdentifierFromConfig) {
throw new Error(missingBundleIdentifierMessage(configDescription));
} else if (bundleIdentifierFromPbxproj && !bundleIdentifierFromConfig) {
if (getConfigFilePaths(projectDir).staticConfigPath) {
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.ios = { ...config.ios, bundleIdentifier: bundleIdentifierFromPbxproj };
});
} else {
throw new Error(missingBundleIdentifierMessage(configDescription));
}
} else if (!bundleIdentifierFromPbxproj && bundleIdentifierFromConfig) {
IOSConfig.BundleIdentifier.setBundleIdentifierForPbxproj(
projectDir,
bundleIdentifierFromConfig
);
}
}
function missingBundleIdentifierMessage(configDescription: string): string {
return `Please define "ios.bundleIdentifier" in ${configDescription} and run "eas build:configure" again.`;
}
/**
* Check if static config exists and if ios.bundleIdentifier is defined there.
* It will return false if the value in static config is different than "ios.bundleIdentifier" in ExpoConfig
*/
async function hasBundleIdentifierInStaticConfigAsync(
projectDir: string,
exp: ExpoConfig
): Promise<boolean> {
if (!exp.ios?.bundleIdentifier) {
return false;
}
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath) {
return false;
}
const rawStaticConfig = await fs.readJson(paths.staticConfigPath);
return rawStaticConfig?.expo?.ios?.bundleIdentifier === exp.ios.bundleIdentifier;
}
@@ -0,0 +1,86 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import { Workflow } from '@expo/eas-build-job';
import {
EasJsonReader,
IosBuildProfile,
IosGenericBuildProfile,
VersionAutoIncrement,
} from '@expo/eas-json';
import Log from '../../log';
import { ConfigureContext } from '../context';
import { isExpoUpdatesInstalled } from '../utils/updates';
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
import {
configureBundleIdentifierAsync,
ensureBundleIdentifierIsValidAsync,
} from './bundleIdentifer';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasIosNativeProject) {
return;
}
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);
}
Log.withTick('iOS project configured');
}
export async function validateAndSyncProjectConfigurationAsync({
projectDir,
exp,
buildProfile,
}: {
projectDir: string;
exp: ExpoConfig;
buildProfile: IosBuildProfile;
}): Promise<void> {
const { workflow, autoIncrement } = buildProfile;
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement);
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);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
} else {
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
}
}
function resolveVersionBumpStrategy(autoIncrement: VersionAutoIncrement): BumpStrategy {
if (autoIncrement === true) {
return BumpStrategy.BUILD_NUMBER;
} else if (autoIncrement === false) {
return BumpStrategy.NOOP;
} else if (autoIncrement === 'buildNumber') {
return BumpStrategy.BUILD_NUMBER;
} else {
return BumpStrategy.SHORT_VERSION;
}
}
@@ -0,0 +1,76 @@
import { Workflow } from '@expo/eas-build-job';
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/new/BuildCredentialsUtils';
import { AppLookupParams } from '../../credentials/ios/credentials';
import { CredentialsResult } from '../build';
import { BuildContext } from '../context';
import { ensureCredentialsAsync } from '../credentials';
import { Platform } from '../types';
export async function ensureIosCredentialsAsync(
ctx: BuildContext<Platform.IOS>
): Promise<CredentialsResult<IosCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
}
assert(ctx.commandCtx.exp?.ios?.bundleIdentifier, 'ios.bundleIdentifier is required');
return await resolveIosCredentialsAsync(ctx.commandCtx.projectDir, {
app: {
accountName: ctx.commandCtx.accountName,
projectName: ctx.commandCtx.projectName,
bundleIdentifier: ctx.commandCtx.exp.ios.bundleIdentifier,
},
workflow: ctx.buildProfile.workflow,
credentialsSource: ctx.buildProfile.credentialsSource,
distribution: ctx.buildProfile.distribution ?? 'store',
enterpriseProvisioning: ctx.buildProfile.enterpriseProvisioning,
nonInteractive: ctx.commandCtx.nonInteractive,
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
});
}
interface ResolveCredentialsParams {
app: AppLookupParams;
workflow: Workflow;
credentialsSource: CredentialsSource;
distribution: IosDistributionType;
enterpriseProvisioning?: IosEnterpriseProvisioning;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
}
export async function resolveIosCredentialsAsync(
projectDir: string,
params: ResolveCredentialsParams
): Promise<CredentialsResult<IosCredentials>> {
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 = await ensureCredentialsAsync(
provider,
params.workflow,
params.credentialsSource,
params.nonInteractive
);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
source: credentialsSource,
};
}
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;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { IOSConfig } from '@expo/config-plugins';
import plist from '@expo/plist';
import fs from 'fs-extra';
import path from 'path';
export async function readPlistAsync(plistPath: string): Promise<object> {
if (await fs.pathExists(plistPath)) {
const expoPlistContent = await fs.readFile(plistPath, 'utf8');
return plist.parse(expoPlistContent);
} else {
return {};
}
}
export async function writePlistAsync(
plistPath: string,
plistObject: IOSConfig.ExpoPlist | IOSConfig.InfoPlist
): Promise<void> {
const contents = plist.build(plistObject);
await fs.mkdirp(path.dirname(plistPath));
await fs.writeFile(plistPath, contents);
}
@@ -0,0 +1,165 @@
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';
import {
IosCredentials,
IosTargetCredentials,
isCredentialsMap,
readEnvironmentSecretsAsync,
} from '../../credentials/credentialsJson/read';
import { getProjectAccountNameAsync } from '../../project/projectUtils';
import { gitRootDirectoryAsync } from '../../utils/git';
import { BuildContext } from '../context';
import { Platform } from '../types';
interface JobData {
projectArchive: ArchiveSource;
credentials?: IosCredentials;
projectConfiguration: {
iosBuildScheme?: string;
iosApplicationTarget?: string;
};
}
export async function prepareJobAsync(
ctx: BuildContext<Platform.IOS>,
jobData: JobData
): Promise<Job> {
if (ctx.buildProfile.workflow === Workflow.GENERIC) {
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else if (ctx.buildProfile.workflow === Workflow.MANAGED) {
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else {
throw new Error("Unknown workflow. Shouldn't happen");
}
}
interface CommonJobProperties {
platform: Platform.IOS;
projectArchive: ArchiveSource;
builderEnvironment: Ios.BuilderEnvironment;
releaseChannel?: string;
distribution?: Ios.DistributionType;
cache: Cache;
secrets: {
buildCredentials: Ios.BuildCredentials;
environmentSecrets?: Record<string, string>;
};
}
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.IOS>,
{
credentials,
targetName,
projectArchive,
}: { credentials?: IosCredentials; targetName?: string; projectArchive: ArchiveSource }
): Promise<Partial<CommonJobProperties>> {
const environmentSecrets = await readEnvironmentSecretsAsync(ctx.commandCtx.projectDir);
let buildCredentials: CommonJobProperties['secrets']['buildCredentials'] = {};
if (credentials && isCredentialsMap(credentials)) {
const targets = Object.keys(credentials);
for (const target of targets) {
buildCredentials[target] = prepareTargetCredentials(credentials[target]);
}
} else if (credentials) {
// targetName
// - for managed projects: sanitized .name from the app config
// - for generic projects: name of the application target
assert(targetName, 'target name should be defined');
buildCredentials = {
[targetName]: prepareTargetCredentials(credentials),
};
}
return {
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,
secrets: {
...(environmentSecrets ? { environmentSecrets } : {}),
buildCredentials,
},
};
}
function prepareTargetCredentials(targetCredentials: IosTargetCredentials): Ios.TargetCredentials {
return {
provisioningProfileBase64: targetCredentials.provisioningProfile,
distributionCertificate: {
dataBase64: targetCredentials.distributionCertificate.certP12,
password: targetCredentials.distributionCertificate.certPassword,
},
};
}
async function prepareGenericJobAsync(
ctx: BuildContext<Platform.IOS>,
jobData: JobData,
buildProfile: IosGenericBuildProfile
): Promise<Partial<Ios.GenericJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
return {
...(await prepareJobCommonAsync(ctx, {
credentials: jobData.credentials,
targetName: jobData.projectConfiguration.iosApplicationTarget,
projectArchive: jobData.projectArchive,
})),
type: Workflow.GENERIC,
scheme: jobData.projectConfiguration.iosBuildScheme,
schemeBuildConfiguration:
buildProfile.schemeBuildConfiguration === 'Auto'
? undefined
: buildProfile.schemeBuildConfiguration,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
};
}
async function prepareManagedJobAsync(
ctx: BuildContext<Platform.IOS>,
jobData: JobData,
buildProfile: IosManagedBuildProfile
): Promise<Partial<Ios.ManagedJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.exp);
return {
...(await prepareJobCommonAsync(ctx, {
credentials: jobData.credentials,
targetName: jobData.projectConfiguration.iosApplicationTarget,
projectArchive: jobData.projectArchive,
})),
type: Workflow.MANAGED,
buildType: buildProfile.buildType,
username: accountName,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
};
}
// copy-pasted from expo-cli/packages/xdl/src/Exp.ts
// it's used in eject
export function sanitizedTargetName(name: string) {
return name
.replace(/[\W_]+/g, '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
+139
View File
@@ -0,0 +1,139 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import semver from 'semver';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { updateAppJsonConfigAsync } from '../utils/appJson';
import { readPlistAsync, writePlistAsync } from './plist';
export enum BumpStrategy {
SHORT_VERSION,
BUILD_NUMBER,
NOOP,
}
export async function bumpVersionAsync({
bumpStrategy,
projectDir,
exp,
}: {
projectDir: string;
exp: ExpoConfig;
bumpStrategy: BumpStrategy;
}): Promise<void> {
if (bumpStrategy === BumpStrategy.NOOP) {
return;
}
ensureStaticConfigExists(projectDir);
const infoPlist = await readInfoPlistAsync(projectDir);
await bumpVersionInAppJsonAsync({ bumpStrategy, projectDir, exp });
Log.log('Updated versions in app.json');
await writeVersionsToInfoPlistAsync({ projectDir, exp, infoPlist });
Log.log('Synchronized versions with Info.plist');
}
export async function bumpVersionInAppJsonAsync({
bumpStrategy,
projectDir,
exp,
}: {
bumpStrategy: BumpStrategy;
projectDir: string;
exp: ExpoConfig;
}): Promise<void> {
if (bumpStrategy === BumpStrategy.NOOP) {
return;
}
ensureStaticConfigExists(projectDir);
Log.addNewLineIfNone();
if (bumpStrategy === BumpStrategy.SHORT_VERSION) {
const shortVersion = IOSConfig.Version.getVersion(exp);
if (semver.valid(shortVersion)) {
const bumpedShortVersion = nullthrows(semver.inc(shortVersion, 'patch'));
Log.log(
`Bumping ${chalk.bold('expo.version')} from ${chalk.bold(shortVersion)} to ${chalk.bold(
bumpedShortVersion
)}`
);
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.version = bumpedShortVersion;
});
} else {
Log.log(`${chalk.bold('expo.version')} = ${chalk.bold(shortVersion)} is not a valid semver`);
const { bumpedShortVersion } = await promptAsync({
type: 'text',
name: 'bumpedShortVersion',
message: 'What is the next version?',
});
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.version = bumpedShortVersion;
});
}
} else {
const buildNumber = IOSConfig.Version.getBuildNumber(exp);
if (buildNumber.match(/^\d+(\.\d+)*$/)) {
const comps = buildNumber.split('.');
comps[comps.length - 1] = String(Number(comps[comps.length - 1]) + 1);
const bumpedBuildNumber = comps.join('.');
Log.log(
`Bumping ${chalk.bold('expo.ios.buildNumber')} from ${chalk.bold(
buildNumber
)} to ${chalk.bold(bumpedBuildNumber)}`
);
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.ios = { ...config.ios, buildNumber: String(bumpedBuildNumber) };
});
} else {
Log.log(`${chalk.bold('expo.ios.buildNumber')} = ${chalk.bold(buildNumber)} is not a number`);
const { bumpedBuildNumber } = await promptAsync({
type: 'text',
name: 'bumpedBuildNumber',
message: 'What is the next build number?',
});
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.ios = { ...config.ios, buildNumber: String(bumpedBuildNumber) };
});
}
}
}
async function readInfoPlistAsync(projectDir: string): Promise<IOSConfig.InfoPlist> {
const infoPlistPath = IOSConfig.Paths.getInfoPlistPath(projectDir);
return await readPlistAsync(infoPlistPath);
}
async function writeVersionsToInfoPlistAsync({
projectDir,
exp,
infoPlist,
}: {
projectDir: string;
exp: ExpoConfig;
infoPlist: IOSConfig.InfoPlist;
}): Promise<IOSConfig.InfoPlist> {
let updatedInfoPlist = IOSConfig.Version.setVersion(exp, infoPlist);
updatedInfoPlist = IOSConfig.Version.setBuildNumber(exp, updatedInfoPlist);
await writeInfoPlistAsync({ projectDir, infoPlist: updatedInfoPlist });
return updatedInfoPlist;
}
async function writeInfoPlistAsync({
projectDir,
infoPlist,
}: {
projectDir: string;
infoPlist: IOSConfig.InfoPlist;
}): Promise<void> {
const infoPlistPath = IOSConfig.Paths.getInfoPlistPath(projectDir);
await writePlistAsync(infoPlistPath, infoPlist);
}
function ensureStaticConfigExists(projectDir: string): void {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath) {
throw new Error('autoIncrement option is not supported when using app.config.js');
}
}
+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;
}
+66
View File
@@ -0,0 +1,66 @@
import { Metadata } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import { getAppIdentifierAsync } from '../project/projectUtils';
import { gitCommitHashAsync } from '../utils/git';
import { readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync } from './android/UpdatesModule';
import { BuildContext } from './context';
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
* the src directory and would change the directory structure of the emitted files
* under the build directory
*/
const packageJSON = require('../../package.json');
export async function collectMetadata<T extends Platform>(
ctx: BuildContext<T>,
{
credentialsSource,
}: {
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
}
): Promise<Metadata> {
return {
trackingContext: ctx.trackingCtx,
appVersion: ctx.commandCtx.exp.version!,
cliVersion: packageJSON.version,
workflow: ctx.buildProfile.workflow,
credentialsSource,
sdkVersion: ctx.commandCtx.exp.sdkVersion,
releaseChannel: await resolveReleaseChannel(ctx),
distribution: ctx.buildProfile.distribution ?? 'store',
appName: ctx.commandCtx.exp.name,
appIdentifier:
(await getAppIdentifierAsync({
projectDir: ctx.commandCtx.projectDir,
platform: ctx.platform,
exp: ctx.commandCtx.exp,
})) ?? undefined,
buildProfile: ctx.commandCtx.profile,
gitCommitHash: await gitCommitHashAsync(),
};
}
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';
}
+17
View File
@@ -0,0 +1,17 @@
export { Platform } from '@expo/eas-build-job';
export enum RequestedPlatform {
Android = 'android',
Ios = 'ios',
All = 'all',
}
export enum BuildStatus {
IN_QUEUE = 'in-queue',
IN_PROGRESS = 'in-progress',
ERRORED = 'errored',
FINISHED = 'finished',
CANCELED = 'canceled',
}
export type TrackingContext = Record<string, string | number>;
@@ -0,0 +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(name: Event, properties: Record<string, any>) {
logEvent(name, properties);
},
};
@@ -0,0 +1,25 @@
// import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import * as ExpoConfig from '@expo/config';
import assert from 'assert';
import fs from 'fs-extra';
export async function updateAppJsonConfigAsync(
{
projectDir,
exp,
}: {
projectDir: string;
exp: ExpoConfig.ExpoConfig;
},
modifyConfig: (config: any) => void
): Promise<void> {
const paths = ExpoConfig.getConfigFilePaths(projectDir);
assert(paths.staticConfigPath, "can't update dynamic config");
const rawStaticConfig = await fs.readJSON(paths.staticConfigPath);
rawStaticConfig.expo = rawStaticConfig.expo ?? {};
modifyConfig(rawStaticConfig.expo);
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
modifyConfig(exp);
}
@@ -0,0 +1,95 @@
import chalk from 'chalk';
import { BuildFragment, BuildStatus as GraphQLBuildStatus } from '../../graphql/generated';
import formatFields from '../../utils/formatFields';
import { appPlatformDisplayNames } from '../constants';
import { getBuildLogsUrl } from './url';
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: appPlatformDisplayNames[build.platform],
},
{
label: 'Status',
get value() {
switch (build.status) {
case GraphQLBuildStatus.InQueue:
return chalk.blue('in queue');
case GraphQLBuildStatus.InProgress:
return chalk.blue('in progress');
case GraphQLBuildStatus.Canceled:
return chalk.gray('canceled');
case GraphQLBuildStatus.Finished:
return chalk.green('finished');
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 }),
},
{
label: 'Artifact',
get value() {
if (
build.status === GraphQLBuildStatus.InQueue ||
build.status === GraphQLBuildStatus.InProgress
) {
}
switch (build.status) {
case GraphQLBuildStatus.InQueue:
case GraphQLBuildStatus.InProgress:
return '<in progress>';
case GraphQLBuildStatus.Canceled:
case GraphQLBuildStatus.Errored:
return '---------';
case GraphQLBuildStatus.Finished: {
const url = build.artifacts?.buildUrl;
return url ? url : chalk.red('not found');
}
default:
return 'unknown';
}
},
},
{ label: 'Started at', value: new Date(build.createdAt).toLocaleString() },
{
label: 'Finished at',
value:
build.status === GraphQLBuildStatus.InQueue ||
build.status === GraphQLBuildStatus.InProgress
? '<in progress>'
: new Date(build.updatedAt).toLocaleString(),
},
{ 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';
}
};
@@ -0,0 +1,125 @@
import assert from 'assert';
import chalk from 'chalk';
import {
BuildError,
BuildFragment,
BuildStatus,
DistributionType,
EasBuildDeprecationInfo,
EasBuildDeprecationInfoType,
} from '../../graphql/generated';
import Log, { learnMore } from '../../log';
import {
appPlatformDisplayNames,
appPlatformEmojis,
requestedPlatformDisplayNames,
} from '../constants';
import { getBuildLogsUrl } from './url';
export function printLogsUrls(
accountName: string,
builds: { platform: 'android' | 'ios'; buildId: string }[]
): void {
if (builds.length === 1) {
const { buildId } = builds[0];
const logsUrl = getBuildLogsUrl({
buildId,
account: accountName,
});
Log.log(`Build details: ${chalk.underline(logsUrl)}`);
} else {
builds.forEach(({ buildId, platform }) => {
const logsUrl = getBuildLogsUrl({
buildId,
account: accountName,
});
Log.log(
`${requestedPlatformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`
);
});
}
}
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 BuildFragment[]).forEach(build =>
printBuildResult(accountName, build)
);
}
}
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,
});
Log.log(
`${appPlatformEmojis[build.platform]} Open this link on your ${
appPlatformDisplayNames[build.platform]
} devices to install the app:`
);
Log.log(`${chalk.underline(logsUrl)}`);
} 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(`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`);
Log.log(`${chalk.underline(url)}`);
}
}
export function printDeprecationWarnings(deprecationInfo?: EasBuildDeprecationInfo | null): void {
if (!deprecationInfo) {
return;
}
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 === 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.'
);
Log.warn(deprecationInfo.message);
} else {
Log.warn('An unexpected warning was encountered. Please report it as a bug:');
Log.warn(deprecationInfo);
}
}
export function printUserError(error: BuildError): void {
Log.error(error.message);
if (error.docsUrl) {
Log.error(learnMore(error.docsUrl, { dim: false }));
}
}
@@ -0,0 +1,194 @@
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import fs from 'fs-extra';
import ora from 'ora';
import path from 'path';
import tar from 'tar';
import { v4 as uuidv4 } from 'uuid';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { formatBytes } from '../../utils/files';
import {
doesGitRepoExistAsync,
getGitDiffOutputAsync,
gitDiffAsync,
gitRootDirectoryAsync,
gitStatusAsync,
isGitInstalledAsync,
} from '../../utils/git';
import { getTmpDirectory } from '../../utils/paths';
import { endTimer, formatMilliseconds, startTimer } from '../../utils/timer';
async function ensureGitRepoExistsAsync(): Promise<void> {
if (!(await isGitInstalledAsync())) {
throw new Error('git command not found, install it before proceeding');
}
if (await doesGitRepoExistAsync()) {
return;
}
Log.warn("It looks like you haven't initialized the git repository yet.");
Log.warn('EAS Build requires you to use a git repository for your project.');
const confirmInit = await confirmAsync({
message: `Would you like to run 'git init' in the current directory?`,
});
if (!confirmInit) {
throw new Error(
'A git repository is required for building your project. Initialize it and run this command again.'
);
}
await spawnAsync('git', ['init']);
Log.log("We're going to make an initial commit for you repository.");
await commitPromptAsync({ initialCommitMessage: 'Initial commit', commitAllFiles: true });
}
async function isGitStatusCleanAsync(): Promise<boolean> {
const changes = await gitStatusAsync({ showUntracked: true });
return changes.length === 0;
}
async function maybeBailOnGitStatusAsync(): Promise<void> {
if (await isGitStatusCleanAsync()) {
return;
}
Log.addNewLineIfNone();
Log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
Log.log(
`It's recommended to ${chalk.bold(
'commit all your changes before proceeding'
)}, so you can revert the changes made by this command if necessary.`
);
const answer = await confirmAsync({
message: `Would you like to proceed?`,
});
if (!answer) {
throw new Error('Please commit all changes. Aborting...');
}
}
async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void> {
if (await isGitStatusCleanAsync()) {
return;
}
Log.addNewLineIfNone();
Log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
Log.log(
`This operation needs to be run on a clean working tree, please ${chalk.bold(
'commit all your changes before proceeding'
)}.`
);
if (nonInteractive) {
throw new Error('Please commit all changes. Aborting...');
}
const answer = await confirmAsync({
message: `Commit changes to git?`,
});
if (answer) {
await commitPromptAsync({ commitAllFiles: true });
} else {
throw new Error('Please commit all changes. Aborting...');
}
}
async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
const spinner = ora('Compressing project files');
await fs.mkdirp(getTmpDirectory());
const shallowClonePath = path.join(getTmpDirectory(), `${uuidv4()}-shallow-clone`);
const tarPath = path.join(getTmpDirectory(), `${uuidv4()}.tar.gz`);
// If the compression takes longer then a second, show the spinner.
// This can happen when the user has a lot of resources or doesn't ignore their CocoaPods.
// A basic project on a Mac can compress in roughly ~40ms.
// A fairly complex project without CocoaPods ignored can take up to 30s.
const timer = setTimeout(
() => {
spinner.start();
},
Log.isDebug ? 1 : 1000
);
// TODO: Possibly warn after more time about unoptimized assets.
const compressTimerLabel = 'makeProjectTarballAsync';
startTimer(compressTimerLabel);
try {
await spawnAsync('git', [
'clone',
'--no-hardlinks',
'--depth',
'1',
`file://${await gitRootDirectoryAsync()}`,
shallowClonePath,
]);
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.',
]);
} catch (err) {
clearTimeout(timer);
if (spinner.isSpinning) {
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 };
}
async function showDiffAsync() {
const outputTooLarge = (await getGitDiffOutputAsync()).split(/\r\n|\r|\n/).length > 100;
await gitDiffAsync({ withPager: outputTooLarge });
}
async function commitPromptAsync({
initialCommitMessage,
commitAllFiles,
}: {
initialCommitMessage?: string;
commitAllFiles?: boolean;
} = {}): Promise<void> {
const { message } = await promptAsync({
type: 'text',
name: 'message',
message: 'Commit message:',
initial: initialCommitMessage,
validate: (input: string) => input !== '',
});
if (commitAllFiles) {
await spawnAsync('git', ['add', '-A']);
}
await commitChangedFilesAsync(message);
}
async function commitChangedFilesAsync(message: string): Promise<void> {
await spawnAsync('git', ['add', '-u']);
await spawnAsync('git', ['commit', '-m', message]);
}
export {
isGitStatusCleanAsync,
showDiffAsync,
commitPromptAsync,
commitChangedFilesAsync,
ensureGitRepoExistsAsync,
ensureGitStatusIsCleanAsync,
maybeBailOnGitStatusAsync,
makeProjectTarballAsync,
};
@@ -0,0 +1,14 @@
import { ExpoConfig, getPackageJson } from '@expo/config';
export function isExpoUpdatesInstalled(projectDir: string) {
const packageJson = getPackageJson(projectDir);
return packageJson.dependencies && 'expo-updates' in packageJson.dependencies;
}
export function ensureValidVersions(exp: ExpoConfig): void {
if (!exp.runtimeVersion && !exp.sdkVersion) {
throw new Error(
"Couldn't find either 'runtimeVersion' or 'sdkVersion' to configure 'expo-updates'. Please specify at least one of these properties under the 'expo' key in 'app.json'"
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { getExpoWebsiteBaseUrl } from '../../api';
export function getBuildLogsUrl({
buildId,
account,
}: {
buildId: string;
account: string;
}): string {
return `${getExpoWebsiteBaseUrl()}/accounts/${account}/builds/${buildId}`;
}
export function getArtifactUrl(artifactId: string): string {
return `${getExpoWebsiteBaseUrl()}/artifacts/${artifactId}`;
}
@@ -0,0 +1,16 @@
import { Command } from '@oclif/command';
import Log from '../../log';
import { showLoginPromptAsync } from '../../user/actions';
export default class AccountLogin extends Command {
static description = 'log in with your Expo account';
static aliases = ['login'];
async run() {
Log.log('Log in to EAS');
await showLoginPromptAsync();
Log.log('Logged in');
}
}
@@ -0,0 +1,15 @@
import { Command } from '@oclif/command';
import Log from '../../log';
import { logoutAsync } from '../../user/User';
export default class AccountLogout extends Command {
static description = 'log out';
static aliases = ['logout'];
async run() {
await logoutAsync();
Log.log('Logged out');
}
}
@@ -0,0 +1,21 @@
import { Command } from '@oclif/command';
import chalk from 'chalk';
import Log from '../../log';
import { getActorDisplayName, getUserAsync } from '../../user/User';
export default class AccountView extends Command {
static description = 'show the username you are logged in as';
static aliases = ['whoami'];
async run() {
const user = await getUserAsync();
if (user) {
Log.log(chalk.green(getActorDisplayName(user)));
} else {
Log.warn('Not logged in');
process.exit(1);
}
}
}
@@ -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.`
);
}
}
}
@@ -0,0 +1,115 @@
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 {
CreateUpdateBranchForAppMutation,
CreateUpdateBranchForAppMutationVariables,
UpdateBranch,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
import { getBranchNameAsync } from '../../utils/git';
export async function createUpdateBranchOnAppAsync({
appId,
name,
}: CreateUpdateBranchForAppMutationVariables): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
gql`
mutation createUpdateBranchForApp($appId: ID!, $name: String!) {
updateBranch {
createUpdateBranchForApp(appId: $appId, name: $name) {
id
name
}
}
}
`,
{
appId,
name,
}
)
.toPromise()
);
const newBranch = result.updateBranch.createUpdateBranchForApp;
if (!newBranch) {
throw new Error(`Could not create branch ${name}.`);
}
return newBranch;
}
export default class BranchCreate extends Command {
static hidden = true;
static description = 'Create a branch on the current project.';
static args = [
{
name: 'name',
required: false,
description: 'Name of the branch to create',
},
];
static flags = {
json: flags.boolean({
description: 'return a json with the new branch ID and name.',
default: false,
}),
};
async run() {
let {
args: { name },
flags,
} = this.parse(BranchCreate);
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 (flags.json) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please name the branch:',
initial:
(await getBranchNameAsync()) || `branch-${Math.random().toString(36).substr(2, 4)}`,
validate: value => (value ? true : validationMessage),
}));
}
const newBranch = await createUpdateBranchOnAppAsync({ appId: projectId, name });
if (flags.json) {
Log.log(newBranch);
return;
}
Log.withTick(
`️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}`
)}.`
);
}
}
@@ -0,0 +1,128 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import CliTable from 'cli-table3';
import gql from 'graphql-tag';
import { format } from 'timeago.js';
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/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;
static description = 'List all branches on this project.';
static flags = {
json: flags.boolean({
description: 'return output as JSON',
default: false,
}),
};
async run() {
const { flags } = this.parse(BranchList);
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 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', ...UPDATE_COLUMNS],
wordWrap: true,
});
table.push(
...branches.map(branch => [
branch.name,
formatUpdate(branch.updates[0]),
branch.updates[0]?.runtimeVersion ?? 'N/A',
branch.updates[0]?.group ?? 'N/A',
])
);
Log.log(chalk.bold('Branches with their most recent update group:'));
Log.log(table.toString());
if (branches.length >= BRANCHES_LIMIT) {
Log.warn(`Showing first ${BRANCHES_LIMIT} branches, some results might be omitted.`);
}
}
}
}
export function formatUpdate(update: FormatUpdateParameter): string {
if (!update) {
return 'N/A';
}
const message = update.message ? `"${update.message}" ` : '';
return `${message}(${format(update.createdAt, 'en_US')} by ${getActorDisplayName(
update.actor as any
)})`;
}
@@ -0,0 +1,303 @@
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 {
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,
}),
'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 new update group.`,
default: false,
}),
};
async run() {
let {
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 { 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'"
);
}
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
if (!name) {
const validationMessage = 'branch name may not be empty.';
if (jsonFlag) {
throw new Error(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 (!message) {
const validationMessage = 'publish message may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ publishMessage: message } = await promptAsync({
type: 'text',
name: 'publishMessage',
message: `Please enter a publication message.`,
initial: republish
? `Republish "${oldMessage!}" - group: ${group}`
: (await getLastCommitMessageAsync())?.trim(),
validate: value => (value ? true : validationMessage),
}));
}
let newUpdateGroup;
const publishSpinner = ora('Publishing...').start();
try {
newUpdateGroup = await PublishMutation.publishUpdateGroupAsync({
branchId,
updateInfoGroup,
runtimeVersion: republish ? oldRuntimeVersion! : runtimeVersion,
message,
});
publishSpinner.succeed('Published!');
} catch (e) {
publishSpinner.fail('Failed to published updates');
throw e;
}
if (jsonFlag) {
Log.log(JSON.stringify(newUpdateGroup));
} else {
const outputMessage = new Table({
wordWrap: true,
chars: {
top: '',
'top-mid': '',
'top-left': '',
'top-right': '',
bottom: '',
'bottom-mid': '',
'bottom-left': '',
'bottom-right': '',
left: '',
'left-mid': '',
mid: '',
'mid-mid': '',
right: '',
'right-mid': '',
middle: ' ',
},
style: { 'padding-left': 0, 'padding-right': 0 },
});
outputMessage.push(
[chalk.dim('project'), `@${accountName}/${slug}`],
[chalk.dim('branch'), name],
[chalk.dim('runtimeVersion'), runtimeVersion],
[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}`;
}
@@ -0,0 +1,129 @@
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 {
EditUpdateBranchInput,
EditUpdateBranchMutation,
EditUpdateBranchMutationVariables,
UpdateBranch,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
async function renameUpdateBranchOnAppAsync({
appId,
name,
newName,
}: EditUpdateBranchInput): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<EditUpdateBranchMutation, EditUpdateBranchMutationVariables>(
gql`
mutation EditUpdateBranch($input: EditUpdateBranchInput!) {
updateBranch {
editUpdateBranch(input: $input) {
id
name
}
}
}
`,
{
input: {
appId,
name,
newName,
},
}
)
.toPromise()
);
return data.updateBranch.editUpdateBranch;
}
export default class BranchRename extends Command {
static hidden = true;
static description = 'Rename a branch.';
static flags = {
from: flags.string({
description: 'current name of the branch.',
required: false,
}),
to: flags.string({
description: 'new name of the branch.',
required: false,
}),
json: flags.boolean({
description: `return a json with the edited branch's ID and name.`,
default: false,
}),
};
async run() {
let {
flags: { json: jsonFlag, from: currentName, to: newName },
} = this.parse(BranchRename);
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 (!currentName) {
const validationMessage = 'current name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ currentName } = await promptAsync({
type: 'text',
name: 'currentName',
message: 'Please enter the current name of the branch to rename:',
validate: value => (value ? true : validationMessage),
}));
}
if (!newName) {
const validationMessage = 'new name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ newName } = await promptAsync({
type: 'text',
name: 'newName',
message: `Please rename ${currentName}`,
validate: value => (value ? true : validationMessage),
}));
}
const editedBranch = await renameUpdateBranchOnAppAsync({
appId: projectId,
name: currentName!,
newName: newName!,
});
if (jsonFlag) {
Log.log(JSON.stringify(editedBranch));
return;
}
Log.withTick(
`️Renamed branch from ${currentName} to ${chalk.bold(
editedBranch.name
)} on project ${chalk.bold(`@${accountName}/${exp.slug}`)}.`
);
}
}
@@ -0,0 +1,172 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import Table from 'cli-table3';
import gql from 'graphql-tag';
import { groupBy } from 'lodash';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
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, 'group' | 'message' | 'createdAt' | 'actor'>;
export async function viewUpdateBranchAsync({
appId,
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
.query<ViewBranchQuery, ViewBranchQueryVariables>(
gql`
query ViewBranch($appId: String!, $name: String!, $limit: Int!) {
app {
byId(appId: $appId) {
id
updateBranchByName(name: $name) {
id
name
updates(offset: 0, limit: $limit) {
id
group
message
createdAt
runtimeVersion
platform
manifestFragment
actor {
id
... on User {
username
}
... on Robot {
firstName
}
}
}
}
}
}
}
`,
{
appId,
name,
limit: PAGE_LIMIT,
}
)
.toPromise()
);
const updateBranch = data.app?.byId.updateBranchByName;
if (!updateBranch) {
throw new Error(`Could not find branch "${name}"`);
}
return updateBranch;
}
export default class BranchView extends Command {
static hidden = true;
static description = 'View a branch.';
static args = [
{
name: 'name',
required: false,
description: 'Name of the branch to view',
},
];
static flags = {
json: flags.boolean({
description: `return a json with the branch's ID name and recent update groups.`,
default: false,
}),
};
async run() {
let {
args: { name },
flags: { json: jsonFlag },
} = this.parse(BranchView);
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 (!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 view:',
validate: value => (value ? true : validationMessage),
}));
}
const UpdateBranch = await viewUpdateBranchAsync({
appId: projectId,
name,
});
const updates = Object.values(groupBy(UpdateBranch.updates, u => u.group)).map(
group => group[0]
);
if (jsonFlag) {
Log.log(JSON.stringify({ ...UpdateBranch, updates }));
return;
}
const groupTable = new Table({
head: UPDATE_COLUMNS,
wordWrap: true,
});
for (const update of updates) {
groupTable.push([formatUpdate(update), update.runtimeVersion, update.group]);
}
Log.withTick(
`️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(groupTable.toString());
}
}
@@ -0,0 +1,139 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import ora from 'ora';
import { appPlatformEmojis } from '../../build/constants';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
Build,
BuildStatus,
CancelBuildMutation,
CancelBuildMutationVariables,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../prompts';
async function cancelBuildAsync(buildId: string): Promise<Pick<Build, 'id' | 'status'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CancelBuildMutation, CancelBuildMutationVariables>(
gql`
mutation CancelBuildMutation($buildId: ID!) {
build(buildId: $buildId) {
cancel {
id
status
}
}
}
`,
{ buildId }
)
.toPromise()
);
return data.build!.cancel;
}
function formatUnfinishedBuild(
build: Pick<Build, 'id' | 'platform' | 'status' | 'createdAt'>
): string {
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}`;
}
async function selectBuildToCancelAsync(
projectId: string,
projectFullName: string
): Promise<string | null> {
const spinner = ora().start('Fetching the uncompleted builds…');
let builds;
try {
const [inQueueBuilds, inProgressBuilds] = await Promise.all([
BuildQuery.allForAppAsync(projectId, { status: BuildStatus.InQueue }),
BuildQuery.allForAppAsync(projectId, { status: BuildStatus.InProgress }),
]);
spinner.stop();
builds = [...inQueueBuilds, ...inProgressBuilds];
} catch (error) {
spinner.fail(
`Something went wrong and we couldn't fetch the builds for the project ${projectFullName}.`
);
throw error;
}
if (builds.length === 0) {
Log.warn(`There aren't any uncompleted builds for the project ${projectFullName}.`);
return null;
} else if (builds.length === 1) {
Log.log('Found one build');
Log.log(formatUnfinishedBuild(builds[0]));
await confirmAsync({
message: 'Do you want to cancel it?',
});
return builds[0].id;
} else {
const buildId = await selectAsync<string>(
'Which build do you want to cancel?',
builds.map(build => ({
title: formatUnfinishedBuild(build),
value: build.id,
}))
);
return buildId;
}
}
async function ensureBuildExistsAsync(buildId: string): Promise<void> {
try {
await BuildQuery.byIdAsync(buildId);
} catch (err) {
throw new Error(`Couldn't find a build matching the id ${buildId}`);
}
}
export default class BuildCancel extends Command {
static description = 'Cancel a build.';
static args = [{ name: 'BUILD_ID' }];
async run() {
const { BUILD_ID: buildIdFromArg } = this.parse(BuildCancel).args;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
if (buildIdFromArg) {
await ensureBuildExistsAsync(buildIdFromArg);
}
const buildId = buildIdFromArg || (await selectBuildToCancelAsync(projectId, projectFullName));
if (!buildId) {
return;
}
const spinner = ora().start('Canceling the build…');
try {
const { status } = await cancelBuildAsync(buildId);
if (status === BuildStatus.Canceled) {
spinner.succeed('Build canceled');
} else {
spinner.text = 'Build is already completed';
spinner.stopAndPersist();
}
} catch (error) {
spinner.fail(`Something went wrong and we couldn't cancel your build ${buildId}`);
throw error;
}
}
}
@@ -0,0 +1,99 @@
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import { configureAsync } from '../../build/configure';
import { RequestedPlatform } from '../../build/types';
import Log, { learnMore } from '../../log';
import { findProjectRootAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
export default class BuildConfigure extends Command {
static description = 'Configure the project to support EAS Build.';
static flags = {
platform: flags.enum({
description: 'Platform to configure',
char: 'p',
options: ['android', 'ios', 'all'],
}),
'allow-experimental': flags.boolean({
description: 'Enable experimental configuration steps.',
default: false,
}),
};
async run() {
const { flags } = this.parse(BuildConfigure);
Log.log(
'💡 The following process will configure your iOS and/or Android project to be compatible with EAS Build. These changes only apply to your local project files and you can safely revert them at any time.'
);
Log.newLine();
const platform =
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
const allowExperimental = flags['allow-experimental'];
if (allowExperimental) {
Log.warn(
`Project configuration will execute some additional steps that might fail if structure of your native project is significantly different from ${chalk.bold(
'expo eject'
)} or ${chalk.bold('expo init')}`
);
}
await ensureLoggedInAsync();
await configureAsync({
platform,
allowExperimental,
projectDir: (await findProjectRootAsync()) ?? process.cwd(),
});
Log.newLine();
logSuccess(platform);
}
}
function logSuccess(platform: RequestedPlatform) {
let platformsText = 'iOS and Android projects are';
let storesText = 'the Apple App Store or Google Play Store';
if (platform === 'android') {
platformsText = 'Android project is';
storesText = 'the Google Play Store';
} else if (platform === 'ios') {
platformsText = 'iOS project is';
storesText = 'the Apple App Store';
}
Log.log(`🎉 Your ${platformsText} ready to build.
- 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', {
learnMoreMessage: 'Learn more about other capabilities of EAS Build',
})}`);
}
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
const { platform } = await promptAsync({
type: 'select',
message: 'Which platforms would you like to configure for EAS Build?',
name: 'platform',
choices: [
{
title: 'All',
value: RequestedPlatform.All,
},
{
title: 'iOS',
value: RequestedPlatform.Ios,
},
{
title: 'Android',
value: RequestedPlatform.Android,
},
],
});
return platform;
}
+236 -6
View File
@@ -1,11 +1,241 @@
import { Command } from '@oclif/command';
import { ExpoConfig, getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import path from 'path';
export default class BuildIndex extends Command {
static description = 'build an app binary for your project';
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 { RequestedPlatform } from '../../build/types';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import { isGitStatusCleanAsync } from '../../build/utils/repository';
import { AppPlatform } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log, { learnMore } from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../prompts';
import { Actor } from '../../user/User';
import { ensureLoggedInAsync } from '../../user/actions';
static flags = {};
export default class Build extends Command {
static description = 'Start a build';
async run() {
throw new Error('Not implemented');
static flags = {
platform: flags.enum({ char: 'p', options: ['android', 'ios', 'all'] }),
'skip-credentials-check': flags.boolean({
default: false,
description: 'Skip validation of build credentials',
}),
'skip-project-configuration': flags.boolean({
default: false,
description: 'Skip project configuration',
}),
profile: flags.string({
default: 'release',
description: 'Name of the build profile from eas.json',
}),
'non-interactive': flags.boolean({
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',
}),
};
async run(): Promise<void> {
const { flags } = this.parse(Build);
await initAnalyticsAsync(); // TODO: run in oclif hook for every command
try {
const user = await ensureLoggedInAsync();
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;
}
const accountName = await getProjectAccountNameAsync(exp);
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
accountName,
platform,
projectId,
user,
});
if (accountHasPendingBuilds) {
return;
}
exp = (await ensureProjectConfiguredAsync(projectDir)) ?? exp;
const commandCtx = await createCommandContextAsync({
requestedPlatform: platform,
profile: flags.profile,
exp,
projectDir,
projectId,
nonInteractive,
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
}
}
}
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) {
return false;
}
const appPlatforms = toAppPlatforms(platform);
const maybePendingBuilds = await Promise.all(
appPlatforms.map(appPlatform => BuildQuery.getPendingBuildIdAsync(accountName, appPlatform))
);
const pendingBuilds = maybePendingBuilds.filter(i => i !== null);
if (pendingBuilds.length > 0) {
Log.newLine();
Log.error(
'Your other builds are still pending. Wait for them to complete before running this command again.'
);
Log.newLine();
const results = await Promise.all(
pendingBuilds.map(pendingBuild => {
return BuildQuery.byIdAsync(nullthrows(pendingBuild).id);
})
);
for (const result of results) {
Log.log(formatGraphQLBuild(result));
}
process.exitCode = 1;
return true;
}
return false;
}
function 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;
} else {
return true;
}
}
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];
}
}
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
const { platform } = await promptAsync({
type: 'select',
message: 'Build for platforms',
name: 'platform',
choices: [
{
title: 'All',
value: RequestedPlatform.All,
},
{
title: 'iOS',
value: RequestedPlatform.Ios,
},
{
title: 'Android',
value: RequestedPlatform.Android,
},
],
});
return platform;
}
async function ensureProjectConfiguredAsync(projectDir: string): Promise<ExpoConfig | null> {
if (await fs.pathExists(path.join(projectDir, 'eas.json'))) {
return null;
}
const confirm = await confirmAsync({
message: 'This app is not set up for building with EAS. Set it up now?',
});
if (confirm) {
await configureAsync({
projectDir,
platform: RequestedPlatform.All,
allowExperimental: false,
});
if (!(await isGitStatusCleanAsync())) {
throw new Error(
'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(
'https://docs.expo.io/build/eas-json'
)})`
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import ora from 'ora';
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,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
export default class BuildList extends Command {
static description = 'list all builds for your project';
static flags = {
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: requestedPlatform, status: buildStatus, limit = 10 } = this.parse(
BuildList
).flags;
const platform = toAppPlatform(requestedPlatform);
const graphqlBuildStatus = toGraphQLBuildStatus(buildStatus);
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
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 builds = await BuildQuery.allForAppAsync(projectId, {
limit,
platform,
status: graphqlBuildStatus,
});
if (builds.length) {
if (platform || graphqlBuildStatus) {
spinner.succeed(
`Showing ${builds.length} matching builds for the project ${projectName}`
);
} else {
spinner.succeed(`Showing last ${builds.length} builds for the project ${projectName}`);
}
const list = builds
.map(build => formatGraphQLBuild(build))
.join(`\n\n${chalk.dim('———')}\n\n`);
Log.log(`\n${list}`);
} else {
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the build list ${projectName}`);
throw e;
}
}
}
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;
}
};
@@ -1,15 +0,0 @@
import { Command, flags } from '@oclif/command';
export default class BuildStatus extends Command {
static description = 'get the status of the latest builds for your project';
static flags = {
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished'] }),
};
async run() {
// const { flags } = this.parse(Add);
throw new Error('Not implemented');
}
}
@@ -0,0 +1,63 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import ora from 'ora';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import { BuildFragment } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
export default class BuildView extends Command {
static description = 'view a build for your project';
static args = [{ name: 'BUILD_ID' }];
async run() {
const { BUILD_ID: buildId } = this.parse(BuildView).args;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
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: BuildFragment;
if (buildId) {
build = await BuildQuery.byIdAsync(buildId);
} else {
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) {
spinner.succeed(`Found a matching build for the project ${projectName}`);
} else {
spinner.succeed(`Showing the last build 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 {
spinner.fail(
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
);
}
throw err;
}
}
}
@@ -0,0 +1,153 @@
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 {
CreateUpdateChannelOnAppMutation,
CreateUpdateChannelOnAppMutationVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
findProjectRootAsync,
getBranchByNameAsync,
getProjectAccountNameAsync,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { createUpdateBranchOnAppAsync } from '../branch/create';
async function createUpdateChannelOnAppAsync({
appId,
channelName,
branchId,
}: {
appId: string;
channelName: string;
branchId: string;
}): Promise<CreateUpdateChannelOnAppMutation> {
// Point the new channel at a branch with its same name.
const branchMapping = JSON.stringify({
data: [{ branchId, branchMappingLogic: 'true' }],
version: 0,
});
return await withErrorHandlingAsync(
graphqlClient
.mutation<CreateUpdateChannelOnAppMutation, CreateUpdateChannelOnAppMutationVariables>(
gql`
mutation CreateUpdateChannelOnApp($appId: ID!, $name: String!, $branchMapping: String!) {
updateChannel {
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
id
name
branchMapping
}
}
}
`,
{
appId,
name: channelName,
branchMapping,
}
)
.toPromise()
);
}
export default class ChannelCreate extends Command {
static hidden = true;
static description = 'Create a channel on the current project.';
static args = [
{
name: 'name',
required: false,
description: 'Name of the channel to create',
},
];
static flags = {
json: flags.boolean({
description:
'print output as a JSON object with the new channel ID, name and branch mapping.',
default: false,
}),
};
async run() {
let {
args: { name: channelName },
flags: { json: jsonFlag },
} = this.parse(ChannelCreate);
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 = 'Channel name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name: channelName } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please name the channel:',
validate: value => (value ? true : validationMessage),
}));
}
let branchId: string;
let branchMessage: string;
try {
const existingBranch = await getBranchByNameAsync({
appId: projectId,
name: channelName,
});
branchId = existingBranch.id;
branchMessage = `We found a branch with the same name`;
} catch (e) {
const newBranch = await createUpdateBranchOnAppAsync({
appId: projectId,
name: channelName,
});
branchId = newBranch.id;
branchMessage = `We also went ahead and made a branch with the same name`;
}
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(JSON.stringify(newChannel));
return;
}
Log.withTick(
`️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);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Command } from '@oclif/command';
import { runCredentialsManagerAsync } from '../credentials/CredentialsManager';
import { CredentialsManager } from '../credentials/CredentialsManager';
import { createCredentialsContextAsync } from '../credentials/context';
import { SelectPlatform } from '../credentials/manager/SelectPlatform';
@@ -9,6 +9,6 @@ export default class Credentials extends Command {
async run() {
const ctx = await createCredentialsContextAsync(process.cwd(), {});
await runCredentialsManagerAsync(ctx, new SelectPlatform());
new CredentialsManager(ctx).runActionAsync(new SelectPlatform());
}
}
@@ -0,0 +1,97 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import assert from 'assert';
import chalk from 'chalk';
import ora from 'ora';
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import { AppleTeamQuery } from '../../credentials/ios/api/graphql/queries/AppleTeamQuery';
import formatDevice from '../../devices/utils/formatDevice';
import Log from '../../log';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
export default class BuildList extends Command {
static description = 'list all registered devices for your account';
static flags = {
'apple-team-id': flags.string(),
};
async run() {
let appleTeamIdentifier = this.parse(BuildList).flags['apple-team-id'];
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
let spinner: ora.Ora;
if (!appleTeamIdentifier) {
spinner = ora().start('Fetching the list of teams for the project…');
try {
const teams = await AppleTeamQuery.getAllForAccountAsync(accountName);
if (teams.length) {
spinner.succeed();
if (teams.length === 1) {
appleTeamIdentifier = teams[0].appleTeamIdentifier;
} else {
const result = await promptAsync({
type: 'select',
name: 'appleTeamIdentifier',
message: 'What Apple Team would you like to list devices for?',
choices: teams.map(team => ({
title: team.appleTeamName
? `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`
: team.appleTeamIdentifier,
value: team.appleTeamIdentifier,
})),
});
appleTeamIdentifier = result.appleTeamIdentifier;
}
} else {
spinner.fail(`Couldn't find any teams for the account ${accountName}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the list of teams`);
throw e;
}
}
assert(appleTeamIdentifier, 'No team identifier is specified');
spinner = ora().start('Fetching the list of devices for the team…');
try {
const result = await AppleDeviceQuery.getAllForAppleTeamAsync(
accountName,
appleTeamIdentifier
);
if (result?.appleDevices.length) {
const { appleTeamName, appleDevices } = result;
spinner.succeed(
`Found ${appleDevices.length} devices for team ${appleTeamName ?? appleTeamIdentifier}`
);
const list = appleDevices
.map(device =>
formatDevice(device, { appleTeamName, appleTeamIdentifier: appleTeamIdentifier! })
)
.join(`\n\n${chalk.dim('———')}\n\n`);
Log.log(`\n${list}`);
} else {
spinner.fail(`Couldn't find any devices for the team ${appleTeamIdentifier}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the device list`);
throw e;
}
}
}
@@ -0,0 +1,53 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import ora from 'ora';
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import formatDevice from '../../devices/utils/formatDevice';
import Log from '../../log';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
export default class DeviceView extends Command {
static description = 'view a device for your project';
static args = [{ name: 'UDID' }];
async run() {
const { UDID } = this.parse(DeviceView).args;
if (!UDID) {
Log.log(
`The device UDID is required to view a specific device. For example:
eas device:view 00005787-000872430189501D
If you are not sure what is the UDID of the device you are looking for, run:
eas device:list
`
);
throw new Error('Device UDID is missing');
}
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const spinner = ora().start(`Fetching device details for ${UDID}…`);
try {
const device = await AppleDeviceQuery.getByDeviceIdentifier(accountName, UDID);
if (device) {
spinner.succeed('Fetched device details');
Log.log(`\n${formatDevice(device, device.appleTeam)}`);
} else {
spinner.fail(`Couldn't find a device with the UDID ${UDID}`);
}
} catch (e) {
console.log(e);
spinner.fail(`Something went wrong and we couldn't fetch the device with UDID ${UDID}`);
throw e;
}
}
}
-13
View File
@@ -1,13 +0,0 @@
import { Command } from '@oclif/command';
import { showLoginPromptAsync } from '../user/actions';
export default class Login extends Command {
static description = 'log in with your EAS account';
async run() {
this.log('Log in to EAS');
await showLoginPromptAsync();
this.log('Logged in.');
}
}
-12
View File
@@ -1,12 +0,0 @@
import { Command } from '@oclif/command';
import { logoutAsync } from '../user/User';
export default class Logout extends Command {
static description = 'log out';
async run() {
await logoutAsync();
this.log('Logged out.');
}
}
@@ -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,59 @@
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';
static usage = 'secrets:list';
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());
}
}
+231
View File
@@ -0,0 +1,231 @@
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 } from '../submissions/types';
import { ensureLoggedInAsync } from '../user/actions';
const COMMON_FLAGS = '';
const ANDROID_FLAGS = 'Android specific options';
const IOS_FLAGS = 'iOS specific options';
export default class BuildSubmit extends Command {
static description = 'Submits build artifact to app store';
static usage = 'submit --platform=(android|ios)';
static aliases = ['build:submit'];
static examples = [
`$ eas submit --platform=ios
- Fully interactive iOS submission\n`,
`$ eas submit --platform=android
- Fully interactive Android submission\n`,
`$ eas submit -p android --latest --key=/path/to/google-services.json
- Minimal non-interactive Android submission, however it can ask you for other params if not specified\n`,
`$ EXPO_APPLE_APP_SPECIFIC_PASSWORD=xxx eas submit -p ios --latest [email protected] --asc-app-id=1234567890,
- Minimal non-interactive iOS submission, assuming you already have an app in App Store Connect
and provide its App ID`,
];
static flags = {
platform: flags.enum({
char: 'p',
description: 'For which platform you want to submit a build',
options: ['android', 'ios'],
helpLabel: COMMON_FLAGS,
}),
/* Common flags for both platforms */
latest: flags.boolean({
description: 'Submit the latest build for specified platform',
exclusive: ['id', 'path', 'url'],
default: false,
helpLabel: COMMON_FLAGS,
}),
id: flags.string({
description: 'ID of the build to submit',
exclusive: ['latest, path, url'],
helpLabel: COMMON_FLAGS,
}),
path: flags.string({
description: 'Path to the .apk/.aab file',
exclusive: ['latest', 'id', 'url'],
helpLabel: COMMON_FLAGS,
}),
url: flags.string({
description: 'App archive url',
exclusive: ['latest', 'id', 'path'],
helpLabel: COMMON_FLAGS,
}),
verbose: flags.boolean({
description: 'Always print logs from Submission Service',
default: false,
helpLabel: COMMON_FLAGS,
}),
/* Android specific flags */
type: flags.enum<'apk' | 'aab'>({
description: 'Android archive type',
options: ['apk', 'aab'],
helpLabel: ANDROID_FLAGS,
}),
key: flags.string({
description: 'Path to the JSON key used to authenticate with Google Play',
helpLabel: ANDROID_FLAGS,
}),
'android-package': flags.string({
description: 'Android package name (default: expo.android.package from app config)',
helpLabel: ANDROID_FLAGS,
}),
track: flags.enum({
description: 'The track of the application to use',
default: 'internal',
options: ['production', 'beta', 'alpha', 'internal', 'rollout'],
helpLabel: ANDROID_FLAGS,
}),
'release-status': flags.enum({
description: 'Release status (used when uploading new APKs/AABs)',
default: 'completed',
options: ['completed', 'draft', 'halted', 'inProgress'],
helpLabel: ANDROID_FLAGS,
}),
/* iOS specific flags */
'apple-id': flags.string({
description: 'Your Apple ID username (you can also set EXPO_APPLE_ID env variable)',
helpLabel: IOS_FLAGS,
}),
'asc-app-id': flags.string({
description: `App Store Connect unique application Apple ID number. Providing this param results in skipping app creation step. ${learnMore(
'https://expo.fyi/asc-app-id'
)}`,
helpLabel: IOS_FLAGS,
}),
'apple-team-id': flags.string({
description: 'Your Apple Developer Team ID',
helpLabel: IOS_FLAGS,
}),
'app-name': flags.string({
description:
'The name of your app as it will appear on the App Store (default: expo.name from app config)',
helpLabel: IOS_FLAGS,
}),
'bundle-identifier': flags.string({
description:
'Your iOS Bundle Identifier (default: expo.ios.bundleIdentifier from app config)',
helpLabel: IOS_FLAGS,
}),
sku: flags.string({
description:
'An unique ID for your app that is not visible on the App Store, will be generated unless provided',
helpLabel: IOS_FLAGS,
}),
language: flags.string({
description: 'Primary language (e.g. English, German, ...)',
default: 'en-US',
helpLabel: IOS_FLAGS,
}),
'company-name': flags.string({
description:
'The name of your company, needed only for the first upload of any app to App Store',
helpLabel: IOS_FLAGS,
}),
};
async run(): Promise<void> {
const {
flags: {
// android
'android-package': androidPackage,
'release-status': releaseStatus,
// ios
'apple-id': appleId,
'asc-app-id': ascAppId,
'apple-team-id': appleTeamId,
'app-name': appName,
'bundle-identifier': bundleIdentifier,
'company-name': companyName,
// common
...flags
},
} = this.parse(BuildSubmit);
await ensureLoggedInAsync();
const platform =
(flags.platform?.toUpperCase() as AppPlatform | undefined) ??
(await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
// TODO: Make this work outside project dir
if (!projectDir) {
throw new Error("Please run this command inside your project's directory");
}
if (platform === AppPlatform.Android) {
const options: AndroidSubmitCommandFlags = {
androidPackage,
releaseStatus,
...flags,
};
const ctx = AndroidSubmitCommand.createContext(projectDir, projectId, options);
const command = new AndroidSubmitCommand(ctx);
await command.runAsync();
} else if (platform === AppPlatform.Ios) {
const options: IosSubmitCommandFlags = {
appleId,
ascAppId,
appleTeamId,
appName,
bundleIdentifier,
companyName,
...flags,
};
const ctx = IosSubmitCommand.createContext(projectDir, projectId, options);
const command = new IosSubmitCommand(ctx);
await command.runAsync();
} else {
throw new Error(`Unsupported platform: ${platform}!`);
}
}
}
async function promptForPlatformAsync(): Promise<AppPlatform> {
const { platform } = await promptAsync({
type: 'select',
message: 'Submit to platform',
name: 'platform',
choices: [
{
title: 'iOS',
value: AppPlatform.Ios,
},
{
title: 'Android',
value: AppPlatform.Android,
},
],
});
return platform;
}
Loaded 100 of 353 files, more files were not shown because too many files have changed in this diff. Show more