Compare commits

..
114 Commits
Author SHA1 Message Date
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
228 changed files with 27396 additions and 3430 deletions

No files matched your search

+6
View File
@@ -1,3 +1,9 @@
<!-- Thanks for contributing to _EAS CLI_! Before you submit your pull request, please make sure to check the following boxes by putting an x in the [ ] (don't: [x ], [ x], do: [x]) -->
# Checklist
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary.
# Why
Please describe the motivation for this PR, and link to relevant GitHub issues, forums posts, or feature requests.
+93
View File
@@ -0,0 +1,93 @@
# Changelog
This is the log of notable changes to EAS CLI and related packages.
## main
### 🛠 Breaking changes
### 🎉 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))
### 🧹 Chores
## [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))
+1
View File
@@ -6,3 +6,4 @@
1. Wait until the `release` GitHub workflow has finished.
1. Once the release ready to go, you can go to
https://github.com/expo/eas-cli/releases and mark it as not draft.
1. Run `yarn lerna publish from-git` in the repository root folder. This will publish the packages to npm.
+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 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.1.2"
"version": "0.7.0"
}
+1
View File
@@ -16,6 +16,7 @@
"@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",
+32 -4
View File
@@ -1,9 +1,37 @@
module.exports = {
rules: {
'import/no-cycle': 'error',
'import/no-extraneous-dependencies': ['error', { devDependencies: [
'**/__tests__/**/*',
'**/__mocks__/**/*'
] }],
'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'],
};
+58 -34
View File
@@ -7,27 +7,28 @@ EAS command line tool
[![Downloads/week](https://img.shields.io/npm/dw/eas-cli.svg)](https://npmjs.org/package/eas-cli)
[![License](https://img.shields.io/npm/l/eas-cli.svg)](https://github.com/expo/eas-cli/blob/main/package.json)
<!-- toc -->
* [eas-cli](#eas-cli)
* [Installation](#installation)
* [Usage](#usage)
* [Commands](#commands)
<!-- tocstop -->
# Installation
```sh
npm install -g eas-cli
# or
yarn global add eas-cli
```
# Usage
<!-- usage -->
```sh-session
$ npm install -g eas-cli
$ eas COMMAND
running command...
$ eas (-v|--version|version)
eas-cli/0.1.2 darwin-x64 node-v14.15.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
@@ -36,14 +37,16 @@ USAGE
* [`eas account:logout`](#eas-accountlogout)
* [`eas account:view`](#eas-accountview)
* [`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 [BUILDID]`](#eas-buildview-buildid)
* [`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 secrets:list`](#eas-secretslist)
* [`eas submit --platform=(android|ios)`](#eas-submit---platformandroidios)
## `eas account:login`
@@ -58,7 +61,7 @@ ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/login.ts)_
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/account/login.ts)_
## `eas account:logout`
@@ -72,7 +75,7 @@ ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/logout.ts)_
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/account/logout.ts)_
## `eas account:view`
@@ -86,7 +89,7 @@ ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/view.ts)_
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/account/view.ts)_
## `eas build`
@@ -102,10 +105,21 @@ OPTIONS
--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
--wait Wait for build(s) to complete
--[no-]wait Wait for build(s) to complete
```
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/index.ts)_
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
Cancel a build.
```
USAGE
$ eas build:cancel [BUILD_ID]
```
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/cancel.ts)_
## `eas build:configure`
@@ -120,7 +134,7 @@ OPTIONS
--allow-experimental Enable experimental configuration steps.
```
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/configure.ts)_
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/configure.ts)_
## `eas build:list`
@@ -133,21 +147,21 @@ USAGE
OPTIONS
--limit=limit
--platform=(all|android|ios)
--status=(in-queue|in-progress|errored|finished)
--status=(in-queue|in-progress|errored|finished|canceled)
```
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/list.ts)_
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/list.ts)_
## `eas build:view [BUILDID]`
## `eas build:view [BUILD_ID]`
view a build for your project
```
USAGE
$ eas build:view [BUILDID]
$ eas build:view [BUILD_ID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/view.ts)_
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/view.ts)_
## `eas credentials`
@@ -158,7 +172,7 @@ USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/credentials.ts)_
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/credentials.ts)_
## `eas device:create`
@@ -169,7 +183,7 @@ USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/create.ts)_
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/device/create.ts)_
## `eas device:list`
@@ -183,7 +197,7 @@ OPTIONS
--apple-team-id=apple-team-id
```
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/list.ts)_
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/device/list.ts)_
## `eas device:view [UDID]`
@@ -194,7 +208,7 @@ USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/view.ts)_
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -213,6 +227,17 @@ OPTIONS
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
## `eas secrets:list`
Lists environment secrets available for your current app
```
USAGE
$ eas secrets:list
```
_See code: [build/commands/secrets/list.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/secrets/list.ts)_
## `eas submit --platform=(android|ios)`
Submits build artifact to app store
@@ -237,8 +262,7 @@ OPTIONS
iOS specific options=asc-app-id App Store Connect unique application Apple ID
number. Providing this param results in skipping
app creation step. Learn more:
https://expo.fyi/asc-app-id
app creation step. Learn more.
iOS specific options=bundle-identifier Your iOS Bundle Identifier (default:
expo.ios.bundleIdentifier from app config)
@@ -291,5 +315,5 @@ EXAMPLES
and provide its App ID
```
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/submit.ts)_
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/submit.ts)_
<!-- commandsstop -->
@@ -1,3 +0,0 @@
const getConfig = jest.fn(() => ({ exp: {} }));
export { getConfig };
+3
View File
@@ -0,0 +1,3 @@
module.exports = async () => {
process.env.TZ = 'UTC';
};
+7
View File
@@ -2,11 +2,18 @@ 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',
};
+15 -7
View File
@@ -1,18 +1,18 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.1.2",
"version": "0.7.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "0.0.0-alpha.16",
"@expo/apple-utils": "0.0.0-alpha.17",
"@expo/config": "~3.3.19",
"@expo/config-plugins": "~1.0.9",
"@expo/eas-build-job": "0.1.4",
"@expo/eas-json": "^0.1.0-alpha.20",
"@expo/config-plugins": "1.0.21-alpha.0",
"@expo/eas-build-job": "0.2.21",
"@expo/eas-json": "^0.7.0",
"@expo/json-file": "^8.2.24",
"@expo/plist": "^0.0.11",
"@expo/plugin-warn-if-update-available": "^1.7.0",
@@ -21,6 +21,7 @@
"@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",
"chalk": "^4.1.0",
@@ -49,13 +50,17 @@
"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",
@@ -63,6 +68,7 @@
"@babel/preset-typescript": "^7.10.4",
"@expo/babel-preset-cli": "^0.2.17",
"@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",
@@ -77,15 +83,17 @@
"@types/node-forge": "^0.9.5",
"@types/progress": "^2.0.3",
"@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",
"memfs": "^3.2.0",
"mockdate": "^3.0.2",
"nock": "^13.0.5",
"ts-node": "^8",
"typescript": "^3.9.7",
"wonka": "^4.0.14"
+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;
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;
try {
await apiClient.post('test');
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(RequestError);
expect(error).not.toBeInstanceOf(ApiV2Error);
});
});
+18 -1
View File
@@ -1,5 +1,6 @@
import got, { NormalizedOptions } from 'got';
import got, { HTTPError, NormalizedOptions, RequestError } from 'got';
import ApiV2Error from './ApiV2Error';
import { getAccessToken, getSessionSecret } from './user/sessionStorage';
export const apiClient = got.extend({
@@ -18,6 +19,22 @@ export const apiClient = got.extend({
}
},
],
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;
},
],
},
});
@@ -48,7 +48,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
@@ -61,7 +61,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
@@ -75,7 +75,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
@@ -93,7 +93,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
@@ -111,7 +111,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
@@ -126,11 +126,11 @@ describe(ensureCredentialsAsync, () => {
});
try {
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, true);
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 Expo servers'
'Contents of your local credentials.json for Android are not the same as credentials on EAS servers'
);
}
@@ -147,7 +147,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
Workflow.GENERIC,
CredentialsSource.AUTO,
false
);
@@ -165,7 +165,7 @@ describe(ensureCredentialsAsync, () => {
});
try {
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, false);
await ensureCredentialsAsync(provider, Workflow.GENERIC, CredentialsSource.AUTO, false);
throw new Error('ensureCredentialsAsync should throw an Error');
} catch (e) {
expect(e.message).toMatch(
@@ -186,7 +186,7 @@ describe(ensureCredentialsAsync, () => {
});
try {
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, true);
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');
@@ -204,7 +204,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
Workflow.MANAGED,
CredentialsSource.AUTO,
false
);
@@ -217,7 +217,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
Workflow.MANAGED,
CredentialsSource.AUTO,
false
);
@@ -231,7 +231,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
Workflow.MANAGED,
CredentialsSource.AUTO,
false
);
@@ -247,7 +247,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
Workflow.MANAGED,
CredentialsSource.AUTO,
false
);
@@ -265,7 +265,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
Workflow.GENERIC,
CredentialsSource.LOCAL,
false
);
@@ -283,7 +283,7 @@ describe(ensureCredentialsAsync, () => {
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
Workflow.GENERIC,
CredentialsSource.REMOTE,
false
);
@@ -2,7 +2,7 @@ import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import fs from 'fs-extra';
import log from '../../log';
import Log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { ensureValidVersions } from '../utils/updates';
@@ -11,15 +11,14 @@ export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig)
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
const buildGradleContent = await fs.readFile(buildGradlePath, 'utf8');
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
if (!AndroidConfig.Updates.isBuildGradleConfigured(buildGradleContent, projectDir, exp)) {
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir, exp);
await fs.writeFile(
buildGradlePath,
`${buildGradleContent}\n// Integration with Expo updates\n${gradleScriptApply}\n`
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);
@@ -31,7 +30,7 @@ export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig)
);
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
const result = await AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
const result = AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, result);
}
@@ -46,7 +45,7 @@ export async function syncUpdatesConfigurationAsync(
try {
await ensureUpdatesConfiguredAsync(projectDir, exp);
} catch (error) {
log.error(
Log.error(
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
);
throw error;
@@ -64,7 +63,7 @@ export async function syncUpdatesConfigurationAsync(
}
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
log.warn(
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'
);
}
@@ -72,10 +71,10 @@ export async function syncUpdatesConfigurationAsync(
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
const buildGradleContent = await fs.readFile(buildGradlePath, 'utf8');
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
if (!AndroidConfig.Updates.isBuildGradleConfigured(buildGradleContent, projectDir, exp)) {
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir, exp);
if (!AndroidConfig.Updates.isBuildGradleConfigured(projectDir, buildGradleContents)) {
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir);
throw new Error(`Missing ${gradleScriptApply} in ${buildGradlePath}`);
}
@@ -91,3 +90,21 @@ async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig)
throw new Error('Missing values in AndroidManifest.xml');
}
}
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
androidManifestPath
);
return AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidConfig.Updates.Config.RELEASE_CHANNEL
);
} catch (err) {
return null;
}
}
@@ -5,7 +5,7 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import log from '../../log';
import Log from '../../log';
import {
ensureAppIdentifierIsDefinedAsync,
getAndroidApplicationIdAsync,
@@ -24,14 +24,18 @@ function isApplicationIdValid(applicationId: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(applicationId);
}
export async function ensureApplicationIdIsValidAsync(projectDir: string) {
const applicationId = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.Android);
export async function ensureApplicationIdIsValidAsync(projectDir: string, exp: ExpoConfig) {
const applicationId = await ensureAppIdentifierIsDefinedAsync({
projectDir,
platform: Platform.ANDROID,
exp,
});
if (!isApplicationIdValid(applicationId)) {
const configDescription = getProjectConfigDescription(projectDir);
log.error(
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.`);
Log.error(`Update "android.package" in ${configDescription} and run this command again.`);
throw new Error('Invalid applicationId');
}
}
@@ -47,8 +51,8 @@ export async function configureApplicationIdAsync(
if (applicationIdFromAndroidProject && applicationIdFromConfig) {
if (applicationIdFromConfig !== applicationIdFromAndroidProject) {
log.newLine();
log.warn(
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(
@@ -56,10 +60,10 @@ export async function configureApplicationIdAsync(
exp
);
if (!hasApplicationIdInStaticConfig) {
log(`If you choose the one defined in ${configDescription} we'll automatically configure your Android project with it.
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();
Log.newLine();
const { applicationIdSource } = await promptAsync({
type: 'select',
name: 'applicationIdSource',
@@ -102,7 +106,7 @@ However, if you choose the one defined in the Android project you'll have to upd
}
} else if (!applicationIdFromAndroidProject && applicationIdFromConfig) {
// This should never happen, adding warning just in case
log.warn(
Log.warn(
'applicationId is not specified in your ./android/app/build.gradle file. Make sure your project is configured correctly before building.'
);
return;
+15 -15
View File
@@ -8,7 +8,7 @@ import AndroidCredentialsProvider, {
AndroidCredentials,
} from '../../credentials/android/AndroidCredentialsProvider';
import { createCredentialsContextAsync } from '../../credentials/context';
import log from '../../log';
import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
import { CredentialsResult, prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
@@ -22,15 +22,15 @@ export async function prepareAndroidBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig
): Promise<() => Promise<string>> {
const buildCtx = createBuildContext<Platform.Android>({
const buildCtx = createBuildContext<Platform.ANDROID>({
commandCtx,
platform: Platform.Android,
platform: Platform.ANDROID,
easConfig,
});
const { buildProfile } = buildCtx;
if (
buildProfile.workflow === Workflow.Generic &&
buildProfile.workflow === Workflow.GENERIC &&
!(await fs.pathExists(path.join(commandCtx.projectDir, 'android')))
) {
throw new Error(
@@ -41,33 +41,33 @@ export async function prepareAndroidBuildAsync(
}
if (
buildProfile.workflow === Workflow.Generic &&
buildProfile.workflow === Workflow.GENERIC &&
buildProfile.distribution === 'internal' &&
buildProfile.gradleCommand?.match(/bundle/)
) {
log.addNewLineIfNone();
log.warn(
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();
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Would you like to proceed?' });
if (!confirmed) {
log.error('Please update eas.json and come back again.');
Log.error('Please update eas.json and come back again.');
process.exit(1);
}
}
await ensureApplicationIdIsValidAsync(commandCtx.projectDir);
await ensureApplicationIdIsValidAsync(commandCtx.projectDir, commandCtx.exp);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {},
ensureCredentialsAsync: ensureAndroidCredentialsAsync,
ensureProjectConfiguredAsync: async () => {
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
if (buildCtx.buildProfile.workflow === Workflow.GENERIC) {
await validateAndSyncProjectConfigurationAsync(commandCtx.projectDir, commandCtx.exp);
}
},
@@ -75,15 +75,15 @@ This means that it will most likely produce an AAB and you will not be able to i
});
}
function shouldProvideCredentials(ctx: BuildContext<Platform.Android>): boolean {
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
return (
ctx.buildProfile.workflow === Workflow.Managed ||
(ctx.buildProfile.workflow === Workflow.Generic && !ctx.buildProfile.withoutCredentials)
ctx.buildProfile.workflow === Workflow.MANAGED ||
(ctx.buildProfile.workflow === Workflow.GENERIC && !ctx.buildProfile.withoutCredentials)
);
}
async function ensureAndroidCredentialsAsync(
ctx: BuildContext<Platform.Android>
ctx: BuildContext<Platform.ANDROID>
): Promise<CredentialsResult<AndroidCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
@@ -1,7 +1,7 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import log from '../../log';
import Log from '../../log';
import { getAndroidApplicationIdAsync } from '../../project/projectUtils';
import { gitAddAsync } from '../../utils/git';
import { ConfigureContext } from '../context';
@@ -15,7 +15,7 @@ export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void
}
await AndroidConfig.EasBuild.configureEasBuildAsync(ctx.projectDir);
await configureApplicationIdAsync(ctx.projectDir, ctx.exp, ctx.allowExperimental);
await ensureApplicationIdIsValidAsync(ctx.projectDir);
await ensureApplicationIdIsValidAsync(ctx.projectDir, ctx.exp);
const easGradlePath = AndroidConfig.EasBuild.getEasBuildGradlePath(ctx.projectDir);
await gitAddAsync(easGradlePath, { intentToAdd: true });
@@ -23,7 +23,7 @@ export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void
if (isExpoUpdatesInstalled(ctx.projectDir)) {
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
}
log.withTick('Android project configured');
Log.withTick('Android project configured');
}
export async function validateAndSyncProjectConfigurationAsync(
@@ -2,6 +2,7 @@ import {
Android,
ArchiveSource,
ArchiveSourceType,
Cache,
Job,
Workflow,
sanitizeJob,
@@ -22,13 +23,13 @@ interface JobData {
}
export async function prepareJobAsync(
ctx: BuildContext<Platform.Android>,
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Job> {
if (ctx.buildProfile.workflow === Workflow.Generic) {
if (ctx.buildProfile.workflow === Workflow.GENERIC) {
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
} else if (ctx.buildProfile.workflow === Workflow.MANAGED) {
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else {
@@ -37,8 +38,10 @@ export async function prepareJobAsync(
}
interface CommonJobProperties {
platform: Platform.Android;
platform: Platform.ANDROID;
projectArchive: ArchiveSource;
builderEnvironment: Android.BuilderEnvironment;
cache: Cache | null;
secrets: {
buildCredentials?: {
keystore: Android.Keystore;
@@ -48,7 +51,7 @@ interface CommonJobProperties {
}
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.Android>,
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Partial<CommonJobProperties>> {
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
@@ -67,11 +70,19 @@ async function prepareJobCommonAsync(
: {};
return {
platform: Platform.Android,
platform: Platform.ANDROID,
projectArchive: {
type: ArchiveSourceType.S3,
bucketKey: jobData.archiveBucketKey,
},
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: {
...(secretEnvs ? { secretEnvs } : {}),
...buildCredentials,
@@ -80,14 +91,15 @@ async function prepareJobCommonAsync(
}
async function prepareGenericJobAsync(
ctx: BuildContext<Platform.Android>,
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData,
buildProfile: AndroidGenericBuildProfile
): Promise<Partial<Android.GenericJob>> {
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.Generic,
type: Workflow.GENERIC,
gradleCommand: buildProfile.gradleCommand,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
@@ -96,15 +108,16 @@ async function prepareGenericJobAsync(
}
async function prepareManagedJobAsync(
ctx: BuildContext<Platform.Android>,
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData,
buildProfile: AndroidManagedBuildProfile
): Promise<Partial<Android.ManagedJob>> {
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.exp);
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.Managed,
type: Workflow.MANAGED,
username: accountName,
buildType: buildProfile.buildType,
releaseChannel: buildProfile.releaseChannel,
+31 -18
View File
@@ -3,9 +3,10 @@ import { CredentialsSource } from '@expo/eas-json';
import fs from 'fs-extra';
import { apiClient } from '../api';
import log from '../log';
import Log from '../log';
import { promptAsync } from '../prompts';
import { UploadType, uploadAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import { platformDisplayNames } from './constants';
import { BuildContext } from './context';
@@ -14,6 +15,7 @@ import { AnalyticsEvent, Platform, TrackingContext } from './types';
import Analytics from './utils/analytics';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
commitChangedFilesAsync,
commitPromptAsync,
isGitStatusCleanAsync,
makeProjectTarballAsync,
@@ -65,13 +67,13 @@ export async function prepareBuildRequestForPlatformAsync<
}
if (!(await isGitStatusCleanAsync())) {
log.addNewLineIfNone();
const projectType = builder.ctx.platform === Platform.Android ? 'Android' : 'Xcode';
// Currently we are only updaing runtime version durring build, but if it changes in a future
// this message should also contain more info on that (or be more generic)
await reviewAndCommitChangesAsync(`Update runtime version in the ${projectType} project`, {
nonInteractive: builder.ctx.commandCtx.nonInteractive,
});
Log.addNewLineIfNone();
await reviewAndCommitChangesAsync(
`[EAS Build] Run EAS Build for ${platformDisplayNames[builder.ctx.platform as Platform]}`,
{
nonInteractive: builder.ctx.commandCtx.nonInteractive,
}
);
}
const archiveBucketKey = await uploadProjectAsync(builder.ctx);
@@ -89,8 +91,8 @@ export async function prepareBuildRequestForPlatformAsync<
try {
return await withAnalyticsAsync(
async () => {
if (log.isDebug) {
log(`Starting ${platformDisplayNames[job.platform]} build`);
if (Log.isDebug) {
Log.log(`Starting ${platformDisplayNames[job.platform]} build`);
}
const {
data: { buildId, deprecationInfo },
@@ -112,8 +114,8 @@ export async function prepareBuildRequestForPlatformAsync<
}
);
} catch (error) {
if (error.code === 'TURTLE_DEPRECATED_JOB_FORMAT') {
log.error('EAS Build API changed, upgrade to latest expo-cli');
if (error?.expoApiV2ErrorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli');
}
throw error;
}
@@ -135,7 +137,10 @@ async function uploadProjectAsync<TPlatform extends Platform>(
projectTarball.path,
createProgressTracker({
total: projectTarball.size,
message: 'Uploading to EAS Build',
message: ratio =>
`Uploading to EAS Build (${formatBytes(projectTarball.size * ratio)} / ${formatBytes(
projectTarball.size
)})`,
completedMessage: `Uploaded to EAS`,
})
);
@@ -182,12 +187,17 @@ enum ShouldCommitChanges {
}
async function reviewAndCommitChangesAsync(
commitMessage: string,
initialCommitMessage: string,
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
): Promise<void> {
if (process.env.EAS_BUILD_AUTOCOMMIT) {
await commitChangedFilesAsync(initialCommitMessage);
Log.withTick('Committed changes.');
return;
}
if (nonInteractive) {
throw new Error(
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode to review and commit changes.'
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode or set EAS_BUILD_AUTOCOMMIT=1 in your environment.'
);
}
const { selected } = await promptAsync({
@@ -211,10 +221,13 @@ async function reviewAndCommitChangesAsync(
"Aborting, run the command again once you're ready. Make sure to commit any changes you've made."
);
} else if (selected === ShouldCommitChanges.Yes) {
await commitPromptAsync(commitMessage);
log.withTick('Committed changes.');
await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes.');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await showDiffAsync();
await reviewAndCommitChangesAsync(commitMessage, { nonInteractive, askedFirstTime: false });
await reviewAndCommitChangesAsync(initialCommitMessage, {
nonInteractive,
askedFirstTime: false,
});
}
}
+11 -11
View File
@@ -3,7 +3,7 @@ import { EasJsonReader } from '@expo/eas-json';
import fs from 'fs-extra';
import path from 'path';
import log from '../log';
import Log from '../log';
import { promptAsync } from '../prompts';
import { ensureLoggedInAsync } from '../user/actions';
import { gitAddAsync } from '../utils/git';
@@ -49,7 +49,7 @@ export async function configureAsync(options: {
hasIosNativeProject: await fs.pathExists(path.join(options.projectDir, 'ios')),
};
log.newLine();
Log.newLine();
await ensureEasJsonExistsAsync(ctx);
if (ctx.shouldConfigureAndroid) {
await configureAndroidAsync(ctx);
@@ -59,11 +59,11 @@ export async function configureAsync(options: {
}
if (!(await isGitStatusCleanAsync())) {
log.newLine();
Log.newLine();
await reviewAndCommitChangesAsync(configureCommitMessage[options.platform]);
} else {
log.newLine();
log.withTick('No changes were necessary, the project is already configured correctly.');
Log.newLine();
Log.withTick('No changes were necessary, the project is already configured correctly.');
}
}
@@ -76,7 +76,7 @@ export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<v
await reader.validateAsync();
existingEasJson = await reader.readRawAsync();
log.withTick('Validated eas.json');
Log.withTick('Validated eas.json');
// If we have already populated eas.json with the default fields for the
// platform then proceed
@@ -122,7 +122,7 @@ export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<v
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
await gitAddAsync(easJsonPath, { intentToAdd: true });
log.withTick(`${existingEasJson ? 'Updated' : 'Generated'} eas.json`);
Log.withTick(`${existingEasJson ? 'Updated' : 'Generated'} eas.json`);
}
enum ShouldCommitChanges {
@@ -132,7 +132,7 @@ enum ShouldCommitChanges {
}
async function reviewAndCommitChangesAsync(
commitMessage: string,
initialCommitMessage: string,
askedFirstTime: boolean = true
): Promise<void> {
const { selected } = await promptAsync({
@@ -152,10 +152,10 @@ async function reviewAndCommitChangesAsync(
});
if (selected === ShouldCommitChanges.Yes) {
await commitPromptAsync(commitMessage);
log.withTick('Committed changes');
await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await showDiffAsync();
await reviewAndCommitChangesAsync(commitMessage, false);
await reviewAndCommitChangesAsync(initialCommitMessage, false);
}
}
+2 -2
View File
@@ -7,6 +7,6 @@ export const platformDisplayNames = {
};
export const platformEmojis = {
[Platform.iOS]: '🍎',
[Platform.Android]: '🤖',
[Platform.IOS]: '🍎',
[Platform.ANDROID]: '🤖',
};
+4 -3
View File
@@ -1,4 +1,4 @@
import { ExpoConfig, getConfig } from '@expo/config';
import { ExpoConfig } from '@expo/config';
import { AndroidBuildProfile, EasConfig, iOSBuildProfile } from '@expo/eas-json';
import { getProjectAccountName } from '../project/projectUtils';
@@ -25,6 +25,7 @@ export interface CommandContext {
export async function createCommandContextAsync({
requestedPlatform,
profile,
exp,
projectDir,
projectId,
trackingCtx,
@@ -35,6 +36,7 @@ export async function createCommandContextAsync({
}: {
requestedPlatform: RequestedPlatform;
profile: string;
exp: ExpoConfig;
projectId: string;
projectDir: string;
trackingCtx: TrackingContext;
@@ -44,7 +46,6 @@ export async function createCommandContextAsync({
waitForBuildEnd: boolean;
}): Promise<CommandContext> {
const user = await ensureLoggedInAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = getProjectAccountName(exp, user);
const projectName = exp.slug;
@@ -77,7 +78,7 @@ export interface ConfigureContext {
hasIosNativeProject: boolean;
}
type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
type PlatformBuildProfile<T extends Platform> = T extends Platform.ANDROID
? AndroidBuildProfile
: iOSBuildProfile;
+33 -11
View File
@@ -3,7 +3,7 @@ import chalk from 'chalk';
import ora from 'ora';
import { apiClient } from '../api';
import log from '../log';
import Log from '../log';
import { sleep } from '../utils/promise';
import { prepareAndroidBuildAsync } from './android/build';
import { CommandContext } from './context';
@@ -17,17 +17,17 @@ export async function buildAsync(commandCtx: CommandContext): Promise<void> {
await ensureGitStatusIsCleanAsync(commandCtx.nonInteractive);
const scheduledBuilds = await startBuildsAsync(commandCtx);
log.newLine();
Log.newLine();
printLogsUrls(commandCtx.accountName, scheduledBuilds);
log.newLine();
Log.newLine();
if (commandCtx.waitForBuildEnd) {
const builds = await waitForBuildEndAsync(
commandCtx,
scheduledBuilds.map(i => i.buildId)
);
log.newLine();
printBuildResults(commandCtx.accountName, builds);
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
}
}
@@ -51,11 +51,11 @@ async function startBuildsAsync(
}[] = [];
if (shouldBuildAndroid) {
const sendBuildRequestAsync = await prepareAndroidBuildAsync(commandCtx, easConfig);
builds.push({ platform: Platform.Android, sendBuildRequestAsync });
builds.push({ platform: Platform.ANDROID, sendBuildRequestAsync });
}
if (shouldBuildiOS) {
const sendBuildRequestAsync = await prepareIosBuildAsync(commandCtx, easConfig);
builds.push({ platform: Platform.iOS, sendBuildRequestAsync });
builds.push({ platform: Platform.IOS, sendBuildRequestAsync });
}
return Promise.all(
builds.map(async ({ platform, sendBuildRequestAsync }) => ({
@@ -70,7 +70,7 @@ async function waitForBuildEndAsync(
buildIds: string[],
{ timeoutSec = 1800, intervalSec = 30 } = {}
): Promise<(Build | null)[]> {
log('Waiting for build to complete. You can press Ctrl+C to exit.');
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;
@@ -88,19 +88,28 @@ async function waitForBuildEndAsync(
})
);
if (builds.length === 1) {
switch (builds[0]?.status) {
const build = builds[0] as Build;
switch (build.status) {
case BuildStatus.FINISHED:
spinner.succeed('Build finished');
return builds;
case BuildStatus.IN_QUEUE:
spinner.text = 'Build queued...';
break;
case BuildStatus.CANCELED:
spinner.text = 'Build canceled';
spinner.stopAndPersist();
return builds;
case BuildStatus.IN_PROGRESS:
spinner.text = 'Build in progress...';
break;
case BuildStatus.ERRORED:
spinner.fail('Build failed');
throw new Error(`Standalone build failed!`);
if (build.error) {
return builds;
} else {
throw new Error(`Standalone build failed!`);
}
default:
spinner.warn('Unknown status.');
throw new Error(`Unknown status: ${builds} - aborting!`);
@@ -111,20 +120,26 @@ async function waitForBuildEndAsync(
return builds;
} else if (
builds.filter(build =>
build?.status ? [BuildStatus.FINISHED, BuildStatus.ERRORED].includes(build.status) : false
build?.status
? [BuildStatus.FINISHED, BuildStatus.ERRORED, BuildStatus.CANCELED].includes(
build.status
)
: false
).length === builds.length
) {
spinner.fail('Some of the builds failed');
spinner.fail('Some of the builds were canceled or failed.');
return builds;
} else {
const inQueue = builds.filter(build => build?.status === BuildStatus.IN_QUEUE).length;
const inProgress = builds.filter(build => build?.status === BuildStatus.IN_PROGRESS).length;
const errored = builds.filter(build => build?.status === BuildStatus.ERRORED).length;
const finished = builds.filter(build => build?.status === BuildStatus.FINISHED).length;
const canceled = builds.filter(build => build?.status === BuildStatus.CANCELED).length;
const 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}`),
@@ -141,3 +156,10 @@ async function waitForBuildEndAsync(
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
);
}
function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (Build | null)[]): void {
const failedBuilds = (maybeBuilds.filter(i => i) as Build[]).filter(i => i.status === 'errored');
if (failedBuilds.length > 0) {
process.exit(1);
}
}
+10 -10
View File
@@ -3,7 +3,7 @@ import { CredentialsSource } from '@expo/eas-json';
import chalk from 'chalk';
import { CredentialsProvider } from '../credentials/CredentialsProvider';
import log from '../log';
import Log from '../log';
import { confirmAsync, promptAsync } from '../prompts';
import { platformDisplayNames } from './constants';
@@ -11,7 +11,7 @@ function logCredentials(target: 'local' | 'remote', platform: Platform) {
let message = `Using ${target} ${platformDisplayNames[platform]} credentials`;
if (target === 'local') message += ` ${chalk.dim('(credentials.json)')}`;
if (target === 'remote') message += ` ${chalk.dim('(Expo server)')}`;
log.succeed(message);
Log.succeed(message);
}
async function ensureCredentialsAutoAsync(
@@ -21,24 +21,24 @@ async function ensureCredentialsAutoAsync(
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
const platform = platformDisplayNames[provider.platform];
switch (workflow) {
case Workflow.Managed:
case Workflow.MANAGED:
if (await provider.hasLocalAsync()) {
return CredentialsSource.LOCAL;
} else {
return CredentialsSource.REMOTE;
}
case Workflow.Generic: {
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 Expo 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".`
`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(
`Contents of your local credentials.json for ${platform} are not the same as credentials on Expo servers`
Log.log(
`Contents of your local credentials.json for ${platform} are not the same as credentials on EAS servers`
);
}
@@ -48,7 +48,7 @@ async function ensureCredentialsAutoAsync(
message: 'Which credentials do you want to use for this build?',
choices: [
{ title: 'Local credentials.json', value: CredentialsSource.LOCAL },
{ title: 'Credentials stored on Expo servers.', value: CredentialsSource.REMOTE },
{ title: 'Credentials stored on EAS servers.', value: CredentialsSource.REMOTE },
],
});
return select;
@@ -66,8 +66,8 @@ async function ensureCredentialsAutoAsync(
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(
} else if (Log.isDebug) {
Log.warn(
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}`
);
}
+18 -20
View File
@@ -1,24 +1,22 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import plist from '@expo/plist';
import fs from 'fs-extra';
import path from 'path';
import log from '../../log';
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, exp, xcodeProject)) {
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, xcodeProject)) {
xcodeProject = IOSConfig.Updates.ensureBundleReactNativePhaseContainsConfigurationScript(
projectDir,
exp,
xcodeProject
);
await fs.writeFile(IOSConfig.Paths.getPBXProjectPath(projectDir), xcodeProject.writeSync());
@@ -41,7 +39,7 @@ export async function syncUpdatesConfigurationAsync(
try {
await ensureUpdatesConfiguredAsync(projectDir, exp);
} catch (error) {
log.error(
Log.error(
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
);
throw error;
@@ -54,8 +52,8 @@ export async function syncUpdatesConfigurationAsync(
}
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
log.warn(
'Native project configuration is not synced with values present in you app.json, run "eas build:configure" to make sure all values are applied in the native project'
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'
);
}
}
@@ -63,7 +61,7 @@ export async function syncUpdatesConfigurationAsync(
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
const xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, exp, xcodeProject)) {
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.`);
@@ -77,13 +75,7 @@ async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig)
async function readExpoPlistAsync(projectDir: string): Promise<IOSConfig.ExpoPlist> {
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
let expoPlist = {};
if (await fs.pathExists(expoPlistPath)) {
const expoPlistContent = await fs.readFile(expoPlistPath, 'utf8');
expoPlist = plist.parse(expoPlistContent);
}
return expoPlist;
return await readPlistAsync(expoPlistPath);
}
async function writeExpoPlistAsync(
@@ -91,9 +83,15 @@ async function writeExpoPlistAsync(
expoPlist: IOSConfig.ExpoPlist
): Promise<void> {
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
const expoPlistContent = plist.build(expoPlist);
await fs.mkdirp(path.dirname(expoPlistPath));
await fs.writeFile(expoPlistPath, expoPlistContent);
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,200 @@
import { ExpoConfig } from '@expo/config';
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');
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');
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');
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;
}
+40 -21
View File
@@ -6,7 +6,7 @@ import fs from 'fs-extra';
import sortBy from 'lodash/sortBy';
import path from 'path';
import log from '../../log';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
@@ -14,20 +14,20 @@ import { Platform } from '../types';
import { ensureBundleIdentifierIsValidAsync } from './bundleIdentifer';
import { validateAndSyncProjectConfigurationAsync } from './configure';
import { ensureIosCredentialsAsync } from './credentials';
import { prepareJobAsync } from './prepareJob';
import { prepareJobAsync, sanitizedTargetName } from './prepareJob';
export async function prepareIosBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig
): Promise<() => Promise<string>> {
const buildCtx = createBuildContext<Platform.iOS>({
const buildCtx = createBuildContext<Platform.IOS>({
commandCtx,
platform: Platform.iOS,
platform: Platform.IOS,
easConfig,
});
if (
buildCtx.buildProfile.workflow === Workflow.Generic &&
buildCtx.buildProfile.workflow === Workflow.GENERIC &&
!(await fs.pathExists(path.join(commandCtx.projectDir, 'ios')))
) {
throw new Error(
@@ -37,39 +37,56 @@ export async function prepareIosBuildAsync(
);
}
let iosNativeProjectScheme: string | undefined;
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
iosNativeProjectScheme = buildCtx.buildProfile.scheme ?? (await resolveSchemeAsync(buildCtx));
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);
await ensureBundleIdentifierIsValidAsync(commandCtx.projectDir, commandCtx.exp);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {
iosNativeProjectScheme,
iosBuildScheme,
iosApplicationTarget,
},
ensureCredentialsAsync: ensureIosCredentialsAsync,
ensureProjectConfiguredAsync: async () => {
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
await validateAndSyncProjectConfigurationAsync(commandCtx.projectDir, commandCtx.exp);
}
await validateAndSyncProjectConfigurationAsync({
projectDir: commandCtx.projectDir,
exp: commandCtx.exp,
buildProfile: buildCtx.buildProfile,
});
},
prepareJobAsync,
});
}
async function resolveSchemeAsync(ctx: BuildContext<Platform.iOS>): Promise<string> {
const schemes = IOSConfig.Scheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
async function resolveSchemeAsync(ctx: BuildContext<Platform.IOS>): Promise<string> {
const schemes = IOSConfig.BuildScheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
if (schemes.length === 1) {
return schemes[0];
}
const sortedSchemes = sortBy(schemes);
log.newLine();
log(
Log.newLine();
Log.log(
`We've found multiple schemes in your Xcode project: ${chalk.bold(sortedSchemes.join(', '))}`
);
log(
Log.log(
`You can specify the scheme you want to build at ${chalk.bold(
`builds.ios.${ctx.commandCtx.profile}.scheme`
)} in eas.json.`
@@ -77,8 +94,10 @@ async function resolveSchemeAsync(ctx: BuildContext<Platform.iOS>): Promise<stri
if (ctx.commandCtx.nonInteractive) {
const withoutTvOS = sortedSchemes.filter(i => !i.includes('tvOS'));
const scheme = withoutTvOS.length > 0 ? withoutTvOS[0] : sortedSchemes[0];
log(`You've run Expo CLI in non-interactive mode, choosing the ${chalk.bold(scheme)} scheme.`);
log.newLine();
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({
@@ -87,7 +106,7 @@ async function resolveSchemeAsync(ctx: BuildContext<Platform.iOS>): Promise<stri
message: 'Which scheme would you like to build now?',
choices: sortedSchemes.map(scheme => ({ title: scheme, value: scheme })),
});
log.newLine();
Log.newLine();
return selectedScheme as string;
}
}
@@ -1,16 +1,16 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import assert from 'assert';
import chalk from 'chalk';
import fs from 'fs-extra';
import log from '../../log';
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,
@@ -21,14 +21,18 @@ function isBundleIdentifierValid(bundleIdentifier: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(bundleIdentifier);
}
export async function ensureBundleIdentifierIsValidAsync(projectDir: string) {
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.iOS);
export async function ensureBundleIdentifierIsValidAsync(projectDir: string, exp: ExpoConfig) {
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync({
projectDir,
platform: Platform.IOS,
exp,
});
if (!isBundleIdentifierValid(bundleIdentifier)) {
const configDescription = getProjectConfigDescription(projectDir);
log.error(
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.`);
Log.error(`Update "ios.bundleIdentifier" in ${configDescription} and run this command again.`);
throw new Error('Invalid bundleIdentifier');
}
}
@@ -38,14 +42,14 @@ export async function configureBundleIdentifierAsync(
exp: ExpoConfig
): Promise<void> {
const configDescription = getProjectConfigDescription(projectDir);
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdenitifer.getBundleIdentifierFromPbxproj(
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(
projectDir
);
const bundleIdentifierFromConfig = IOSConfig.BundleIdenitifer.getBundleIdentifier(exp);
const bundleIdentifierFromConfig = IOSConfig.BundleIdentifier.getBundleIdentifier(exp);
if (bundleIdentifierFromPbxproj && bundleIdentifierFromConfig) {
if (bundleIdentifierFromPbxproj !== bundleIdentifierFromConfig) {
log.addNewLineIfNone();
log.warn(
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(
@@ -53,10 +57,10 @@ export async function configureBundleIdentifierAsync(
exp
);
if (!hasBundleIdentifierInStaticConfig) {
log(`If you choose the one defined in ${configDescription} we'll automatically configure your Xcode project with it.
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();
Log.newLine();
const { bundleIdentifierSource } = await promptAsync({
type: 'select',
name: 'bundleIdentifierSource',
@@ -74,7 +78,7 @@ However, if you choose the one defined in the Xcode project you'll have to updat
});
switch (bundleIdentifierSource) {
case BundleIdentiferSource.AppJson: {
IOSConfig.BundleIdenitifer.setBundleIdentifierForPbxproj(
IOSConfig.BundleIdentifier.setBundleIdentifierForPbxproj(
projectDir,
bundleIdentifierFromConfig
);
@@ -82,7 +86,9 @@ However, if you choose the one defined in the Xcode project you'll have to updat
}
case BundleIdentiferSource.XcodeProject: {
if (hasBundleIdentifierInStaticConfig) {
await updateAppJsonConfigAsync(projectDir, exp, bundleIdentifierFromPbxproj);
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.ios = { ...config.ios, bundleIdentifier: bundleIdentifierFromPbxproj };
});
} else {
throw new Error(missingBundleIdentifierMessage(configDescription));
}
@@ -94,12 +100,14 @@ However, if you choose the one defined in the Xcode project you'll have to updat
throw new Error(missingBundleIdentifierMessage(configDescription));
} else if (bundleIdentifierFromPbxproj && !bundleIdentifierFromConfig) {
if (getConfigFilePaths(projectDir).staticConfigPath) {
await updateAppJsonConfigAsync(projectDir, exp, bundleIdentifierFromPbxproj);
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.ios = { ...config.ios, bundleIdentifier: bundleIdentifierFromPbxproj };
});
} else {
throw new Error(missingBundleIdentifierMessage(configDescription));
}
} else if (!bundleIdentifierFromPbxproj && bundleIdentifierFromConfig) {
IOSConfig.BundleIdenitifer.setBundleIdentifierForPbxproj(
IOSConfig.BundleIdentifier.setBundleIdentifierForPbxproj(
projectDir,
bundleIdentifierFromConfig
);
@@ -110,24 +118,6 @@ function missingBundleIdentifierMessage(configDescription: string): string {
return `Please define "ios.bundleIdentifier" in ${configDescription} and run "eas build:configure" again.`;
}
async function updateAppJsonConfigAsync(
projectDir: string,
exp: ExpoConfig,
newBundleIdentifier: 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,
ios: { ...rawStaticConfig.expo?.ios, bundleIdentifier: newBundleIdentifier },
};
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
exp.ios = { ...exp.ios, bundleIdentifier: newBundleIdentifier };
}
/**
* 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
+62 -17
View File
@@ -1,7 +1,14 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import { Workflow } from '@expo/eas-build-job';
import {
EasJsonReader,
VersionAutoIncrement,
iOSBuildProfile,
iOSGenericBuildProfile,
} from '@expo/eas-json';
import log from '../../log';
import Log from '../../log';
import { ConfigureContext } from '../context';
import { isExpoUpdatesInstalled } from '../utils/updates';
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
@@ -9,33 +16,71 @@ import {
configureBundleIdentifierAsync,
ensureBundleIdentifierIsValidAsync,
} from './bundleIdentifer';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasIosNativeProject) {
return;
}
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
await ensureBundleIdentifierIsValidAsync(ctx.projectDir);
const disableIosBundleIdentifierValidation = (
await new EasJsonReader(ctx.projectDir, 'all').readRawAsync()
)?.experimental?.disableIosBundleIdentifierValidation;
if (!disableIosBundleIdentifierValidation) {
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
await ensureBundleIdentifierIsValidAsync(ctx.projectDir, ctx.exp);
}
if (isExpoUpdatesInstalled(ctx.projectDir)) {
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
}
log.withTick('iOS project configured');
Log.withTick('iOS project configured');
}
export async function validateAndSyncProjectConfigurationAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdenitifer.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.'
);
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 });
}
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
}
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;
}
}
@@ -1,5 +1,5 @@
import { Workflow } from '@expo/eas-build-job';
import { CredentialsSource, DistributionType } from '@expo/eas-json';
import { CredentialsSource, iOSDistributionType } from '@expo/eas-json';
import assert from 'assert';
import { createCredentialsContextAsync } from '../../credentials/context';
@@ -13,7 +13,7 @@ import { ensureCredentialsAsync } from '../credentials';
import { Platform } from '../types';
export async function ensureIosCredentialsAsync(
ctx: BuildContext<Platform.iOS>
ctx: BuildContext<Platform.IOS>
): Promise<CredentialsResult<IosCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
@@ -27,7 +27,7 @@ export async function ensureIosCredentialsAsync(
},
workflow: ctx.buildProfile.workflow,
credentialsSource: ctx.buildProfile.credentialsSource,
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
distribution: ctx.buildProfile.distribution ?? 'store',
nonInteractive: ctx.commandCtx.nonInteractive,
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
});
@@ -37,7 +37,7 @@ interface ResolveCredentialsParams {
app: AppLookupParams;
workflow: Workflow;
credentialsSource: CredentialsSource;
distribution: DistributionType;
distribution: iOSDistributionType;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
}
@@ -68,6 +68,6 @@ export async function resolveIosCredentialsAsync(
};
}
function shouldProvideCredentials(ctx: BuildContext<Platform.iOS>): boolean {
return true;
function shouldProvideCredentials(ctx: BuildContext<Platform.IOS>): boolean {
return ctx.buildProfile.distribution !== 'simulator';
}
+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);
}
+102 -44
View File
@@ -1,16 +1,22 @@
import {
ArchiveSource,
ArchiveSourceType,
Cache,
Ios,
Job,
Workflow,
iOS,
sanitizeJob,
} from '@expo/eas-build-job';
import { iOSGenericBuildProfile, iOSManagedBuildProfile } from '@expo/eas-json';
import assert from 'assert';
import path from 'path';
import { readSecretEnvsAsync } from '../../credentials/credentialsJson/read';
import { IosCredentials } from '../../credentials/ios/IosCredentialsProvider';
import {
IosCredentials,
IosTargetCredentials,
isCredentialsMap,
readSecretEnvsAsync,
} from '../../credentials/credentialsJson/read';
import { getProjectAccountNameAsync } from '../../project/projectUtils';
import { gitRootDirectoryAsync } from '../../utils/git';
import { BuildContext } from '../context';
@@ -20,18 +26,19 @@ interface JobData {
archiveBucketKey: string;
credentials?: IosCredentials;
projectConfiguration: {
iosNativeProjectScheme?: string;
iosBuildScheme?: string;
iosApplicationTarget?: string;
};
}
export async function prepareJobAsync(
ctx: BuildContext<Platform.iOS>,
ctx: BuildContext<Platform.IOS>,
jobData: JobData
): Promise<Job> {
if (ctx.buildProfile.workflow === Workflow.Generic) {
if (ctx.buildProfile.workflow === Workflow.GENERIC) {
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
} else if (ctx.buildProfile.workflow === Workflow.MANAGED) {
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else {
@@ -40,61 +47,97 @@ export async function prepareJobAsync(
}
interface CommonJobProperties {
platform: Platform.iOS;
platform: Platform.IOS;
projectArchive: ArchiveSource;
releaseChannel: string;
builderEnvironment: Ios.BuilderEnvironment;
releaseChannel?: string;
distribution?: Ios.DistributionType;
cache: Cache | null;
secrets: {
buildCredentials?: {
provisioningProfileBase64: string;
distributionCertificate: {
dataBase64: string;
password: string;
};
};
buildCredentials: Ios.BuildCredentials;
secretEnvs?: Record<string, string>;
};
}
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.iOS>,
jobData: JobData
ctx: BuildContext<Platform.IOS>,
{
archiveBucketKey,
credentials,
targetName,
}: { archiveBucketKey: string; credentials?: IosCredentials; targetName?: string }
): Promise<Partial<CommonJobProperties>> {
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
const buildCredentials = jobData.credentials
? {
buildCredentials: {
provisioningProfileBase64: jobData.credentials.provisioningProfile,
distributionCertificate: {
dataBase64: jobData.credentials.distributionCertificate.certP12,
password: jobData.credentials.distributionCertificate.certPassword,
},
},
}
: {};
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,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.S3,
bucketKey: jobData.archiveBucketKey,
bucketKey: archiveBucketKey,
},
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: {
...(secretEnvs ? { secretEnvs } : {}),
...buildCredentials,
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>,
ctx: BuildContext<Platform.IOS>,
jobData: JobData,
buildProfile: iOSGenericBuildProfile
): Promise<Partial<iOS.GenericJob>> {
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
): Promise<Partial<Ios.GenericJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.Generic,
scheme: jobData.projectConfiguration.iosNativeProjectScheme,
...(await prepareJobCommonAsync(ctx, {
archiveBucketKey: jobData.archiveBucketKey,
credentials: jobData.credentials,
targetName: jobData.projectConfiguration.iosApplicationTarget,
})),
type: Workflow.GENERIC,
scheme: jobData.projectConfiguration.iosBuildScheme,
schemeBuildConfiguration:
buildProfile.schemeBuildConfiguration === 'Auto'
? undefined
: buildProfile.schemeBuildConfiguration,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
@@ -102,17 +145,32 @@ async function prepareGenericJobAsync(
}
async function prepareManagedJobAsync(
ctx: BuildContext<Platform.iOS>,
ctx: BuildContext<Platform.IOS>,
jobData: JobData,
buildProfile: iOSManagedBuildProfile
): Promise<Partial<iOS.ManagedJob>> {
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
): Promise<Partial<Ios.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,
...(await prepareJobCommonAsync(ctx, {
archiveBucketKey: jobData.archiveBucketKey,
credentials: jobData.credentials,
targetName: jobData.projectConfiguration.iosApplicationTarget,
})),
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');
}
}
+38 -6
View File
@@ -1,8 +1,13 @@
import { CredentialsSource, DistributionType } from '@expo/eas-json';
import { Metadata } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import { getAppIdentifierAsync } from '../project/projectUtils';
import { gitCommitHashAsync } from '../utils/git';
import { readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync } from './android/UpdatesModule';
import { BuildContext } from './context';
import { BuildMetadata, Platform } from './types';
import { readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync } from './ios/UpdatesModule';
import { Platform } from './types';
import { isExpoUpdatesInstalled } from './utils/updates';
/**
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
@@ -18,17 +23,44 @@ export async function collectMetadata<T extends Platform>(
}: {
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
}
): Promise<BuildMetadata> {
): Promise<Metadata> {
return {
trackingContext: ctx.trackingCtx,
appVersion: ctx.commandCtx.exp.version!,
cliVersion: packageJSON.version,
workflow: ctx.buildProfile.workflow,
credentialsSource,
sdkVersion: ctx.commandCtx.exp.sdkVersion,
trackingContext: ctx.trackingCtx,
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
releaseChannel: await resolveReleaseChannel(ctx),
distribution: ctx.buildProfile.distribution ?? 'store',
appName: ctx.commandCtx.exp.name,
appIdentifier:
(await getAppIdentifierAsync(ctx.commandCtx.projectDir, ctx.platform)) ?? undefined,
(await getAppIdentifierAsync({
projectDir: ctx.commandCtx.projectDir,
platform: ctx.platform,
exp: ctx.commandCtx.exp,
})) ?? undefined,
buildProfile: ctx.commandCtx.profile,
gitCommitHash: await gitCommitHashAsync(),
};
}
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';
}
+6 -70
View File
@@ -1,10 +1,5 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import {
AndroidBuildProfile,
CredentialsSource,
DistributionType,
iOSBuildProfile,
} from '@expo/eas-json';
import { Metadata, Platform, errors } from '@expo/eas-build-job';
import { AndroidBuildProfile, iOSBuildProfile } from '@expo/eas-json';
export enum RequestedPlatform {
Android = 'android',
@@ -19,6 +14,7 @@ export enum BuildStatus {
IN_PROGRESS = 'in-progress',
ERRORED = 'errored',
FINISHED = 'finished',
CANCELED = 'canceled',
}
export type TrackingContext = Record<string, string | number>;
@@ -30,7 +26,8 @@ export interface Build {
createdAt: string;
updatedAt: string;
artifacts?: BuildArtifacts;
metadata?: Partial<BuildMetadata>;
metadata?: Partial<Metadata>;
error?: errors.ExternalUserError;
}
interface BuildArtifacts {
@@ -38,68 +35,7 @@ interface BuildArtifacts {
logsUrl: string;
}
export type BuildMetadata = {
/**
* Application version (the expo.version key in app.json/app.config.js)
*/
appVersion: string;
/**
* EAS CLI version
*/
cliVersion: string;
/**
* Build workflow
* It's either 'generic' or 'managed'
*/
workflow: Workflow;
/**
* Credentials source
* Credentials could be obtained either from credential.json or Expo servers.
*/
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
/**
* Expo SDK version
* It's determined by the expo package version in package.json.
* It's undefined if the expo package is not installed for the project.
*/
sdkVersion?: string;
/**
* Release channel (for expo-updates)
* It's undefined if the expo-updates package is not installed for the project.
*/
releaseChannel?: string;
/**
* Tracking context
* It's used to track build process across different Expo services and tools.
*/
trackingContext: TrackingContext;
/**
* Distribution type
* Indicates whether this is a build for store or internal distribution.
*/
distribution: DistributionType;
/**
* App name (expo.name in app.json/app.config.js)
*/
appName?: string;
/**
* App identifier:
* - iOS builds: the bundle identifier (expo.ios.bundleIdentifier in app.json/app.config.js)
* - Android builds: the application id (expo.android.package in app.json/app.config.js)
*/
appIdentifier?: string;
};
export type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
export type PlatformBuildProfile<T extends Platform> = T extends Platform.ANDROID
? AndroidBuildProfile
: iOSBuildProfile;
@@ -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);
}
@@ -24,6 +24,8 @@ export default function formatBuild(build: Build, { accountName }: Options) {
return chalk.blue('in queue');
case BuildStatus.IN_PROGRESS:
return chalk.blue('in progress');
case BuildStatus.CANCELED:
return chalk.gray('canceled');
case BuildStatus.FINISHED:
return chalk.green('finished');
case BuildStatus.ERRORED:
@@ -41,16 +43,21 @@ export default function formatBuild(build: Build, { accountName }: Options) {
label: 'Artifact',
get value() {
if (build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS) {
return '<in progress>';
}
const url = build.artifacts?.buildUrl;
if (!url) {
return chalk.red('not found');
switch (build.status) {
case BuildStatus.IN_QUEUE:
case BuildStatus.IN_PROGRESS:
return '<in progress>';
case BuildStatus.CANCELED:
case BuildStatus.ERRORED:
return '---------';
case BuildStatus.FINISHED: {
const url = build.artifacts?.buildUrl;
return url ? url : chalk.red('not found');
}
default:
return 'unknown';
}
return url;
},
},
{ label: 'Started at', value: new Date(build.createdAt).toLocaleString() },
@@ -1,8 +1,8 @@
import { DistributionType } from '@expo/eas-json';
import { errors } from '@expo/eas-build-job';
import assert from 'assert';
import chalk from 'chalk';
import log from '../../log';
import Log, { learnMore } from '../../log';
import { platformDisplayNames, platformEmojis } from '../constants';
import { Build } from '../types';
import { getBuildLogsUrl } from './url';
@@ -22,50 +22,67 @@ export function printLogsUrls(
buildId,
account: accountName,
});
log(`Build details: ${chalk.underline(logsUrl)}`);
Log.log(`Build details: ${chalk.underline(logsUrl)}`);
} else {
builds.forEach(({ buildId, platform }) => {
const logsUrl = getBuildLogsUrl({
buildId,
account: accountName,
});
log(`${platformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`);
Log.log(`${platformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`);
});
}
}
export function printBuildResults(accountName: string, builds: (Build | null)[]): void {
Log.newLine();
if (builds.length === 1) {
const [build] = builds;
assert(build, 'Build should be defined');
printBuildResult(accountName, build);
} else {
(builds.filter(i => i) as Build[])
.filter(build => build.status === 'finished')
.forEach(build => printBuildResult(accountName, build));
(builds.filter(i => i) as Build[]).forEach(build => printBuildResult(accountName, build));
}
}
function printBuildResult(accountName: string, build: Build): void {
if (build.metadata?.distribution === DistributionType.INTERNAL) {
Log.addNewLineIfNone();
if (build.status === 'errored') {
const userError = build.error;
Log.error(
`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} build failed${
userError ? ':' : ''
}`
);
if (userError) {
printUserError(userError);
}
return;
}
if (build.status === 'canceled') {
Log.error(
`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} build was canceled`
);
return;
}
if (build.metadata?.distribution === 'internal') {
const logsUrl = getBuildLogsUrl({
buildId: build.id,
account: accountName,
});
log(
Log.log(
`${platformEmojis[build.platform]} Open this link on your ${
platformDisplayNames[build.platform]
} devices to install the app:`
);
log(`${chalk.underline(logsUrl)}`);
log.newLine();
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(`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} app:`);
log(`${chalk.underline(url)}`);
log.newLine();
Log.log(`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} app:`);
Log.log(`${chalk.underline(url)}`);
}
}
@@ -74,17 +91,24 @@ export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): voi
return;
}
if (deprecationInfo.type === 'internal') {
log.warn('This command is using API that soon will be deprecated, please update expo-cli.');
log.warn("Changes won't affect your project config.");
log.warn(deprecationInfo.message);
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
Log.warn("Changes won't affect your project config.");
Log.warn(deprecationInfo.message);
} else if (deprecationInfo.type === 'user-facing') {
log.warn('This command is using API that soon will be deprecated, please update expo-cli.');
log.warn(
'There might be some changes necessary to your project config, latest expo-cli will provide more specific error messages.'
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);
Log.warn(deprecationInfo.message);
} else {
log.warn('An unexpected warning was encountered. Please report it as a bug:');
log.warn(deprecationInfo);
Log.warn('An unexpected warning was encountered. Please report it as a bug:');
Log.warn(deprecationInfo);
}
}
export function printUserError(error: errors.ExternalUserError): void {
Log.error(error.message);
if (error.docsUrl) {
Log.error(learnMore(error.docsUrl, { dim: false }));
}
}
+56 -28
View File
@@ -3,10 +3,12 @@ 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 Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { formatBytes } from '../../utils/files';
import {
doesGitRepoExistAsync,
getGitDiffOutputAsync,
@@ -27,8 +29,8 @@ async function ensureGitRepoExistsAsync(): Promise<void> {
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.');
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?`,
@@ -40,14 +42,13 @@ async function ensureGitRepoExistsAsync(): Promise<void> {
}
await spawnAsync('git', ['init']);
log("We're going to make an initial commit for you repository.");
Log.log("We're going to make an initial commit for you repository.");
await spawnAsync('git', ['add', '-A']);
await commitPromptAsync('Initial commit');
await commitPromptAsync({ initialCommitMessage: 'Initial commit', commitAllFiles: true });
}
async function isGitStatusCleanAsync(): Promise<boolean> {
const changes = await gitStatusAsync();
const changes = await gitStatusAsync({ showUntracked: true });
return changes.length === 0;
}
@@ -55,9 +56,9 @@ async function maybeBailOnGitStatusAsync(): Promise<void> {
if (await isGitStatusCleanAsync()) {
return;
}
log.addNewLineIfNone();
log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
log(
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.`
@@ -75,9 +76,9 @@ 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.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'
)}.`
@@ -89,7 +90,7 @@ async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void
message: `Commit changes to git?`,
});
if (answer) {
await commitPromptAsync();
await commitPromptAsync({ commitAllFiles: true });
} else {
throw new Error('Please commit all changes. Aborting...');
}
@@ -99,6 +100,7 @@ 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.
@@ -109,30 +111,44 @@ async function makeProjectTarballAsync(): Promise<{ path: string; size: number }
() => {
spinner.start();
},
log.isDebug ? 1 : 1000
Log.isDebug ? 1 : 1000
);
// TODO: Possibly warn after more time about unoptimized assets.
const compressTimerLabel = 'makeProjectTarballAsync';
startTimer(compressTimerLabel);
try {
await spawnAsync(
'git',
['archive', '--format=tar.gz', '--prefix', 'project/', '-o', tarPath, 'HEAD'],
{ cwd: await gitRootDirectoryAsync() }
);
} finally {
// Stop the timer
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);
const duration = endTimer(compressTimerLabel);
if (spinner.isSpinning) {
const prettyTime = formatMilliseconds(duration);
spinner.succeed(`Compressed project files ${chalk.dim(prettyTime)}`);
spinner.fail();
}
throw err;
} finally {
await fs.remove(shallowClonePath);
}
clearTimeout(timer);
const { size } = await fs.stat(tarPath);
const duration = endTimer(compressTimerLabel);
if (spinner.isSpinning) {
const prettyTime = formatMilliseconds(duration);
spinner.succeed(
`Compressed project files ${chalk.dim(`${prettyTime} (${formatBytes(size)})`)}`
);
}
return { size, path: tarPath };
}
@@ -141,7 +157,13 @@ async function showDiffAsync() {
await gitDiffAsync({ withPager: outputTooLarge });
}
async function commitPromptAsync(initialCommitMessage?: string): Promise<void> {
async function commitPromptAsync({
initialCommitMessage,
commitAllFiles,
}: {
initialCommitMessage?: string;
commitAllFiles?: boolean;
} = {}): Promise<void> {
const { message } = await promptAsync({
type: 'text',
name: 'message',
@@ -149,8 +171,13 @@ async function commitPromptAsync(initialCommitMessage?: string): Promise<void> {
initial: initialCommitMessage,
validate: (input: string) => input !== '',
});
if (commitAllFiles) {
await spawnAsync('git', ['add', '-A']);
}
await commitChangedFilesAsync(message);
}
// Add changed files only
async function commitChangedFilesAsync(message: string): Promise<void> {
await spawnAsync('git', ['add', '-u']);
await spawnAsync('git', ['commit', '-m', message]);
}
@@ -159,6 +186,7 @@ export {
isGitStatusCleanAsync,
showDiffAsync,
commitPromptAsync,
commitChangedFilesAsync,
ensureGitRepoExistsAsync,
ensureGitStatusIsCleanAsync,
maybeBailOnGitStatusAsync,
@@ -1,6 +1,6 @@
import { Command } from '@oclif/command';
import log from '../../log';
import Log from '../../log';
import { showLoginPromptAsync } from '../../user/actions';
export default class AccountLogin extends Command {
@@ -9,8 +9,8 @@ export default class AccountLogin extends Command {
static aliases = ['login'];
async run() {
log('Log in to EAS');
Log.log('Log in to EAS');
await showLoginPromptAsync();
log('Logged in');
Log.log('Logged in');
}
}
@@ -1,6 +1,6 @@
import { Command } from '@oclif/command';
import log from '../../log';
import Log from '../../log';
import { logoutAsync } from '../../user/User';
export default class AccountLogout extends Command {
@@ -10,6 +10,6 @@ export default class AccountLogout extends Command {
async run() {
await logoutAsync();
log('Logged out');
Log.log('Logged out');
}
}
@@ -1,7 +1,7 @@
import { Command } from '@oclif/command';
import chalk from 'chalk';
import log from '../../log';
import Log from '../../log';
import { getUserAsync } from '../../user/User';
import { getActorDisplayName } from '../../user/actions';
@@ -13,9 +13,9 @@ export default class AccountView extends Command {
async run() {
const user = await getUserAsync();
if (user) {
log(chalk.green(getActorDisplayName(user)));
Log.log(chalk.green(getActorDisplayName(user)));
} else {
log.warn('Not logged in');
Log.warn('Not logged in');
process.exit(1);
}
}
@@ -4,71 +4,73 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateRelease } from '../../graphql/generated';
import log from '../../log';
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 createUpdateReleaseOnAppAsync({
export async function createUpdateBranchOnAppAsync({
appId,
releaseName,
}: {
appId: string;
releaseName: string;
}): Promise<Pick<UpdateRelease, 'id' | 'releaseName'>> {
const data = await withErrorHandlingAsync(
name,
}: CreateUpdateBranchForAppMutationVariables): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateRelease: { createUpdateReleaseForApp: Pick<UpdateRelease, 'id' | 'releaseName'> } },
{ appId: string; releaseName: string }
>(
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
gql`
mutation CreateUpdateReleaseForApp($appId: ID!, $releaseName: String!) {
updateRelease {
createUpdateReleaseForApp(appId: $appId, releaseName: $releaseName) {
mutation createUpdateBranchForApp($appId: ID!, $name: String!) {
updateBranch {
createUpdateBranchForApp(appId: $appId, name: $name) {
id
releaseName
name
}
}
}
`,
{
appId,
releaseName,
name,
}
)
.toPromise()
);
return data.updateRelease.createUpdateReleaseForApp;
const newBranch = result.updateBranch.createUpdateBranchForApp;
if (!newBranch) {
throw new Error(`Could not create branch ${name}.`);
}
return newBranch;
}
export default class ReleaseCreate extends Command {
export default class BranchCreate extends Command {
static hidden = true;
static description = 'Create a release on the current project.';
static description = 'Create a branch on the current project.';
static args = [
{
name: 'releaseName',
name: 'name',
required: false,
description: 'Name of the release to create',
description: 'Name of the branch to create',
},
];
static flags = {
json: flags.boolean({
description: 'return a json with the new release ID and name.',
description: 'return a json with the new branch ID and name.',
default: false,
}),
};
async run() {
let {
args: { releaseName },
args: { name },
flags,
} = this.parse(ReleaseCreate);
} = this.parse(BranchCreate);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
@@ -82,30 +84,30 @@ export default class ReleaseCreate extends Command {
projectName: exp.slug,
});
if (!releaseName) {
const validationMessage = 'Release name may not be empty.';
if (!name) {
const validationMessage = 'Branch name may not be empty.';
if (flags.json) {
throw new Error(validationMessage);
}
({ releaseName } = await promptAsync({
({ name } = await promptAsync({
type: 'text',
name: 'releaseName',
message: 'Please name the release:',
name: 'name',
message: 'Please name the branch:',
initial:
(await getBranchNameAsync()) || `release-${Math.random().toString(36).substr(2, 4)}`,
(await getBranchNameAsync()) || `branch-${Math.random().toString(36).substr(2, 4)}`,
validate: value => (value ? true : validationMessage),
}));
}
const newRelease = await createUpdateReleaseOnAppAsync({ appId: projectId, releaseName });
const newBranch = await createUpdateBranchOnAppAsync({ appId: projectId, name });
if (flags.json) {
log(newRelease);
Log.log(newBranch);
return;
}
log.withTick(
`️Created a new release: ${chalk.bold(newRelease.releaseName)} on project ${chalk.bold(
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 } = await 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 irreversable.`
);
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,112 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
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/actions';
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
}
}
updatedAt
message
}
}
}
}
}
`,
{
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', 'Latest update'] });
table.push(...branches.map(branch => [branch.name, formatUpdate(branch.updates[0])]));
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: Pick<Update, 'id' | 'updatedAt' | 'message'> & {
actor?: Maybe<Pick<User, 'username' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
}
): string {
if (!update) {
return 'N/A';
}
const message = update.message ? `"${update.message}" ` : '';
return `${message}(${format(update.updatedAt, 'en_US')} by ${getActorDisplayName(
update.actor as any
)})`;
}
@@ -0,0 +1,287 @@
import { getConfig } from '@expo/config';
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 flags = {
branch: flags.string({
description: 'name of the branch to publish on.',
}),
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 {
flags: {
json: jsonFlag,
branch: name,
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);
const { slug, runtimeVersion } = exp;
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}`;
}
@@ -4,36 +4,32 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { EditUpdateReleaseInput, UpdateRelease } from '../../graphql/generated';
import log from '../../log';
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 renameUpdateReleaseOnAppAsync({
async function renameUpdateBranchOnAppAsync({
appId,
currentName,
name,
newName,
}: {
appId: string;
currentName: string;
newName: string;
}): Promise<UpdateRelease> {
}: EditUpdateBranchInput): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateRelease: { editUpdateRelease: UpdateRelease } },
{
input: EditUpdateReleaseInput;
}
>(
.mutation<EditUpdateBranchMutation, EditUpdateBranchMutationVariables>(
gql`
mutation EditUpdateRelease($input: EditUpdateReleaseInput!) {
updateRelease {
editUpdateRelease(input: $input) {
mutation EditUpdateBranch($input: EditUpdateBranchInput!) {
updateBranch {
editUpdateBranch(input: $input) {
id
releaseName
name
}
}
}
@@ -41,31 +37,31 @@ async function renameUpdateReleaseOnAppAsync({
{
input: {
appId,
releaseName: currentName,
newReleaseName: newName,
name,
newName,
},
}
)
.toPromise()
);
return data.updateRelease.editUpdateRelease;
return data.updateBranch.editUpdateBranch;
}
export default class ReleaseRename extends Command {
export default class BranchRename extends Command {
static hidden = true;
static description = 'Rename a release.';
static description = 'Rename a branch.';
static flags = {
from: flags.string({
description: 'current name of the release.',
description: 'current name of the branch.',
required: false,
}),
to: flags.string({
description: 'new name of the release.',
description: 'new name of the branch.',
required: false,
}),
json: flags.boolean({
description: `return a json with the edited release's ID and name.`,
description: `return a json with the edited branch's ID and name.`,
default: false,
}),
};
@@ -73,7 +69,7 @@ export default class ReleaseRename extends Command {
async run() {
let {
flags: { json: jsonFlag, from: currentName, to: newName },
} = this.parse(ReleaseRename);
} = this.parse(BranchRename);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
@@ -94,7 +90,7 @@ export default class ReleaseRename extends Command {
({ currentName } = await promptAsync({
type: 'text',
name: 'currentName',
message: 'Please enter the current name of the release to rename:',
message: 'Please enter the current name of the branch to rename:',
validate: value => (value ? true : validationMessage),
}));
}
@@ -112,20 +108,20 @@ export default class ReleaseRename extends Command {
}));
}
const editedRelease = await renameUpdateReleaseOnAppAsync({
const editedBranch = await renameUpdateBranchOnAppAsync({
appId: projectId,
currentName: currentName!,
name: currentName!,
newName: newName!,
});
if (jsonFlag) {
log(editedRelease);
Log.log(JSON.stringify(editedBranch));
return;
}
log.withTick(
`️Renamed release from ${currentName} to ${chalk.bold(
editedRelease.releaseName
Log.withTick(
`️Renamed branch from ${currentName} to ${chalk.bold(
editedBranch.name
)} on project ${chalk.bold(`@${accountName}/${exp.slug}`)}.`
);
}
@@ -0,0 +1,176 @@
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';
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 {
firstName
}
... 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: ['createdAt', 'message', 'group', 'actor'],
wordWrap: true,
});
for (const update of updates) {
groupTable.push([
new Date(update.createdAt).toLocaleString(),
update.message,
update.group,
update.actor?.firstName,
]);
}
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,146 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import ora from 'ora';
import { platformEmojis } from '../../build/constants';
import { Platform } from '../../build/types';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
AppPlatform,
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;
}
const appPlatformMap = {
[AppPlatform.Android]: Platform.ANDROID,
[AppPlatform.Ios]: Platform.IOS,
};
function formatUnfinishedBuild(
build: Pick<Build, 'id' | 'platform' | 'status' | 'createdAt'>
): string {
const platform = platformEmojis[appPlatformMap[build.platform]];
const 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;
}
}
}
@@ -3,7 +3,7 @@ import chalk from 'chalk';
import { configureAsync } from '../../build/configure';
import { RequestedPlatform } from '../../build/types';
import log, { learnMore } from '../../log';
import Log, { learnMore } from '../../log';
import { findProjectRootAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
@@ -25,17 +25,17 @@ export default class BuildConfigure extends Command {
async run() {
const { flags } = this.parse(BuildConfigure);
log(
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();
Log.newLine();
const platform =
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
const allowExperimental = flags['allow-experimental'];
if (allowExperimental) {
log.warn(
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')}`
@@ -49,7 +49,7 @@ export default class BuildConfigure extends Command {
projectDir: (await findProjectRootAsync()) ?? process.cwd(),
});
log.newLine();
Log.newLine();
logSuccess(platform);
}
}
@@ -66,14 +66,13 @@ function logSuccess(platform: RequestedPlatform) {
storesText = 'the Apple App Store';
}
log(`🎉 Your ${platformsText} ready to build.
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',
'Learn more about other capabilities of EAS Build'
)}`);
- ${learnMore('https://docs.expo.io/build/introduction', {
learnMoreMessage: 'Learn more about other capabilities of EAS Build',
})}`);
}
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
+12 -8
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -14,7 +15,7 @@ import formatBuild 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 Log, { learnMore } from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
@@ -51,6 +52,7 @@ export default class Build extends Command {
}),
wait: flags.boolean({
default: true,
allowNo: true,
description: 'Wait for build(s) to complete',
}),
};
@@ -67,7 +69,8 @@ export default class Build extends Command {
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
@@ -75,7 +78,7 @@ export default class Build extends Command {
return;
}
const accountName = await getProjectAccountNameAsync(projectDir);
const accountName = await getProjectAccountNameAsync(exp);
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
accountName,
platform,
@@ -97,6 +100,7 @@ export default class Build extends Command {
const commandCtx = await createCommandContextAsync({
requestedPlatform: platform,
profile: flags.profile,
exp,
projectDir,
projectId,
trackingCtx,
@@ -131,11 +135,11 @@ async function ensureNoPendingBuildsExistAsync({
);
const pendingBuilds = maybePendingBuilds.filter(i => i !== null);
if (pendingBuilds.length > 0) {
log.newLine();
log.error(
Log.newLine();
Log.error(
'Your other builds are still pending. Wait for them to complete before running this command again.'
);
log.newLine();
Log.newLine();
const results = await Promise.all(
pendingBuilds.map(pendingBuild => {
return apiClient.get<{ data: BuildType }>(
@@ -148,7 +152,7 @@ async function ensureNoPendingBuildsExistAsync({
);
for (const result of results) {
log(formatBuild(result.body.data, { accountName }));
Log.log(formatBuild(result.body.data, { accountName }));
}
process.exitCode = 1;
@@ -195,7 +199,7 @@ async function ensureProjectConfiguredAsync(projectDir: string): Promise<void> {
return;
}
const confirm = await confirmAsync({
message: "This app isn't setup for building with EAS. Set it up now?",
message: 'This app is not set up for building with EAS. Set it up now?',
});
if (confirm) {
await configureAsync({
+8 -6
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import ora from 'ora';
@@ -5,7 +6,7 @@ import ora from 'ora';
import { apiClient } from '../../api';
import { Build } from '../../build/types';
import formatBuild from '../../build/utils/formatBuild';
import log from '../../log';
import Log from '../../log';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
@@ -18,7 +19,7 @@ export default class BuildList extends Command {
static flags = {
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished'] }),
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished', 'canceled'] }),
limit: flags.integer(),
};
@@ -26,9 +27,10 @@ export default class BuildList extends Command {
const { platform, status, limit = 10 } = this.parse(BuildList).flags;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const accountName = await getProjectAccountNameAsync(projectDir);
const projectName = await getProjectFullNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const accountName = await getProjectAccountNameAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const spinner = ora().start('Fetching the build list for the project…');
@@ -56,7 +58,7 @@ export default class BuildList extends Command {
.map(build => formatBuild(build, { accountName }))
.join(`\n\n${chalk.dim('———')}\n\n`);
log(`\n${list}`);
Log.log(`\n${list}`);
} else {
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
}
+9 -7
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import { HTTPError } from 'got';
import ora from 'ora';
@@ -5,7 +6,7 @@ import ora from 'ora';
import { apiClient } from '../../api';
import { Build } from '../../build/types';
import formatBuild from '../../build/utils/formatBuild';
import log from '../../log';
import Log from '../../log';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
@@ -16,15 +17,16 @@ import {
export default class BuildView extends Command {
static description = 'view a build for your project';
static args = [{ name: 'buildId' }];
static args = [{ name: 'BUILD_ID' }];
async run() {
const { buildId } = this.parse(BuildView).args;
const { BUILD_ID: buildId } = this.parse(BuildView).args;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const accountName = await getProjectAccountNameAsync(projectDir);
const projectName = await getProjectFullNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const accountName = await getProjectAccountNameAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const spinner = ora().start('Fetching the build…');
@@ -58,7 +60,7 @@ export default class BuildView extends Command {
spinner.succeed(`Showing the last build for the project ${projectName}`);
}
log(`\n${formatBuild(build, { accountName })}`);
Log.log(`\n${formatBuild(build, { accountName })}`);
} catch (e) {
const error = e as HTTPError;
+53 -55
View File
@@ -4,65 +4,56 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateChannel } from '../../graphql/generated';
import log from '../../log';
import {
CreateUpdateChannelOnAppMutation,
CreateUpdateChannelOnAppMutationVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
findProjectRootAsync,
getBranchByNameAsync,
getProjectAccountNameAsync,
getReleaseByNameAsync,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { createUpdateReleaseOnAppAsync } from '../release/create';
import { createUpdateBranchOnAppAsync } from '../branch/create';
async function createUpdateChannelOnAppAsync({
appId,
channelName,
releaseId,
branchId,
}: {
appId: string;
channelName: string;
releaseId: string;
}): Promise<UpdateChannel> {
// Point the new channel at a release with its same name.
const releaseMapping = JSON.stringify({
data: [{ releaseId, releaseMappingLogic: 'true' }],
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,
});
const data = await withErrorHandlingAsync(
return await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateChannel: { createUpdateChannelForApp: UpdateChannel } },
{ appId: string; channelName: string; releaseMapping: string }
>(
.mutation<CreateUpdateChannelOnAppMutation, CreateUpdateChannelOnAppMutationVariables>(
gql`
mutation CreateUpdateChannelForApp(
$appId: ID!
$channelName: String!
$releaseMapping: String!
) {
mutation CreateUpdateChannelOnApp($appId: ID!, $name: String!, $branchMapping: String!) {
updateChannel {
createUpdateChannelForApp(
appId: $appId
channelName: $channelName
releaseMapping: $releaseMapping
) {
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
id
channelName
releaseMapping
name
branchMapping
}
}
}
`,
{
appId,
channelName,
releaseMapping,
name: channelName,
branchMapping,
}
)
.toPromise()
);
return data.updateChannel.createUpdateChannelForApp;
}
export default class ChannelCreate extends Command {
@@ -71,7 +62,7 @@ export default class ChannelCreate extends Command {
static args = [
{
name: 'channelName',
name: 'name',
required: false,
description: 'Name of the channel to create',
},
@@ -80,14 +71,14 @@ export default class ChannelCreate extends Command {
static flags = {
json: flags.boolean({
description:
'print output as a JSON object with the new channel ID, name and release mapping.',
'print output as a JSON object with the new channel ID, name and branch mapping.',
default: false,
}),
};
async run() {
let {
args: { channelName },
args: { name: channelName },
flags: { json: jsonFlag },
} = this.parse(ChannelCreate);
@@ -95,10 +86,9 @@ export default class ChannelCreate extends Command {
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
@@ -109,47 +99,55 @@ export default class ChannelCreate extends Command {
if (jsonFlag) {
throw new Error(validationMessage);
}
({ channelName } = await promptAsync({
({ name: channelName } = await promptAsync({
type: 'text',
name: 'channelName',
name: 'name',
message: 'Please name the channel:',
validate: value => (value ? true : validationMessage),
}));
}
let releaseId: string;
let releaseMessage: string;
let branchId: string;
let branchMessage: string;
try {
const existingRelease = await getReleaseByNameAsync({
const existingBranch = await getBranchByNameAsync({
appId: projectId,
releaseName: channelName,
name: channelName,
});
releaseId = existingRelease.id;
releaseMessage = `We found a release with the same name`;
branchId = existingBranch.id;
branchMessage = `We found a branch with the same name`;
} catch (e) {
const newRelease = await createUpdateReleaseOnAppAsync({
const newBranch = await createUpdateBranchOnAppAsync({
appId: projectId,
releaseName: channelName,
name: channelName,
});
releaseId = newRelease.id;
releaseMessage = `We also went ahead and made a release with the same name`;
branchId = newBranch.id;
branchMessage = `We also went ahead and made a branch with the same name`;
}
const newChannel = await createUpdateChannelOnAppAsync({
const {
updateChannel: { createUpdateChannelForApp: newChannel },
} = await createUpdateChannelOnAppAsync({
appId: projectId,
channelName,
releaseId,
branchId,
});
if (!newChannel) {
throw new Error(
`Could not create channel with name ${channelName} on app with id ${projectId}`
);
}
if (jsonFlag) {
log(newChannel);
Log.log(JSON.stringify(newChannel));
return;
}
log.withTick(
`️Created a new channel ${chalk.bold(newChannel.channelName)} on project ${chalk.bold(
Log.withTick(
`️Created a new channel ${chalk.bold(newChannel.name)} on project ${chalk.bold(
`@${accountName}/${slug}`
)}. ${releaseMessage} and have pointed the channel at it. You can now update your app by publishing!`
)}. ${branchMessage} and have pointed the channel at it. You can now update your app by publishing!`
);
}
}
@@ -0,0 +1,195 @@
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;
}
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 });
// todo: refactor when multiple branches per channel are available
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,128 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import Table from 'cli-table3';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
GetAllChannelsForAppQuery,
GetAllChannelsForAppQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
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
updateBranches(offset: 0, limit: 1) {
id
name
updates(offset: 0, limit: 1) {
id
group
message
createdAt
actor {
id
... on User {
firstName
}
... 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;
}
const table = new Table({
head: ['channel', 'branch', 'update', 'message', 'created-at', 'actor'],
wordWrap: true,
});
for (const channel of channels) {
// TODO (cedric): refactor when multiple branches per channel are available
const branch = channel.updateBranches[0];
const update = branch.updates[0];
table.push([
channel.name,
branch.name,
update?.group,
update?.message,
update?.createdAt && new Date(update.createdAt).toLocaleString(),
update?.actor?.firstName,
]);
}
Log.log(chalk`{bold Channels for this app:}`);
Log.log(table.toString());
}
}
@@ -0,0 +1,151 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import Table from 'cli-table3';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
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';
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
updateBranches(offset: 0, limit: 1) {
id
name
updates(offset: 0, limit: 1) {
id
group
message
createdAt
actor {
id
... on User {
firstName
}
... on Robot {
firstName
}
}
}
}
}
}
}
}
`,
{ appId, channelName }
)
.toPromise()
);
}
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;
}
const table = new Table({
head: ['branch', 'update', 'message', 'created-at', 'actor'],
wordWrap: true,
});
for (const branch of channel.updateBranches) {
// TODO (cedric): refactor when multiple branches per channel are available
const update = branch.updates[0];
table.push([
branch.name,
update?.group,
update?.message,
update?.createdAt && new Date(update.createdAt).toLocaleString(),
update?.actor?.firstName,
]);
}
Log.withTick(
chalk`Channel: {bold ${channel.name}} on project {bold ${accountName}/${slug}}. Channel ID: {bold ${channel.id}}`
);
Log.log(chalk`{bold Recent update groups published on this branch:}`);
Log.log(table.toString());
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Command } from '@oclif/command';
import { runStandaloneCredentialsManagerAsync } 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 runStandaloneCredentialsManagerAsync(ctx, new SelectPlatform());
new CredentialsManager(ctx).runActionAsync(new SelectPlatform());
}
}
+11 -5
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import assert from 'assert';
import chalk from 'chalk';
@@ -6,7 +7,7 @@ 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 Log from '../../log';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
@@ -21,7 +22,8 @@ export default class BuildList extends Command {
let appleTeamIdentifier = this.parse(BuildList).flags['apple-team-id'];
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const accountName = await getProjectAccountNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
let spinner: ora.Ora;
@@ -42,7 +44,9 @@ export default class BuildList extends Command {
name: 'appleTeamIdentifier',
message: 'What Apple Team would you like to list devices for?',
choices: teams.map(team => ({
title: `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`,
title: team.appleTeamName
? `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`
: team.appleTeamIdentifier,
value: team.appleTeamIdentifier,
})),
});
@@ -71,7 +75,9 @@ export default class BuildList extends Command {
if (result?.appleDevices.length) {
const { appleTeamName, appleDevices } = result;
spinner.succeed(`Found ${appleDevices.length} devices for team ${appleTeamName}`);
spinner.succeed(
`Found ${appleDevices.length} devices for team ${appleTeamName ?? appleTeamIdentifier}`
);
const list = appleDevices
.map(device =>
@@ -79,7 +85,7 @@ export default class BuildList extends Command {
)
.join(`\n\n${chalk.dim('———')}\n\n`);
log(`\n${list}`);
Log.log(`\n${list}`);
} else {
spinner.fail(`Couldn't find any devices for the team ${appleTeamIdentifier}`);
}
+6 -4
View File
@@ -1,9 +1,10 @@
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 Log from '../../log';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
export default class DeviceView extends Command {
@@ -15,7 +16,7 @@ export default class DeviceView extends Command {
const { UDID } = this.parse(DeviceView).args;
if (!UDID) {
log(
Log.log(
`The device UDID is required to view a specific device. For example:
eas device:view 00005787-000872430189501D
@@ -29,7 +30,8 @@ If you are not sure what is the UDID of the device you are looking for, run:
}
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const accountName = await getProjectAccountNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const spinner = ora().start(`Fetching device details for ${UDID}…`);
@@ -38,7 +40,7 @@ If you are not sure what is the UDID of the device you are looking for, run:
if (device) {
spinner.succeed('Fetched device details');
log(`\n${formatDevice(device, device.appleTeam)}`);
Log.log(`\n${formatDevice(device, device.appleTeam)}`);
} else {
spinner.fail(`Couldn't find a device with the UDID ${UDID}`);
}
@@ -1,171 +0,0 @@
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 { Update } from '../../graphql/generated';
import log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
const PAGE_LIMIT = 10_000;
type TruncatedUpdate = Pick<Update, 'updateGroup' | 'updateMessage' | 'createdAt' | 'actor'>;
async function viewUpdateReleaseAsync({
appId,
releaseName,
}: {
appId: string;
releaseName: string;
}): Promise<{
id: string;
releaseName: string;
updates: TruncatedUpdate[];
}> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{
app: {
byId: {
updateReleaseByReleaseName: {
id: string;
releaseName: string;
updates: TruncatedUpdate[];
};
};
};
},
{
appId: string;
releaseName: string;
}
>(
gql`
query ViewRelease($appId: String!, $releaseName: String!) {
app {
byId(appId: $appId) {
updateReleaseByReleaseName(releaseName: $releaseName) {
id
releaseName
updates(offset: 0, limit: ${PAGE_LIMIT}) {
updateGroup
updateMessage
createdAt
actor {
... on User {
firstName
}
... on Robot {
firstName
}
}
}
}
}
}
}
`,
{
appId,
releaseName,
}
)
.toPromise()
);
return data.app.byId.updateReleaseByReleaseName;
}
export default class ReleaseView extends Command {
static hidden = true;
static description = 'View a release.';
static args = [
{
name: 'releaseName',
required: false,
description: 'Name of the release to view',
},
];
static flags = {
json: flags.boolean({
description: `return a json with the release's ID name and recent update groups.`,
default: false,
}),
};
async run() {
let {
args: { releaseName },
flags: { json: jsonFlag },
} = this.parse(ReleaseView);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
if (!releaseName) {
const validationMessage = 'Release name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ releaseName } = await promptAsync({
type: 'text',
name: 'releaseName',
message: 'Please enter the name of the release to view:',
validate: value => (value ? true : validationMessage),
}));
}
const UpdateRelease = await viewUpdateReleaseAsync({
appId: projectId,
releaseName,
});
const updates = Object.values(groupBy(UpdateRelease.updates, u => u.updateGroup)).map(
updateGroup => updateGroup[0]
);
if (jsonFlag) {
log({ ...UpdateRelease, updates });
return;
}
const updateGroupTable = new Table({
head: ['createdAt', 'updateMessage', 'updateGroup', 'actor'],
wordWrap: true,
});
for (const update of updates) {
updateGroupTable.push([
new Date(update.createdAt).toLocaleString(),
update.updateMessage,
update.updateGroup,
update.actor?.firstName,
]);
}
log.withTick(
`️Release: ${chalk.bold(UpdateRelease.releaseName)} on project ${chalk.bold(
`@${accountName}/${slug}`
)}. Release ID: ${chalk.bold(UpdateRelease.id)}`
);
log(chalk.bold('Recent update groups published on this release:'));
log(updateGroupTable.toString());
}
}
@@ -0,0 +1,154 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import Log from '../../log';
import { 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 { ensureLoggedInAsync, getActorDisplayName } from '../../user/actions';
export enum EnvironmentSecretTargetLocation {
ACCOUNT = 'account',
PROJECT = 'project',
}
export default class EnvironmentSecretCreate extends Command {
static hidden = true;
static description = 'Create an environment secret on the current project or owner account.';
static args = [
{
name: 'target',
required: false,
description: 'Target location for the secret',
options: [EnvironmentSecretTargetLocation.ACCOUNT, EnvironmentSecretTargetLocation.PROJECT],
},
{
name: 'name',
required: false,
description: 'Name of the secret',
},
{
name: 'value',
required: false,
description: 'Value of the secret',
},
];
async run() {
const user = await ensureLoggedInAsync();
let {
args: { name, value: secretValue, target },
} = 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 (!target) {
const validationMessage = 'Secret target may not be empty.';
({ target } = await promptAsync({
type: 'select',
name: 'target',
message: 'Where should this secret be used:',
choices: [
{ title: 'Account-wide', value: EnvironmentSecretTargetLocation.ACCOUNT },
{ title: 'Project-specific', value: EnvironmentSecretTargetLocation.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.';
}
if (value.match(/^\w+$/)) {
return 'Names may contain only letters, numbers, and underscores.';
}
return true;
},
}));
}
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 (target === EnvironmentSecretTargetLocation.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 (target === EnvironmentSecretTargetLocation.ACCOUNT) {
const ownerAccount = findAccountByName(user.accounts, accountName);
if (!ownerAccount) {
Log.warn(
`Your account (${getActorDisplayName(user)}) 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,61 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import Log from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { toggleConfirmAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
export default class EnvironmentSecretDelete extends Command {
static hidden = true;
static description = `
Delete an environment secret by ID.
Unsure where to find the secret's ID? Run ${'`eas secrets:list`'}
`;
static args = [
{
name: 'id',
required: true,
description: `ID of the secret to delete`,
},
];
async run() {
await ensureLoggedInAsync();
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
const {
args: { id },
} = this.parse(EnvironmentSecretDelete);
Log.addNewLineIfNone();
Log.warn(
`You are about to permamently delete secret with id: "${id}".\nThis action is irreversable.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Canceled deletion of secret with id: "${id}".`);
process.exit(1);
}
await EnvironmentSecretMutation.delete(id);
Log.withTick(`️Deleted secret with id "${id}".`);
}
}
@@ -0,0 +1,69 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import Table from 'cli-table3';
import dateFormat from 'dateformat';
import { EnvironmentSecretFragment } from '../../graphql/generated';
import { EnvironmentSecretsQuery } from '../../graphql/queries/EnvironmentSecretsQuery';
import Log from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { EnvironmentSecretTargetLocation } from './create';
export default class EnvironmentSecretsList 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 [accountSecrets, appSecrets] = await Promise.all([
EnvironmentSecretsQuery.byAcccountNameAsync(projectAccountName),
EnvironmentSecretsQuery.byAppFullNameAsync(projectFullName),
]);
const secrets = [
...appSecrets.map(s => ({ ...s, target: EnvironmentSecretTargetLocation.PROJECT })),
...accountSecrets.map(s => ({ ...s, target: EnvironmentSecretTargetLocation.ACCOUNT })),
] as (EnvironmentSecretFragment & { target: EnvironmentSecretTargetLocation })[];
const table = new Table({
head: ['Name', 'Target', 'Updated at', 'ID'],
wordWrap: true,
});
for (const secret of secrets) {
const { name, createdAt: updatedAt, target, id } = secret;
table.push([name, target, dateFormat(updatedAt, 'mmm dd HH:MM:ss'), id]);
}
Log.log(chalk`{bold Secrets for this account and project:}`);
Log.log(table.toString());
}
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import { learnMore } from '../log';
@@ -168,7 +169,8 @@ export default class BuildSubmit extends Command {
const platform = platformFromParams ?? (await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
@@ -1,78 +1,13 @@
import log from '../log';
import { Context } from './context';
export interface CredentialsManager {
runActionAsync(action: Action): Promise<void>;
pushNextAction(action: Action): void;
popAction(): Action | null;
export interface Action<T = void> {
runAsync(manager: CredentialsManager, ctx: Context): Promise<T>;
}
export interface Action {
runAsync(manager: CredentialsManager, ctx: Context): Promise<void>;
}
export class CredentialsManager {
constructor(private ctx: Context) {}
export class QuitError extends Error {}
export async function runStandaloneCredentialsManagerAsync(
ctx: Context,
startAction: Action
): Promise<void> {
const manager = new CredentialsManagerImpl(ctx, startAction);
await manager.runManagerAsync(true);
}
export async function runCredentialsManagerAsync(ctx: Context, startAction: Action): Promise<void> {
const manager = new CredentialsManagerImpl(ctx, startAction);
await manager.runManagerAsync();
}
class CredentialsManagerImpl implements CredentialsManager {
private actionStack: Action[] = [];
constructor(private ctx: Context, startAction: Action) {
this.actionStack.push(startAction);
}
public pushNextAction(action: Action): void {
this.actionStack.push(action);
}
public popAction(): Action | null {
return this.actionStack.pop() ?? null;
}
public async runActionAsync(action: Action) {
await new CredentialsManagerImpl(this.ctx, action).doRunManagerAsync();
}
public async runManagerAsync(isStandaloneManager: boolean = false): Promise<void> {
try {
await this.doRunManagerAsync(isStandaloneManager);
} catch (error) {
if (error instanceof QuitError) {
return;
}
throw error;
}
}
private async doRunManagerAsync(isStandaloneManager: boolean = false): Promise<void> {
while (true) {
try {
const currentAction = this.popAction();
if (!currentAction) {
return;
}
await currentAction.runAsync(this, this.ctx);
} catch (error) {
if (!isStandaloneManager || error instanceof QuitError) {
throw error;
} else {
log.error(error);
}
}
}
public async runActionAsync<T>(action: Action<T>): Promise<T> {
return await action.runAsync(this, this.ctx);
}
}
@@ -0,0 +1,17 @@
export function getAppstoreMock() {
return {
ensureAuthenticatedAsync: jest.fn(),
ensureBundleIdExistsAsync: jest.fn(),
listDistributionCertificatesAsync: jest.fn(),
createDistributionCertificateAsync: jest.fn(),
revokeDistributionCertificateAsync: jest.fn(),
listPushKeysAsync: jest.fn(),
createPushKeyAsync: jest.fn(),
revokePushKeyAsync: jest.fn(),
useExistingProvisioningProfileAsync: jest.fn(),
listProvisioningProfilesAsync: jest.fn(),
createProvisioningProfileAsync: jest.fn(),
revokeProvisioningProfileAsync: jest.fn(),
createOrReuseAdhocProvisioningProfileAsync: jest.fn(),
};
}
@@ -3,17 +3,23 @@ import merge from 'lodash/merge';
import { CredentialsManager } from '../CredentialsManager';
import { Context } from '../context';
import { getAndroidApiMockWithoutCredentials } from './fixtures-android';
import { getAppstoreMock } from './fixtures-appstore';
import { testAppJson, testUsername } from './fixtures-constants';
import { getIosApiMockWithoutCredentials } from './fixtures-ios';
import { getNewIosApiMockWithoutCredentials } from './fixtures-new-ios';
export function createCtxMock(mockOverride: Record<string, any> = {}): Context {
const defaultMock = {
ios: getIosApiMockWithoutCredentials(),
newIos: getNewIosApiMockWithoutCredentials(),
android: getAndroidApiMockWithoutCredentials(),
appStore: jest.fn(),
appStore: getAppstoreMock(),
ensureAppleCtx: jest.fn(),
ensureProjectContext: jest.fn(),
user: {
__typename: 'User',
username: testUsername,
accounts: [{ id: 'test-account-id', name: testUsername }],
},
hasAppleCtx: jest.fn(() => true),
hasProjectContext: true,
@@ -29,10 +35,6 @@ export function createManagerMock(mockOverride: Record<string, any> = {}): Crede
runActionAsync: jest.fn(() => {
throw new Error('unexpected call'); // should be implemented in test directly
}),
pushNextAction: jest.fn(() => {
throw new Error('unexpected call'); // should be implemented in test directly
}),
popAction: jest.fn(),
},
mockOverride
);
@@ -1,3 +1,9 @@
import {
AppFragment,
AppleAppIdentifierFragment,
AppleDistributionCertificateFragment,
AppleTeamFragment,
} from '../../graphql/generated';
import {
DistributionCertificate,
DistributionCertificateStoreInfo,
@@ -34,6 +40,61 @@ export const testProvisioningProfileFromApple: ProvisioningProfileStoreInfo = {
};
export const testProvisioningProfilesFromApple = [testProvisioningProfileFromApple];
export const testAppleTeamFragment: AppleTeamFragment = {
id: 'test-apple-team-id-1',
appleTeamIdentifier: 'test-apple-team-identifier',
};
export const testAppleAppIdentifierFragment: AppleAppIdentifierFragment = {
id: 'test-apple-app-identifier-id-1',
bundleIdentifier: 'foo.bar.com',
};
export const testAppFragment: AppFragment = {
id: 'test-app-id',
fullName: '@testuser/testapp',
slug: 'testapp',
};
export const testDistCertFragmentNoDependencies: AppleDistributionCertificateFragment = {
id: 'test-dist-cert-id-1',
certificateP12: 'Y2VydHAxMg==',
certificatePassword: 'test-password',
serialNumber: 'test-serial',
developerPortalIdentifier: 'test-developer-identifier',
validityNotAfter: new Date(),
validityNotBefore: new Date(),
updatedAt: new Date(),
appleTeam: { ...testAppleTeamFragment },
iosAppBuildCredentialsList: [],
};
export const testDistCertFragmentOneDependency: AppleDistributionCertificateFragment = {
id: 'test-dist-cert-id-1',
certificateP12: 'Y2VydHAxMg==',
certificatePassword: 'test-password',
serialNumber: 'test-serial',
developerPortalIdentifier: 'test-developer-identifier',
validityNotAfter: new Date(),
validityNotBefore: new Date(),
updatedAt: new Date(),
appleTeam: { ...testAppleTeamFragment },
iosAppBuildCredentialsList: [
{
id: 'test-build-credentials-id',
iosAppCredentials: {
id: 'test-app-credentials-id',
appleAppIdentifier: { ...testAppleAppIdentifierFragment },
app: { ...testAppFragment },
},
provisioningProfile: {
id: 'test-provisioning-profile-id',
developerPortalIdentifier: 'test-developer-identifier',
},
},
],
};
export const testDistCert: DistributionCertificate = {
certP12: 'Y2VydHAxMg==',
certPassword: 'test-password',
@@ -0,0 +1,141 @@
import {
DistributionCertificate,
DistributionCertificateStoreInfo,
ProvisioningProfile,
ProvisioningProfileStoreInfo,
PushKey,
PushKeyStoreInfo,
} from '../ios/appstore/Credentials.types';
import { IosDistCredentials, IosPushCredentials } from '../ios/credentials';
import { testProvisioningProfileBase64 } from './fixtures-base64-data';
import { testBundleIdentifier, testExperienceName } from './fixtures-constants';
const today = new Date();
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
export const testAppleTeam = {
id: 'test-team-id',
};
export const testProvisioningProfile: ProvisioningProfile = {
provisioningProfileId: 'test-id',
provisioningProfile: testProvisioningProfileBase64,
teamId: 'id',
};
export const testProvisioningProfiles = [testProvisioningProfile];
export const testProvisioningProfileFromApple: ProvisioningProfileStoreInfo = {
name: 'test-name',
status: 'Active',
expires: tomorrow.getTime(),
distributionMethod: 'test',
certificates: [],
provisioningProfileId: testProvisioningProfile.provisioningProfileId,
provisioningProfile: testProvisioningProfile.provisioningProfile,
teamId: 'id',
};
export const testProvisioningProfilesFromApple = [testProvisioningProfileFromApple];
export const testDistCert: DistributionCertificate = {
certP12: 'Y2VydHAxMg==',
certPassword: 'test-password',
distCertSerialNumber: 'test-serial',
teamId: 'test-team-id',
};
export const testIosDistCredential: IosDistCredentials = {
id: 1,
type: 'dist-cert',
...testDistCert,
};
export const testIosDistCredentials = [testIosDistCredential];
export const testDistCertFromApple: DistributionCertificateStoreInfo = {
id: 'test-id',
name: 'test-name',
status: 'Active',
created: today.getTime(),
expires: tomorrow.getTime(),
ownerName: 'test-owner',
ownerId: 'test-id',
serialNumber: testIosDistCredential.distCertSerialNumber as string,
};
export const testDistCertsFromApple = [testDistCertFromApple];
export const testPushKey: PushKey = {
apnsKeyP8: 'test-p8',
apnsKeyId: 'test-key-id',
teamId: 'test-team-id',
};
export const testIosPushCredential: IosPushCredentials = {
id: 2,
type: 'push-key',
...testPushKey,
};
export const testIosPushCredentials = [testIosPushCredential];
export const testPushKeyFromApple: PushKeyStoreInfo = {
id: testIosPushCredential.apnsKeyId,
name: 'test-name',
};
export const testPushKeysFromApple = [testPushKeyFromApple];
export const testLegacyPushCert = {
pushId: 'test-push-id',
pushP12: 'test-push-p12',
pushPassword: 'test-push-password',
};
export const testAppCredential = {
experienceName: testExperienceName,
bundleIdentifier: testBundleIdentifier,
distCredentialsId: testIosDistCredential.id,
pushCredentialsId: testIosPushCredential.id,
credentials: {
...testProvisioningProfile,
},
};
export const testAllCredentialsForApp = {
...testAppCredential,
pushCredentials: testPushKey,
distCredentials: testDistCert,
};
export const testAppCredentials = [testAppCredential];
export const testAllCredentials = {
userCredentials: [...testIosDistCredentials, ...testIosPushCredentials],
appCredentials: testAppCredentials,
};
export function getNewIosApiMockWithoutCredentials() {
return {
getAppAsync: jest.fn(),
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: jest.fn(),
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
getDevicesForAppleTeamAsync: jest.fn(),
createProvisioningProfileAsync: jest.fn(),
getProvisioningProfileAsync: jest.fn(),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfilesAsync: jest.fn(),
getDistributionCertificateForAppAsync: jest.fn(),
getDistributionCertificatesForAccountAsync: jest.fn(),
createDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
};
}
export function getNewIosApiMockWithCredentials() {
return {
getAppAsync: jest.fn(),
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: jest.fn(),
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
getDevicesForAppleTeamAsync: jest.fn(),
createProvisioningProfileAsync: jest.fn(),
getProvisioningProfileAsync: jest.fn(),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfilesAsync: jest.fn(),
getDistributionCertificateForAppAsync: jest.fn(),
getDistributionCertificatesForAccountAsync: jest.fn(),
createDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
};
}
@@ -1,8 +1,8 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import log from '../../log';
import { runCredentialsManagerAsync } from '../CredentialsManager';
import Log from '../../log';
import { CredentialsManager } from '../CredentialsManager';
import { CredentialsProvider } from '../CredentialsProvider';
import { Context } from '../context';
import * as credentialsJsonReader from '../credentialsJson/read';
@@ -24,7 +24,7 @@ interface Options {
}
export default class AndroidCredentialsProvider implements CredentialsProvider {
public readonly platform = Platform.Android;
public readonly platform = Platform.ANDROID;
constructor(private ctx: Context, private options: Options) {}
@@ -46,7 +46,7 @@ export default class AndroidCredentialsProvider implements CredentialsProvider {
const rawCredentialsJson = await credentialsJsonReader.readRawAsync(this.ctx.projectDir);
return !!rawCredentialsJson?.android;
} catch (err) {
log.error(err); // malformed json
Log.error(err); // malformed json
return false;
}
}
@@ -85,10 +85,12 @@ export default class AndroidCredentialsProvider implements CredentialsProvider {
}
private async getRemoteAsync(): Promise<AndroidCredentials> {
await runCredentialsManagerAsync(this.ctx, new SetupBuildCredentials(this.projectFullName));
await new CredentialsManager(this.ctx).runActionAsync(
new SetupBuildCredentials(this.projectFullName)
);
const keystore = await this.ctx.android.fetchKeystoreAsync(this.projectFullName);
if (!keystore) {
throw new Error('Unable to set up credentials, failed to fetch keystore from Expo servers');
throw new Error('Unable to set up credentials, failed to fetch keystore from EAS servers');
}
return { keystore };
}
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import log from '../../../log';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { maybeRenameExistingFileAsync } from '../../../utils/files';
import { Action, CredentialsManager } from '../../CredentialsManager';
@@ -18,7 +18,7 @@ export class DownloadKeystore implements Action {
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const keystore = await ctx.android.fetchKeystoreAsync(this.projectFullName);
if (!keystore) {
log.warn('There is no valid Keystore defined for this app');
Log.warn('There is no valid Keystore defined for this app');
return;
}
@@ -34,11 +34,11 @@ export class DownloadKeystore implements Action {
await maybeRenameExistingFileAsync(ctx.projectDir, keystorePath);
log(`Saving Keystore to ${keystorePath}`);
Log.log(`Saving Keystore to ${keystorePath}`);
await fs.writeFile(keystorePath, keystore.keystore, 'base64');
if (displayCredentials) {
log(`Keystore credentials
Log.log(`Keystore credentials
Keystore password: ${chalk.bold(keystore.keystorePassword)}
Key alias: ${chalk.bold(keystore.keyAlias)}
Key password: ${chalk.bold(keystore.keyPassword)}
@@ -54,7 +54,7 @@ export class BackupKeystore implements Action {
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
if (await ctx.android.fetchKeystoreAsync(this.projectFullName)) {
log.warn('Backing up your old Android Keystore now...');
Log.warn('Backing up your old Android Keystore now...');
await manager.runActionAsync(
new DownloadKeystore(this.projectFullName, {
displayCredentials: true,
@@ -1,4 +1,4 @@
import log from '../../../log';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
@@ -17,6 +17,6 @@ export class UpdateFcmKey implements Action {
]);
await ctx.android.updateFcmKeyAsync(this.experienceName, fcmApiKey);
log.succeed('Updated successfully');
Log.succeed('Updated successfully');
}
}
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import log from '../../../log';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
@@ -11,7 +11,7 @@ export class RemoveKeystore implements Action {
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
if (!(await ctx.android.fetchKeystoreAsync(this.projectFullName))) {
log.warn('There is no Keystore defined for this app.');
Log.warn('There is no Keystore defined for this app.');
return;
}
@@ -31,29 +31,29 @@ export class RemoveKeystore implements Action {
await manager.runActionAsync(new BackupKeystore(this.projectFullName));
await ctx.android.removeKeystoreAsync(this.projectFullName);
log.succeed('Keystore removed');
Log.succeed('Keystore removed');
}
}
displayWarning() {
log.newLine();
log.warn(
Log.newLine();
Log.warn(
`Clearing your Android build credentials from our build servers is a ${chalk.bold(
'PERMANENT and IRREVERSIBLE action.'
)}`
);
log.warn(
Log.warn(
chalk.bold(
'Android Keystore must be identical to the one previously used to submit your app to the Google Play Store.'
)
);
log.warn(
Log.warn(
'Please read https://docs.expo.io/distribution/building-standalone-apps/#if-you-choose-to-build-for-android for more info before proceeding.'
);
log.newLine();
log.warn(
Log.newLine();
Log.warn(
chalk.bold('Your Keystore will be backed up to your current directory if you continue.')
);
log.newLine();
Log.newLine();
}
}
@@ -1,4 +1,4 @@
import log from '../../../log';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { readAndroidCredentialsAsync } from '../../credentialsJson/read';
@@ -16,7 +16,7 @@ export class SetupBuildCredentials implements Action {
throw new Error('Generating a new Keystore is not supported in --non-interactive mode');
}
manager.pushNextAction(new UpdateKeystore(this.projectFullName));
await manager.runActionAsync(new UpdateKeystore(this.projectFullName));
}
}
@@ -31,7 +31,7 @@ export class SetupBuildCredentialsFromCredentialsJson implements Action {
try {
localCredentials = await readAndroidCredentialsAsync(ctx.projectDir);
} catch (error) {
log.error(
Log.error(
'Reading credentials from credentials.json failed. Make sure this file is correct and all credentials are present there.'
);
throw error;
@@ -40,6 +40,6 @@ export class SetupBuildCredentialsFromCredentialsJson implements Action {
await validateKeystoreAsync(localCredentials.keystore);
}
await ctx.android.updateKeystoreAsync(this.projectFullName, localCredentials.keystore);
log.succeed('Keystore updated');
Log.succeed('Keystore updated');
}
}
@@ -1,4 +1,4 @@
import log from '../../../log';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { updateAndroidCredentialsAsync } from '../../credentialsJson/update';
@@ -7,10 +7,10 @@ export class UpdateCredentialsJson implements Action {
constructor(private projectFullName: string) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
log('Updating Android credentials in credentials.json');
Log.log('Updating Android credentials in credentials.json');
await updateAndroidCredentialsAsync(ctx);
log.succeed(
'Android part of your local credentials.json is synced with values stored on Expo servers.'
Log.succeed(
'Android part of your local credentials.json is synced with values stored on EAS servers.'
);
}
}
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import log from '../../../log';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
@@ -17,7 +17,7 @@ export class UpdateKeystore implements Action {
const keystore = await this.provideOrGenerateAsync();
await ctx.android.updateKeystoreAsync(this.projectFullName, keystore);
log.succeed('Keystore updated');
Log.succeed('Keystore updated');
}
private async provideOrGenerateAsync(): Promise<Keystore> {
@@ -30,17 +30,17 @@ export class UpdateKeystore implements Action {
}
private displayWarning() {
log.newLine();
log.warn(
Log.newLine();
Log.warn(
`Updating your Android build credentials will remove previous version from our servers, this is a ${chalk.bold(
'PERMANENT and IRREVERSIBLE action.'
)}`
);
log.warn(
Log.warn(
chalk.bold(
'Android Keystore must be identical to the one previously used to submit your app to the Google Play Store.'
)
);
log.newLine();
Log.newLine();
}
}
@@ -38,7 +38,7 @@ describe('run RemoveKeystore when keystore does exist', () => {
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).toHaveBeenCalledTimes(1);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
@@ -59,7 +59,6 @@ describe('run RemoveKeystore when keystore does exist', () => {
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).toHaveBeenCalledTimes(1);
expect(prompts).toHaveBeenCalledTimes(1);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
@@ -83,7 +82,7 @@ describe('run RemoveKeystore when keystore does exist', () => {
}
expect(prompts).not.toHaveBeenCalled();
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
});
@@ -100,7 +99,7 @@ describe('run RemoveKeystore when keystore does not exist', () => {
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).not.toHaveBeenCalled();
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
@@ -115,7 +114,7 @@ describe('run RemoveKeystore when keystore does not exist', () => {
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).not.toHaveBeenCalled();
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
@@ -38,7 +38,7 @@ describe('run SetupBuildCredentials when www has valid credentials', () => {
await new SetupBuildCredentials(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).not.toHaveBeenCalled();
});
@@ -54,7 +54,7 @@ describe('run SetupBuildCredentials when www has valid credentials', () => {
await new SetupBuildCredentials(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).not.toHaveBeenCalled();
});
@@ -69,12 +69,12 @@ describe('run SetupBuildCredentials when www has no credentials', () => {
});
const manager = createManagerMock();
(manager.pushNextAction as any).mockImplementationOnce((action: Action) => {
(manager.runActionAsync as any).mockImplementationOnce((action: Action) => {
expect(action).toBeInstanceOf(UpdateKeystore);
});
await new SetupBuildCredentials(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).toHaveBeenCalledTimes(1);
expect(manager.runActionAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(0); // will be called in UpdateKeystore
});
@@ -42,7 +42,7 @@ describe('run UpdateKeystore when keystore exist on www', () => {
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).toHaveBeenCalledTimes(1);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
@@ -66,7 +66,7 @@ describe('run UpdateKeystore when keystore exist on www', () => {
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).toHaveBeenCalledTimes(5);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
@@ -86,7 +86,7 @@ describe('run UpdateKeystore when keystore does not exist on www', () => {
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).toHaveBeenCalledTimes(1);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
@@ -110,7 +110,7 @@ describe('run UpdateKeystore when keystore does not exist on www', () => {
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.pushNextAction).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).toHaveBeenCalledTimes(5);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
@@ -5,7 +5,7 @@ import path from 'path';
import tempy from 'tempy';
import { v4 as uuidv4 } from 'uuid';
import log from '../../../log';
import Log from '../../../log';
import { getTmpDirectory } from '../../../utils/paths';
import { Keystore } from '../credentials';
@@ -20,9 +20,9 @@ export async function keytoolCommandExistsAsync(): Promise<boolean> {
async function ensureKeytoolCommandExistsAsync(): Promise<void> {
if (!(await keytoolCommandExistsAsync())) {
log.error('keytool is required to run this command, make sure you have it installed?');
log.warn('keytool is a part of OpenJDK: https://openjdk.java.net/');
log.warn('Also make sure that keytool is in your PATH after installation.');
Log.error('keytool is required to run this command, make sure you have it installed?');
Log.warn('keytool is a part of OpenJDK: https://openjdk.java.net/');
Log.warn('Also make sure that keytool is in your PATH after installation.');
throw new Error('keytool not found');
}
}
@@ -70,12 +70,12 @@ export async function logKeystoreHashesAsync(keystore: Keystore, linePrefix: str
const googleHash = crypto.createHash('sha1').update(data).digest('hex').toUpperCase();
const googleHash256 = crypto.createHash('sha256').update(data).digest('hex').toUpperCase();
const fbHash = crypto.createHash('sha1').update(data).digest('base64');
log(
Log.log(
`${linePrefix}Google Certificate Fingerprint: ${googleHash.replace(/(.{2}(?!$))/g, '$1:')}`
);
log(`${linePrefix}Google Certificate Hash (SHA-1): ${googleHash}`);
log(`${linePrefix}Google Certificate Hash (SHA-256): ${googleHash256}`);
log(`${linePrefix}Facebook Key Hash: ${fbHash}`);
Log.log(`${linePrefix}Google Certificate Hash (SHA-1): ${googleHash}`);
Log.log(`${linePrefix}Google Certificate Hash (SHA-256): ${googleHash256}`);
Log.log(`${linePrefix}Facebook Key Hash: ${fbHash}`);
}
async function createKeystoreAsync(credentials: {
@@ -1,34 +1,34 @@
import chalk from 'chalk';
import log from '../../../log';
import Log from '../../../log';
import { AndroidCredentials } from '../credentials';
import { keytoolCommandExistsAsync, logKeystoreHashesAsync } from '../utils/keystore';
export async function printAndroidCredentials(credentialsList: AndroidCredentials[]) {
log(chalk.bold('Available Android credentials'));
log.newLine();
Log.log(chalk.bold('Available Android credentials'));
Log.newLine();
for (const credentials of credentialsList) {
await printAndroidAppCredentials(credentials);
}
}
export async function printAndroidAppCredentials(credentials: AndroidCredentials) {
log(chalk.green(credentials.experienceName));
log(chalk.bold(' Keystore hashes'));
Log.log(chalk.green(credentials.experienceName));
Log.log(chalk.bold(' Keystore hashes'));
if (credentials.keystore?.keystore) {
if (await keytoolCommandExistsAsync()) {
try {
await logKeystoreHashesAsync(credentials.keystore, ' ');
} catch (error) {
log.error(' Failed to parse the Keystore', error);
Log.error(' Failed to parse the Keystore', error);
}
} else {
log.warn(' keytool is required to calulate Keystore hashes');
Log.warn(' keytool is required to calulate Keystore hashes');
}
} else {
log(' -----------------------');
Log.log(' -----------------------');
}
log(chalk.bold(' Push Notifications credentials'));
log(' FCM API Key: ', credentials.pushCredentials?.fcmApiKey ?? '---------------------');
log('\n');
Log.log(chalk.bold(' Push Notifications credentials'));
Log.log(' FCM API Key: ', credentials.pushCredentials?.fcmApiKey ?? '---------------------');
Log.log('\n');
}
+6 -6
View File
@@ -2,7 +2,7 @@ import { ExpoConfig, getConfig } from '@expo/config';
import chalk from 'chalk';
import pick from 'lodash/pick';
import log from '../log';
import Log from '../log';
import { getProjectAccountName } from '../project/projectUtils';
import { confirmAsync } from '../prompts';
import { Actor } from '../user/User';
@@ -95,13 +95,13 @@ class CredentialsContext implements Context {
// Figure out if User A is configuring credentials as admin for User B's project
const isProxyUser = this.user.__typename === 'Robot' || owner !== this.user.username;
log(
Log.log(
`Accessing credentials ${isProxyUser ? 'on behalf of' : 'for'} ${owner} in project ${
this.exp.slug
}`
);
} else {
log(`Accessing credentials for ${this.exp.owner ?? getActorDisplayName(this.user)}`);
Log.log(`Accessing credentials for ${this.exp.owner ?? getActorDisplayName(this.user)}`);
}
}
@@ -115,12 +115,12 @@ class CredentialsContext implements Context {
return;
}
log(
Log.log(
chalk.green(
'If you provide your Apple account credentials we will be able to generate all necessary build credentials and fully validate them.'
)
);
log(
Log.log(
chalk.green(
'This is optional, but without Apple account access you will need to provide all the values manually and we can only run minimal validation on them.'
)
@@ -131,7 +131,7 @@ class CredentialsContext implements Context {
if (confirm) {
await this.appStore.ensureAuthenticatedAsync();
} else {
log(
Log.log(
chalk.green(
'No problem! 👌 If you select an action that requires those credentials we will ask you again about it.'
)
@@ -90,93 +90,140 @@ describe('credentialsJson', () => {
});
describe('readIosAsync', () => {
it('should read ios credentials if everything is correct', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
describe('single target credentials', () => {
it('should read ios credentials if everything is correct', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const result = await credentialsJsonReader.readIosCredentialsAsync('.');
expect(result).toEqual({
provisioningProfile: 'c29tZWJpbmFyeWNvbnRlbnQ=',
distributionCertificate: {
certP12: 'c29tZWJpbmFyeWNvbnRlbnQy',
certPassword: 'certPass',
},
});
});
it('should throw error when credentials.json is missing', async () => {
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow(
'credentials.json must exist in the project root directory and contain a valid JSON'
);
});
it('should throw error if ios field is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow('iOS credentials are missing from credentials.json');
});
it('should throw error if some field is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow(
'credentials.json is not valid [ValidationError: "ios" does not match any of the allowed types]'
);
});
it('should throw error if dist cert file is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./pprofile': 'somebinarycontent',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow("ENOENT: no such file or directory, open 'cert.p12'");
});
it('should throw error if provisioningProfile file is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow("ENOENT: no such file or directory, open 'pprofile'");
});
});
describe('multi-target credentials', () => {
it('should read ios credentials if everything is correct', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
target1: {
provisioningProfilePath: 'pprofile-1.mobileprovision',
distributionCertificate: {
path: 'dist-cert-1.p12',
password: 'cert-pass-1',
},
},
target2: {
provisioningProfilePath: 'pprofile-2.mobileprovision',
distributionCertificate: {
path: 'dist-cert-2.p12',
password: 'cert-pass-2',
},
},
},
}),
'./pprofile-1.mobileprovision': 'pprofile-1-somebinarycontent',
'./pprofile-2.mobileprovision': 'pprofile-2-somebinarycontent',
'./dist-cert-1.p12': 'cert-1-somebinarycontent',
'./dist-cert-2.p12': 'cert-2-somebinarycontent',
});
const result = await credentialsJsonReader.readIosCredentialsAsync('.');
expect(result).toEqual({
target1: {
provisioningProfile: 'cHByb2ZpbGUtMS1zb21lYmluYXJ5Y29udGVudA==',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
certP12: 'Y2VydC0xLXNvbWViaW5hcnljb250ZW50',
certPassword: 'cert-pass-1',
},
},
}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const result = await credentialsJsonReader.readIosCredentialsAsync('.');
expect(result).toEqual({
provisioningProfile: 'c29tZWJpbmFyeWNvbnRlbnQ=',
distributionCertificate: {
certP12: 'c29tZWJpbmFyeWNvbnRlbnQy',
certPassword: 'certPass',
},
});
});
it('should throw error when credentials.json is missing', async () => {
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow(
'credentials.json must exist in the project root directory and contain a valid JSON'
);
});
it('should throw error if ios field is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow('iOS credentials are missing from credentials.json');
});
it('should throw error if some field is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
target2: {
provisioningProfile: 'cHByb2ZpbGUtMi1zb21lYmluYXJ5Y29udGVudA==',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
certP12: 'Y2VydC0yLXNvbWViaW5hcnljb250ZW50',
certPassword: 'cert-pass-2',
},
},
}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow(
'credentials.json is not valid [ValidationError: "ios.provisioningProfilePath" is required]'
);
});
it('should throw error if dist cert file is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./pprofile': 'somebinarycontent',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow("ENOENT: no such file or directory, open 'cert.p12'");
});
it('should throw error if provisioningProfile file is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow("ENOENT: no such file or directory, open 'pprofile'");
});
});
@@ -17,15 +17,18 @@ jest.mock('prompts');
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
const originalConsoleError = console.error;
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
console.error = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
console.error = originalConsoleError;
});
beforeEach(() => {
@@ -114,7 +117,7 @@ describe('update credentials.json', () => {
throw new Error('updateAndroidCredentialsAsync should throw an error');
} catch (e) {
expect(e.message).toMatch(
'There are no credentials configured for this project on Expo servers'
'There are no credentials configured for this project on EAS servers'
);
}
const keystore = await fs.readFile('./keystore.jks', 'base64');
@@ -277,7 +280,7 @@ describe('update credentials.json', () => {
throw new Error('updateIosCredentialsAsync should throw na error');
} catch (e) {
expect(e.message).toMatch(
'There are no credentials configured for this project on Expo servers'
'There are no credentials configured for this project on EAS servers'
);
}
const certP12 = await fs.readFile('./cert.p12', 'base64');
@@ -331,5 +334,67 @@ describe('update credentials.json', () => {
},
});
});
it('should throw an error if credentials.json contains credentials for multi-target projects', async () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
},
});
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
target1: {
provisioningProfilePath: 'pprofile-1.mobileprovision',
distributionCertificate: {
path: 'dist-cert-1.p12',
password: 'cert-pass-1',
},
},
target2: {
provisioningProfilePath: 'pprofile-2.mobileprovision',
distributionCertificate: {
path: 'dist-cert-2.p12',
password: 'cert-pass-2',
},
},
},
}),
'./pprofile-1.mobileprovision': 'pprofile-1-somebinarycontent',
'./pprofile-2.mobileprovision': 'pprofile-2-somebinarycontent',
'./dist-cert-1.p12': 'cert-1-somebinarycontent',
'./dist-cert-2.p12': 'cert-2-somebinarycontent',
});
await expect(() => updateIosCredentialsAsync(ctx, 'bundleIdentifier')).rejects.toThrow(
/Updating credentials.json failed/
);
});
it(`should throw an error if there is a typo in credentials.json (because we can't tell if this is a target name or not)`, async () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
},
});
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
// typo!!!
xprovisioningProfilePath: 'pprofile-1.mobileprovision',
distributionCertificate: {
path: 'dist-cert-1.p12',
password: 'cert-pass-1',
},
},
}),
'./pprofile-1.mobileprovision': 'pprofile-1-somebinarycontent',
'./dist-cert-1.p12': 'cert-1-somebinarycontent',
});
await expect(() => updateIosCredentialsAsync(ctx, 'bundleIdentifier')).rejects.toThrow(
/Updating credentials.json failed/
);
});
});
});
@@ -4,6 +4,13 @@ import path from 'path';
import { Keystore } from '../android/credentials';
interface CredentialsJsonIosCredentials {
provisioningProfilePath: string;
distributionCertificate: {
path: string;
password: string;
};
}
export interface CredentialsJson {
android?: {
keystore: {
@@ -13,18 +20,20 @@ export interface CredentialsJson {
keyPassword: string;
};
};
ios?: {
provisioningProfilePath: string;
distributionCertificate: {
path: string;
password: string;
};
};
ios?: CredentialsJsonIosCredentials | Record<string, CredentialsJsonIosCredentials>;
experimental?: {
npmToken?: string;
};
}
const IosTargetCredentials = Joi.object({
provisioningProfilePath: Joi.string().required(),
distributionCertificate: Joi.object({
path: Joi.string().required(),
password: Joi.string().required(),
}).required(),
});
const CredentialsJsonSchema = Joi.object({
android: Joi.object({
keystore: Joi.object({
@@ -34,13 +43,10 @@ const CredentialsJsonSchema = Joi.object({
keyPassword: Joi.string().required(),
}).required(),
}),
ios: Joi.object({
provisioningProfilePath: Joi.string().required(),
distributionCertificate: Joi.object({
path: Joi.string().required(),
password: Joi.string().required(),
}).required(),
}),
ios: [
IosTargetCredentials,
Joi.object().pattern(Joi.string().required(), IosTargetCredentials.required()),
],
experimental: Joi.object({
npmToken: Joi.string(),
}),
@@ -50,13 +56,15 @@ interface AndroidCredentials {
keystore: Keystore;
}
interface iOSCredentials {
export interface IosTargetCredentials {
provisioningProfile: string;
distributionCertificate: {
certP12: string;
certPassword: string;
};
}
export type IosTargetCredentialsMap = Record<string, IosTargetCredentials>;
export type IosCredentials = IosTargetCredentials | IosTargetCredentialsMap;
export async function fileExistsAsync(projectDir: string): Promise<boolean> {
return await fs.pathExists(path.join(projectDir, 'credentials.json'));
@@ -78,22 +86,52 @@ export async function readAndroidCredentialsAsync(projectDir: string): Promise<A
};
}
export async function readIosCredentialsAsync(projectDir: string): Promise<iOSCredentials> {
export async function readIosCredentialsAsync(projectDir: string): Promise<IosCredentials> {
const credentialsJson = await readAsync(projectDir);
if (!credentialsJson.ios) {
throw new Error('iOS credentials are missing from credentials.json');
}
if (!isInternalCredentialsMap(credentialsJson.ios)) {
return await readCredentialsForTargetAsync(projectDir, credentialsJson.ios);
} else {
const targets = Object.keys(credentialsJson.ios);
const targetCredentialsMap: IosTargetCredentialsMap = {};
for (const target of targets) {
targetCredentialsMap[target] = await readCredentialsForTargetAsync(
projectDir,
credentialsJson.ios[target]
);
}
return targetCredentialsMap;
}
}
function isInternalCredentialsMap(
ios: CredentialsJsonIosCredentials | Record<string, CredentialsJsonIosCredentials>
): ios is Record<string, CredentialsJsonIosCredentials> {
return typeof ios.provisioningProfilePath !== 'string';
}
export function isCredentialsMap(ios: IosCredentials): ios is IosTargetCredentialsMap {
return typeof ios.provisioningProfile !== 'string';
}
async function readCredentialsForTargetAsync(
projectDir: string,
targetCredentials: CredentialsJsonIosCredentials
): Promise<IosTargetCredentials> {
return {
provisioningProfile: await fs.readFile(
getAbsolutePath(projectDir, credentialsJson.ios.provisioningProfilePath),
getAbsolutePath(projectDir, targetCredentials.provisioningProfilePath),
'base64'
),
distributionCertificate: {
certP12: await fs.readFile(
getAbsolutePath(projectDir, credentialsJson.ios.distributionCertificate.path),
getAbsolutePath(projectDir, targetCredentials.distributionCertificate.path),
'base64'
),
certPassword: credentialsJson.ios.distributionCertificate.password,
certPassword: targetCredentials.distributionCertificate.password,
},
};
}
@@ -1,7 +1,7 @@
import fs from 'fs-extra';
import path from 'path';
import log from '../../log';
import Log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { confirmAsync } from '../../prompts';
import { gitStatusAsync } from '../../utils/git';
@@ -21,8 +21,8 @@ export async function updateAndroidCredentialsAsync(ctx: Context): Promise<void>
const rawFile = await fs.readFile(credentialsJsonFilePath, 'utf-8');
rawCredentialsJsonObject = JSON.parse(rawFile);
} catch (error) {
log.error(`Reading credentials.json failed [${error}]`);
log.error('Make sure that file is correct (or remove it) and rerun this command.');
Log.error(`Reading credentials.json failed [${error}]`);
Log.error('Make sure that file is correct (or remove it) and rerun this command.');
throw error;
}
}
@@ -30,7 +30,7 @@ export async function updateAndroidCredentialsAsync(ctx: Context): Promise<void>
const experienceName = `@${accountName}/${ctx.exp.slug}`;
const keystore = await ctx.android.fetchKeystoreAsync(experienceName);
if (!keystore) {
throw new Error('There are no credentials configured for this project on Expo servers');
throw new Error('There are no credentials configured for this project on EAS servers');
}
const isKeystoreComplete =
@@ -39,17 +39,17 @@ export async function updateAndroidCredentialsAsync(ctx: Context): Promise<void>
if (!isKeystoreComplete) {
const confirm = await confirmAsync({
message:
'Credentials on Expo servers might be invalid or incomplete. Are you sure you want to continue?',
'Credentials on EAS servers might be invalid or incomplete. Are you sure you want to continue?',
});
if (!confirm) {
log.warn('Aborting...');
Log.warn('Aborting...');
return;
}
}
const keystorePath =
rawCredentialsJsonObject?.android?.keystore?.keystorePath ?? 'android/keystores/keystore.jks';
log(`Writing Keystore to ${keystorePath}`);
Log.log(`Writing Keystore to ${keystorePath}`);
await updateFileAsync(ctx.projectDir, keystorePath, keystore.keystore);
const shouldWarnKeystore = await isFileUntrackedAsync(keystorePath);
@@ -94,13 +94,31 @@ export async function updateIosCredentialsAsync(
const rawFile = await fs.readFile(credentialsJsonFilePath, 'utf-8');
rawCredentialsJsonObject = JSON.parse(rawFile);
} catch (error) {
log.error(`There was an error while reading credentials.json [${error}]`);
log.error('Make sure that file is correct (or remove it) and rerun this command.');
Log.error(`There was an error while reading credentials.json [${error}]`);
Log.error('Make sure that file is correct (or remove it) and rerun this command.');
throw error;
}
// TODO: implement updating credentials.json with multi-target credentials from EAS servers
if (rawCredentialsJsonObject?.ios) {
const keys = Object.keys(rawCredentialsJsonObject.ios);
const maybeUnknownKey = keys.find(
key => key !== 'provisioningProfilePath' && key !== 'distributionCertificate'
);
if (maybeUnknownKey) {
Log.error(
`It looks like your credentials.json either contains multi-target credentials or you've made a typo.`
);
Log.error(`- The key in 'ios' object that EAS CLI encountered is: ${maybeUnknownKey}`);
Log.error(
`- Updating credentials.json (for multi-target iOS projects) with credentials stored on EAS servers is not supported at the moment, sorry!`
);
throw new Error('Updating credentials.json failed');
}
}
}
const accountName = await getProjectAccountName(ctx.exp, ctx.user);
const accountName = getProjectAccountName(ctx.exp, ctx.user);
const appLookupParams = {
accountName,
projectName: ctx.exp.slug,
@@ -113,7 +131,7 @@ export async function updateIosCredentialsAsync(
const appCredentials = await ctx.ios.getAppCredentialsAsync(appLookupParams);
const distCredentials = await ctx.ios.getDistributionCertificateAsync(appLookupParams);
if (!appCredentials?.credentials?.provisioningProfile && !distCredentials) {
throw new Error('There are no credentials configured for this project on Expo servers');
throw new Error('There are no credentials configured for this project on EAS servers');
}
const areCredentialsComplete =
@@ -124,15 +142,15 @@ export async function updateIosCredentialsAsync(
if (!areCredentialsComplete) {
const confirm = await confirmAsync({
message:
'Credentials on Expo servers might be invalid or incomplete. Are you sure you want to continue?',
'Credentials on EAS servers might be invalid or incomplete. Are you sure you want to continue?',
});
if (!confirm) {
log.warn('Aborting...');
Log.warn('Aborting...');
return;
}
}
log(`Writing Provisioning Profile to ${profilePath}`);
Log.log(`Writing Provisioning Profile to ${profilePath}`);
await updateFileAsync(
ctx.projectDir,
profilePath,
@@ -140,7 +158,7 @@ export async function updateIosCredentialsAsync(
);
const shouldWarnPProfile = await isFileUntrackedAsync(profilePath);
log(`Writing Distribution Certificate to ${distCertPath}`);
Log.log(`Writing Distribution Certificate to ${distCertPath}`);
await updateFileAsync(ctx.projectDir, distCertPath, distCredentials?.certP12);
const shouldWarnDistCert = await isFileUntrackedAsync(distCertPath);
@@ -199,11 +217,11 @@ async function isFileUntrackedAsync(path: string): Promise<boolean> {
function displayUntrackedFilesWarning(newFilePaths: string[]) {
if (newFilePaths.length === 1) {
log.warn(
Log.warn(
`File ${newFilePaths[0]} is currently untracked, remember to add it to .gitignore or to encrypt it. (e.g. with git-crypt)`
);
} else if (newFilePaths.length > 1) {
log.warn(
Log.warn(
`Files ${newFilePaths.join(
', '
)} are currently untracked, remember to add them to .gitignore or to encrypt them. (e.g. with git-crypt)`
@@ -1,22 +1,20 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource, DistributionType } from '@expo/eas-json';
import { CredentialsSource, iOSDistributionType } from '@expo/eas-json';
import { IosDistributionType } from '../../graphql/generated';
import log from '../../log';
import { ensureAccounts, findAccountByName } from '../../user/Account';
import { runCredentialsManagerAsync } from '../CredentialsManager';
import { AppleTeamFragment, IosDistributionType } from '../../graphql/generated';
import Log from '../../log';
import { findAccountByName } from '../../user/Account';
import { CredentialsManager } from '../CredentialsManager';
import { CredentialsProvider } from '../CredentialsProvider';
import { Context } from '../context';
import * as credentialsJsonReader from '../credentialsJson/read';
import type { IosCredentials } from '../credentialsJson/read';
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
import { resolveAppleTeamIfAuthenticatedAsync } from './actions/new/AppleTeamUtils';
import { AppleTeamMissingError, MissingCredentialsNonInteractiveError } from './errors';
import { isAdHocProfile } from './utils/provisioningProfile';
export interface IosCredentials {
provisioningProfile: string;
distributionCertificate: {
certP12: string;
certPassword: string;
};
}
export { IosCredentials };
interface PartialIosCredentials {
provisioningProfile?: string;
@@ -28,7 +26,7 @@ interface PartialIosCredentials {
interface Options {
app: AppLookupParams;
distribution: DistributionType;
distribution: iOSDistributionType;
skipCredentialsCheck?: boolean;
}
@@ -39,11 +37,16 @@ interface AppLookupParams {
}
export default class IosCredentialsProvider implements CredentialsProvider {
public readonly platform = Platform.iOS;
public readonly platform = Platform.IOS;
constructor(private ctx: Context, private options: Options) {}
public async hasRemoteAsync(): Promise<boolean> {
// TODO: this is temporary
// remove this check when we implement syncing local credentials for internal distribution
if (this.options.distribution === 'internal' && (await this.hasLocalAsync())) {
return false;
}
const { distributionCertificate, provisioningProfile } = await this.fetchRemoteAsync();
return !!(
distributionCertificate?.certP12 ||
@@ -53,10 +56,6 @@ export default class IosCredentialsProvider implements CredentialsProvider {
}
public async hasLocalAsync(): Promise<boolean> {
if (this.options.distribution === DistributionType.INTERNAL) {
// TODO: add support for using credentials.json for internal distribution
return false;
}
if (!(await credentialsJsonReader.fileExistsAsync(this.ctx.projectDir))) {
return false;
}
@@ -64,7 +63,7 @@ export default class IosCredentialsProvider implements CredentialsProvider {
const rawCredentialsJson = await credentialsJsonReader.readRawAsync(this.ctx.projectDir);
return !!rawCredentialsJson?.ios;
} catch (err) {
log.error(err); // malformed json
Log.error(err); // malformed json
return false;
}
}
@@ -74,6 +73,15 @@ export default class IosCredentialsProvider implements CredentialsProvider {
const [remote, local] = await Promise.all([this.fetchRemoteAsync(), this.getLocalAsync()]);
const r = remote;
const l = local;
// TODO
// For now, when credentials.json contains credentials for multi-target project
// assume they are synced with the Expo servers.
// Change this behavior when we figure out how to store multi-target project credentials in the db.
if (credentialsJsonReader.isCredentialsMap(l)) {
return true;
}
return !!(
r &&
l &&
@@ -98,19 +106,39 @@ export default class IosCredentialsProvider implements CredentialsProvider {
}
private async getLocalAsync(): Promise<IosCredentials> {
if (this.options.distribution === DistributionType.INTERNAL) {
// TODO: add support for using credentials.json for internal distribution
throw new Error('Using credentials.json for internal distribution is not supported yet.');
const credentials = await credentialsJsonReader.readIosCredentialsAsync(this.ctx.projectDir);
if (credentialsJsonReader.isCredentialsMap(credentials)) {
for (const targetName of Object.keys(credentials)) {
this.assertProvisioningProfileType(credentials[targetName].provisioningProfile, targetName);
}
} else {
this.assertProvisioningProfileType(credentials.provisioningProfile);
}
return credentials;
}
private assertProvisioningProfileType(provisionigProfile: string, targetName?: string) {
const isAdHoc = isAdHocProfile(provisionigProfile);
if (this.options.distribution === 'internal' && !isAdHoc) {
throw new Error(
`You must use an adhoc provisioning profile${
targetName && ` (target '${targetName})'`
} for internal distribution`
);
} else if (this.options.distribution !== 'internal' && isAdHoc) {
throw new Error(
`You can't use an adhoc provisioning profile${
targetName && ` (target '${targetName}')`
} for app store distribution`
);
}
return await credentialsJsonReader.readIosCredentialsAsync(this.ctx.projectDir);
}
private async getRemoteAsync(): Promise<IosCredentials> {
if (this.options.skipCredentialsCheck) {
log('Skipping credentials check');
Log.log('Skipping credentials check');
} else {
await runCredentialsManagerAsync(
this.ctx,
await new CredentialsManager(this.ctx).runActionAsync(
new SetupBuildCredentials(this.options.app, this.options.distribution)
);
}
@@ -144,9 +172,9 @@ export default class IosCredentialsProvider implements CredentialsProvider {
}
private async fetchRemoteAsync(): Promise<PartialIosCredentials> {
if (this.options.distribution === DistributionType.INTERNAL) {
if (this.options.distribution === 'internal') {
const { app } = this.options;
const account = findAccountByName(ensureAccounts(this.ctx.user.accounts), app.accountName);
const account = findAccountByName(this.ctx.user.accounts, app.accountName);
if (!account) {
throw new Error(`You do not have access to the ${app.accountName} account`);
}
@@ -157,31 +185,36 @@ export default class IosCredentialsProvider implements CredentialsProvider {
projectName: this.options.app.projectName,
};
// for now, let's require the user to authenticate with Apple
const { team } = await this.ctx.appStore.ensureAuthenticatedAsync();
const appleTeam = await this.ctx.newIos.createOrGetExistingAppleTeamAsync(appLookupParams, {
appleTeamIdentifier: team.id,
appleTeamName: team.name,
});
const [distCert, provisioningProfile] = await Promise.all([
this.ctx.newIos.getDistributionCertificateForAppAsync(
appLookupParams,
appleTeam,
IosDistributionType.AdHoc
),
this.ctx.newIos.getProvisioningProfileAsync(
appLookupParams,
appleTeam,
IosDistributionType.AdHoc
),
]);
return {
provisioningProfile: provisioningProfile?.provisioningProfile ?? undefined,
distributionCertificate: {
certP12: distCert?.certificateP12 ?? undefined,
certPassword: distCert?.certificatePassword ?? undefined,
},
};
let appleTeam: AppleTeamFragment | null = null;
if (!this.ctx.nonInteractive) {
await this.ctx.appStore.ensureAuthenticatedAsync();
appleTeam = await resolveAppleTeamIfAuthenticatedAsync(this.ctx, appLookupParams);
}
try {
const [distCert, provisioningProfile] = await Promise.all([
this.ctx.newIos.getDistributionCertificateForAppAsync(
appLookupParams,
IosDistributionType.AdHoc,
{ appleTeam }
),
this.ctx.newIos.getProvisioningProfileAsync(appLookupParams, IosDistributionType.AdHoc, {
appleTeam,
}),
]);
return {
provisioningProfile: provisioningProfile?.provisioningProfile ?? undefined,
distributionCertificate: {
certP12: distCert?.certificateP12 ?? undefined,
certPassword: distCert?.certificatePassword ?? undefined,
},
};
} catch (err) {
if (err instanceof AppleTeamMissingError && this.ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError();
}
throw err;
}
} else {
const [distCert, provisioningProfile] = await Promise.all([
this.ctx.ios.getDistributionCertificateAsync(this.options.app),
@@ -2,7 +2,7 @@ import assert from 'assert';
import chalk from 'chalk';
import ora from 'ora';
import log from '../../../log';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import {
@@ -36,14 +36,14 @@ export class ConfigureProvisioningProfile implements Action {
}
if (!profile.provisioningProfileId) {
log.warn("The provisioning profile we have on file cannot be validated on Apple's servers.");
Log.warn("The provisioning profile we have on file cannot be validated on Apple's servers.");
return;
}
if (ctx.appStore.authCtx) {
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
if (!distCert) {
log.warn('There is no distribution certificate for this app.');
Log.warn('There is no distribution certificate for this app.');
return;
}
const profilesFromApple = await ctx.appStore.listProvisioningProfilesAsync(
@@ -53,15 +53,15 @@ export class ConfigureProvisioningProfile implements Action {
appleInfo => appleInfo.provisioningProfileId === profile.provisioningProfileId
);
if (!profilesWithMatchingId || profilesWithMatchingId.length < 1) {
log.warn('This profile is no longer valid on Apple Developer Portal.');
Log.warn('This profile is no longer valid on Apple Developer Portal.');
}
await this.configureAndUpdateAsync(ctx, this.app, distCert, profilesWithMatchingId[0]);
} else {
log.warn(
Log.warn(
"Without access to your Apple account we can't configure provisioning profiles for you."
);
log.warn('Make sure to recreate the profile if you selected a new distribution certificate.');
Log.warn('Make sure to recreate the profile if you selected a new distribution certificate.');
}
}
@@ -84,7 +84,7 @@ export class ConfigureProvisioningProfile implements Action {
const spinner = ora(`Updating Expo profile for ${projectTag}`).start();
try {
// Update profile on expo servers
// Update profile on EAS servers
await ctx.ios.updateProvisioningProfileAsync(app, updatedProfile);
spinner.succeed(`Updated Expo profile for ${projectTag}`);
} catch (error) {
@@ -1,6 +1,6 @@
import assert from 'assert';
import log from '../../../log';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { IosDistCredentials } from '../credentials';
@@ -14,9 +14,9 @@ export class CreateDistributionCertificateStandaloneManager implements Action {
const action = new CreateDistributionCertificate(this.accountName);
await manager.runActionAsync(action);
log.newLine();
Log.newLine();
displayIosUserCredentials(action.distCredentials);
log.newLine();
Log.newLine();
}
}
@@ -40,6 +40,6 @@ export class CreateDistributionCertificate implements Action {
this.accountName,
distCert
);
log.succeed('Created distribution certificate');
Log.succeed('Created distribution certificate');
}
}
@@ -1,6 +1,6 @@
import assert from 'assert';
import log from '../../../log';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
@@ -17,7 +17,7 @@ export class CreateProvisioningProfileStandaloneManager implements Action {
const appCredentials = await ctx.ios.getAppCredentialsAsync(this.app);
displayIosAppCredentials(appCredentials);
log.newLine();
Log.newLine();
}
}
@@ -28,14 +28,14 @@ export class CreateProvisioningProfile implements Action {
const provisioningProfile = await this.provideOrGenerateAsync(ctx);
await ctx.ios.updateProvisioningProfileAsync(this.app, provisioningProfile);
log.succeed('Created provisioning profile');
Log.succeed('Created provisioning profile');
}
private async provideOrGenerateAsync(ctx: Context): Promise<ProvisioningProfile> {
const userProvided = await askForUserProvidedAsync(provisioningProfileSchema);
if (userProvided) {
// userProvided profiles don't come with ProvisioningProfileId's (only accessible from Apple Portal API)
log.warn('Provisioning profile: Unable to validate specified profile.');
Log.warn('Provisioning profile: Unable to validate specified profile.');
return userProvided;
}
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import log, { learnMore } from '../../../log';
import Log, { learnMore } from '../../../log';
import { promptAsync } from '../../../prompts';
import { CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
@@ -39,14 +39,14 @@ export async function provideOrGenerateDistributionCertificateAsync(
const userProvided = await promptForDistCertAsync(ctx);
if (userProvided) {
if (!ctx.appStore.authCtx) {
log.warn(
Log.warn(
'Unable to validate distribution certificate due to insufficient Apple Credentials'
);
return userProvided;
} else {
const isValid = await validateDistributionCertificateAsync(ctx, userProvided);
if (!isValid) {
log.warn("Provided Distribution Certificate is no longer valid on Apple's server");
Log.warn("Provided Distribution Certificate is no longer valid on Apple's server");
}
return isValid
? userProvided
@@ -88,8 +88,8 @@ async function generateDistributionCertificateAsync(
} catch (e) {
if (e instanceof AppleTooManyCertsError) {
const distCerts = await ctx.appStore.listDistributionCertificatesAsync();
log.warn('Maximum number of Distribution Certificates generated on Apple Developer Portal.');
log.warn(APPLE_DIST_CERTS_TOO_MANY_GENERATED_ERROR);
Log.warn('Maximum number of Distribution Certificates generated on Apple Developer Portal.');
Log.warn(APPLE_DIST_CERTS_TOO_MANY_GENERATED_ERROR);
if (ctx.nonInteractive) {
throw new Error(
@@ -97,13 +97,13 @@ async function generateDistributionCertificateAsync(
);
}
log(
Log.log(
chalk.grey(
`✅ Distribution Certificates can be revoked with no side effects for App Store builds.`
)
);
log(learnMore('https://docs.expo.io/distribution/app-signing/#summary'));
log.newLine();
Log.log(learnMore('https://docs.expo.io/distribution/app-signing/#summary'));
Log.newLine();
const { distCertsToRevoke } = await promptAsync({
type: 'multiselect',
@@ -151,7 +151,7 @@ export async function selectDistributionCertificateAsync(
options.filterInvalid && validDistCredentials ? validDistCredentials : distCredentials;
if (distCredentials.length === 0) {
log.warn('There are no Distribution Certificates available in your Expo account.');
Log.warn('There are no Distribution Certificates available in your Expo account.');
return null;
}
@@ -241,7 +241,7 @@ export async function getValidDistCertsAsync(
(cred): cred is IosDistCredentials => cred.type === 'dist-cert'
);
if (!ctx.appStore.authCtx) {
log(chalk.yellow(`Unable to determine validity of Distribution Certificates.`));
Log.log(chalk.yellow(`Unable to determine validity of Distribution Certificates.`));
return distCredentials;
}
const certInfoFromApple = await ctx.appStore.listDistributionCertificatesAsync();
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import log from '../../../log';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { Context } from '../../context';
import {
@@ -16,7 +16,7 @@ export async function selectProvisioningProfileFromAppleAsync(
): Promise<ProvisioningProfileStoreInfo | null> {
const profiles = await ctx.appStore.listProvisioningProfilesAsync(bundleIdentifier);
if (profiles.length === 0) {
log.warn(
Log.warn(
`There are no provisioning profiles available on Apple Developer Portal for bundle identifier ${bundleIdentifier}.`
);
return null;
@@ -53,7 +53,7 @@ export async function selectProvisioningProfileFromExpoAsync(
({ credentials }) => !!credentials.provisioningProfile && !!credentials.provisioningProfileId
);
if (profiles.length === 0) {
log.warn('There are no provisioning profiles available in your Expo account.');
Log.warn('There are no provisioning profiles available in your Expo account.');
return null;
}
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import log from '../../../log';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
@@ -21,8 +21,8 @@ export class RemoveDistributionCertificate implements Action {
await manager.runActionAsync(
new RemoveSpecificDistributionCertificate(selected?.id, this.accountName, this.options)
);
log.succeed('Removed distribution certificate');
log.newLine();
Log.succeed('Removed distribution certificate');
Log.newLine();
}
}
}
@@ -54,12 +54,12 @@ export class RemoveSpecificDistributionCertificate implements Action {
message: `You are removing certificate used by ${appList}. Do you want to continue?`,
});
if (!confirm) {
log('Aborting');
Log.log('Aborting');
return;
}
}
log('Removing Distribution Certificate');
Log.log('Removing Distribution Certificate');
await ctx.ios.deleteDistributionCertificateAsync(this.userCredentialsId, this.accountName);
let shouldRevoke = this.options.shouldRevoke;
@@ -91,7 +91,7 @@ export class RemoveSpecificDistributionCertificate implements Action {
if (!(await ctx.ios.getProvisioningProfileAsync(appLookupParams))) {
continue;
}
log(
Log.log(
`Removing Provisioning Profile for ${appCredentials.experienceName} (${appCredentials.bundleIdentifier})`
);
await manager.runActionAsync(
@@ -1,4 +1,4 @@
import log from '../../../log';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
@@ -30,7 +30,7 @@ export class RemoveSpecificProvisioningProfile implements Action {
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
await ctx.ios.deleteProvisioningProfileAsync(this.app);
log.succeed(
Log.succeed(
`Removed provisioning profile for @${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`
);
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff. Show more