Compare commits

...
63 Commits
Author SHA1 Message Date
Brent Vatne a9da78946b v0.18.2 2021-06-25 14:57:32 -07:00
Brent Vatne b6d9accea4 [eas-cli] Fix inverted conditional so we actually only prompt about commit when index is dirty (#481) 2021-06-25 14:54:26 -07:00
Quinlan Jung 4611a65bce [eas-cli] show projects that depend on a push key (#472) 2021-06-25 13:55:16 -07:00
Quinlan Jung db29b3fc18 [eas-cli] make apple team optional in appropriate cases (#468)
* [eas-cli] make apple team optional in appropriate cases

* pr feedback
2021-06-25 13:53:37 -07:00
Dominik Sokal 2534a46bd3 [ENG-1396] change default ios image for sdk <= 41 (#479)
* change default ios image for sdk <= 41

* update CHANGELOG
2021-06-25 15:23:24 +02:00
Dominik Sokal 7def4b502e remove redundant type 2021-06-25 13:58:38 +02:00
Brent Vatne 4ea26d7ca3 Remove leftover CHANGELOG entry 2021-06-24 17:56:14 -07:00
Brent Vatne c4c8075b1f Update CHANGELOG 2021-06-24 17:55:06 -07:00
Brent Vatne 266545d2a0 v0.18.1 2021-06-24 17:43:58 -07:00
Brent Vatne ddd7046513 Add entry to CHANGELOG 2021-06-24 17:43:14 -07:00
Quinlan Jung 7a44eeb22b [eas-cli] add basic printing support for Apple App Specific Password (#474) 2021-06-24 17:42:00 -07:00
Brent Vatne 5bcf8263cf [eas-cli] Automatically migrate build credentials to EAS (#477) 2021-06-24 17:41:17 -07:00
Dominik Sokal f380ba0106 update CHANGELOG 2021-06-24 13:35:32 +02:00
Dominik Sokal 1a6d81f68e v0.18.0 2021-06-24 12:17:08 +02:00
Dominik Sokal 522818113b support new build status: new (#475)
* support new build status: 'new'

* update CHANGELOG
2021-06-24 12:07:44 +02:00
Hayden Daly f93d347665 [docs] update 'See code' links in README (#446)
Why
Majority of the See code links in the README resolve to nonexistent paths causing 404s. For example, build/commands/account/login.ts should resolve to src/commands/account/login.ts.

How
Simple find and replace with the links and updating relevant text using patch-readme.js script added to package.json.

Test Plan
Clicked through the links to make sure they all resolve to right locations.
2021-06-23 14:52:36 -07:00
Wojciech KozyraandDominik Sokal e96cee1c3b [eas-cli] support different vcs (#462)
* [eas-cli] add abstraction around VCS, implement git and local version

* Apply suggestions from code review

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

* review feedback

* update CHANGELOG

Co-authored-by: Dominik Sokal <[email protected]>
2021-06-23 12:05:31 +02:00
Quinlan Jung 4d46e0a8ac changelog stuff 2021-06-22 13:02:58 -07:00
Quinlan Jung 80e35c7524 [eas-cli] add 'remove push key' action (#470)
* [eas-cli] remove push key action

* todo
2021-06-22 12:23:31 -07:00
Quinlan Jung 186c26bc99 [eas-cli] setup push key action (#469) 2021-06-22 11:49:26 -07:00
jkh be6a7f54f9 [eas-cli][eas-json] add updates.channel to build metadata (#461) 2021-06-21 10:29:31 -07:00
Ville Immonen fff7aac5b6 Add an internal distribution with dev client to build:configure defaults (#465) 2021-06-21 12:51:27 +03:00
Quinlan Jung 0d90fbd3ff [eas-cli] create and assign push key actions (#467) 2021-06-18 10:32:05 -07:00
Quinlan Jung e71779049e [eas-cli] rename android mocks 2021-06-17 21:24:38 -07:00
Quinlan Jung 7da909e3c1 [eas-cli] fix android manager push description 2021-06-17 21:15:13 -07:00
Quinlan Jung e439fd14a9 changelog stuff 2021-06-17 11:19:45 -07:00
Ville Immonen ffed1b446a Record dev client builds created and downloaded to analytics (#454) 2021-06-17 15:28:38 +03:00
Quinlan Jung 7b65412ec6 [eas-cli] swap android manager for the refactored beta one (#460)
* [eas-cli] swap the android manager with the beta one

* [eas-cli] remove unused classes, remove new directory

* [eas-cli] delete credentials manager class

* [eas-cli] replace API newAndroid -> android

* [eas-cli] printCredentialsBeta -> printCredentials
2021-06-16 09:55:55 -07:00
Quinlan Jung f1cbdcc094 changelog stuff 2021-06-15 15:56:11 -07:00
Quinlan Jung ec5573efa6 [eas-cli] add push key graphql types (#456) 2021-06-15 11:14:33 -07:00
Quinlan JungandDominik Sokal 242da62271 [eas-cli] action to upload/download credentials.json to www (#455)
* [eas-cli] action to upload/download credentials.json to www

* Update packages/eas-cli/src/credentials/android/actions/new/SetupBuildCredentialsFromCredentialsJson.ts

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

* Update packages/eas-cli/src/credentials/android/actions/UpdateCredentialsJson.ts

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

Co-authored-by: Dominik Sokal <[email protected]>
2021-06-15 11:14:15 -07:00
Wojciech Kozyra 23d1221ef9 [eas-cli] add support for big sur image for ios builds (#457)
* [eas-cli] add support for big sure image for ios builds

* update CHANGELOG
2021-06-15 16:20:43 +02:00
Quinlan Jung 5402d8c74f changelog stuff 2021-06-14 19:02:47 -07:00
Dominik Sokal 176026645c fix typo: Expo CLI -> EAS CLI 2021-06-14 15:45:30 +02:00
Brent Vatne b0d4ed6c0e [eas-cli] Deprecate --skip-credentials-check (#442) 2021-06-10 20:27:06 -07:00
Quinlan Jung 2f7ac71921 [eas-cli] remove android fcm/keystore actions (#451) 2021-06-10 11:50:08 -07:00
Quinlan Jung b599315a85 [eas-cli] more manager dx improvements (#449)
* [eas-cli] improve ios manager dx

* add go back
2021-06-10 11:49:45 -07:00
Quinlan Jung e86d8aa77a [eas-cli] add additionalTypenames for creds mutation to invalidate urql cache 2021-06-09 19:38:16 -07:00
Quinlan Jung ab4ac87d52 [eas-cli] download and backup keystore action (#447)
* [eas-cli] download and backup keystore action

* pr feedback
2021-06-09 15:42:57 -07:00
Quinlan Jung af98fb1621 [eas-cli] refactor ios manager (#448) 2021-06-09 13:11:04 -07:00
Dominik Sokal 0ce7c1b3b4 clean up legacy stuff (#444)
* drop support for npm token in credentials.json

* remove --allow-experimantal flag

* update CHANGELOG
2021-06-09 14:31:30 +02:00
Dominik Sokal 2bdaa0d428 [ENG-1271] make iOS credentials manager work with multi-target projects (#441)
* make function for selecting Xcode scheme generic

* move XcodeBuildContext resolver

* adjust ios credentials manager to multitarget projects

* update CHANGELOG

* address PR feedback
2021-06-09 13:56:17 +02:00
Quinlan Jung 2486a4de97 [ENG-1242][eas-cli] copy Expo Classic credentials to EAS if user is eligible (#445)
* [eas-cli] copy Expo Classic credentials to EAS if user is eligible

* fix tsc

* fix lint

* pr feedback
2021-06-08 22:17:14 -07:00
kgc00 6137b215e9 [eas-cli] Introduce action stat (#437)
* Introduces a new abstract class for commands to inherit from -- EasCommand
* EasCommand does two things, login and analytics:
  * Reads local credentials or initiates login (depending on command requirements) 
  * Bootstraps analytics, sends an 'action' stat to track cli usage, and flushes events at the end of the commands lifecycle
* Build and Submit now subclass EasCommand
* Tests for EasCommand are included
2021-06-07 10:54:14 -07:00
Quinlan Jung 265ba16594 [eas-cli] refactor credential input types (#443)
* [eas-cli] use generated input types

* pulled cleaned up schema from www

* fix lint
2021-06-07 10:33:56 -07:00
Quinlan Jung a7bc3d0c98 [eas-cli] action to select android build credentials (#438)
* [eas-cli] action to select android build credentials

* oops

* pr feedback
2021-06-07 10:33:36 -07:00
Quinlan Jung ee618880d7 [eas-cli] fcm create and assign graphql actions (#440) 2021-06-04 14:03:57 -07:00
Quinlan Jung 2c95c8c310 [eas-cli] add fcm types for graphql (#439) 2021-06-04 14:02:38 -07:00
Dominik Sokal b90e37d9fc update CHANGELOG 2021-06-02 13:19:35 +02:00
Dominik Sokal 34563070f4 v0.17.0 2021-06-02 12:07:46 +02:00
Dominik Sokal 66b269f4bf update CHANGELOG 2021-06-02 12:06:41 +02:00
Brent Vatne 168cb4d4a0 [eas-cli] Fix git repo root path on Windows (#429)
* Fix git repo root path on Windows

* Update CHANGELOG
2021-06-02 12:03:33 +02:00
Quinlan Jung 91b531da43 [eas-cli] use correct method to determine android application identifier (#431)
* [eas-cli] use correct method to determine android application identifier

* pr feedback
2021-06-02 12:00:51 +02:00
Quinlan Jung 2e0d567361 update changelog 2021-06-01 11:59:51 -07:00
Quinlan Jung 81e6280cff [eas-cli] use new android build credentials provider (#427)
* [eas-cli] use new android build credentials provider

* pr feedback

* fix tsc

* pr feedback:
2021-06-01 11:56:37 -07:00
Quinlan Jung e2fda7b9d3 [eas-cli] refactor application id configuration (#432) 2021-06-01 10:34:08 -07:00
Dominik Sokal ede30807f5 fix bundle identifier getter (#434)
* fix bundle identifier getter

* update CHANGELOG
2021-06-01 14:25:03 +02:00
Brent Vatne 9f354013fa Update CHANGELOG 2021-05-26 15:09:59 -07:00
Brent Vatne 30552f134c v0.16.0 2021-05-26 14:49:42 -07:00
Brent Vatne e4beba9a21 Add env var to disable capability sync and extra logs when EXPO_DEBUG is set (#426) 2021-05-26 13:11:42 -07:00
Dominik Sokal b71ab7ec84 implement managed credentials support for multitarget iOS projects (#414)
* ignore --skip-credentials-check flag for ios builds

* account for parent bundle identifier when creating apple app identifier

* implement managed credentials support for multitarget ios projects

* update CHANGELOG.md

* address PR feedback

* upgrade @expo/config-plugins 1.0.32 -> 1.0.33

* fix lint warning

* address more PR feedback

* address PR feedback
2021-05-26 11:06:29 +02:00
Brent Vatne 596cfc372b Move updating the release on Github to after the release is completed (#418) 2021-05-25 15:56:55 +02:00
Dominik Sokal d5f6c5680c update CHANGELOG 2021-05-25 15:55:23 +02:00
178 changed files with 6683 additions and 3613 deletions

No files matched your search

+69 -2
View File
@@ -8,14 +8,81 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Change default iOS image for projects with Expo SDK <= 41 (SDK 41 won't build with Xcode 12.5). ([#479](https://github.com/expo/eas-cli/pull/479) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Fix inverted conditional so we actually only prompt about commit when index is dirty. ([#481](https://github.com/expo/eas-cli/pull/481) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
## [0.18.1](https://github.com/expo/eas-cli/releases/tag/v0.18.1) - 2021-06-24
### 🎉 New features
- Add basic printing support for Apple App Specific Password. ([#474](https://github.com/expo/eas-cli/pull/474) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- Fix CLI UI getting blocked on credentials migration, leading to partially migrated state. ([#477](https://github.com/expo/eas-cli/pull/477) by [@brentvatne](https://github.com/brentvatne))
## [0.18.0](https://github.com/expo/eas-cli/releases/tag/v0.18.0) - 2021-06-24
### 🛠 Breaking changes
- Drop support for `experimental.npmToken` in credentials.json, EAS Secrets can be used instead. ([#444](https://github.com/expo/eas-cli/pull/444) by [@dsokal](https://github.com/dsokal))
- Remove `--allow-experimental` flag from `eas build:configure` as it has no effect now. ([#444](https://github.com/expo/eas-cli/pull/444) by [@dsokal](https://github.com/dsokal))
### 🎉 New features
- Make credentials manager work with multi-target iOS projects. ([#441](https://github.com/expo/eas-cli/pull/441) by [@dsokal](https://github.com/dsokal))
- Copy over credentials from Expo Classic to EAS. ([#445](https://github.com/expo/eas-cli/pull/445) by [@quinlanj](https://github.com/quinlanj))
- Add Big Sur image for iOS builds. ([#457](https://github.com/expo/eas-cli/pull/457) by [@wkozyra95](https://github.com/wkozyra95))
- New version of Android credentials manager. ([#460](https://github.com/expo/eas-cli/pull/460) by [@quinlanj](https://github.com/quinlanj))
- Add an internal distribution with dev client to `build:configure` defaults. ([#465](https://github.com/expo/eas-cli/pull/465) by [@fson](https://github.com/fson))
- Add `updates.channel` to build metadata. ([#461](https://github.com/expo/eas-cli/pull/461) by [@jkhales](https://github.com/jkhales))
- iOS push key setup and management now available in `eas-cli credentials`. ([#469](https://github.com/expo/eas-cli/pull/469) [#470](https://github.com/expo/eas-cli/pull/470) by [@quinlanj](https://github.com/quinlanj))
- Support new build status: `new`. ([#475](https://github.com/expo/eas-cli/pull/475) by [@dsokal](https://github.com/dsokal))
### 🧹 Chores
- Deprecate `--skip-credentials-check` flag because it doesn't do anything and is no longer needed. ([#442](https://github.com/expo/eas-cli/pull/442) by [@brentvatne](https://github.com/brentvatne))
- Move android credentials code to new Graphql API. ([#439](https://github.com/expo/eas-cli/pull/439) [#440](https://github.com/expo/eas-cli/pull/440) [#438](https://github.com/expo/eas-cli/pull/438) [#443](https://github.com/expo/eas-cli/pull/443) [#447](https://github.com/expo/eas-cli/pull/447) [#451](https://github.com/expo/eas-cli/pull/451) [#455](https://github.com/expo/eas-cli/pull/455) by [@quinlanj](https://github.com/quinlanj))
- Prepare Graphql infra to support iOS push keys. ([#456](https://github.com/expo/eas-cli/pull/456) by [@quinlanj](https://github.com/quinlanj))
- Improve credentials DX ([#448](https://github.com/expo/eas-cli/pull/448) [#449](https://github.com/expo/eas-cli/pull/449) by [@quinlanj](https://github.com/quinlanj))
- Add analytics on dev client builds. ([#454](https://github.com/expo/eas-cli/pull/454) by [@fson](https://github.com/fson))
- Support non-git projects. ([#462](https://github.com/expo/eas-cli/pull/462) by [@wkozyra95](https://github.com/wkozyra95))
## [0.17.0](https://github.com/expo/eas-cli/releases/tag/v0.17.0) - 2021-06-02
### 🐛 Bug fixes
- Fix bundle identifier resolution when native target is not provided. ([#434](https://github.com/expo/eas-cli/pull/434) by [@dsokal](https://github.com/dsokal))
- Fix git repo root path getter on Windows. ([#429](https://github.com/expo/eas-cli/pull/429) by [@brentvatne](https://github.com/brentvatne))
- Fix resolving Android application identifier. ([#431](https://github.com/expo/eas-cli/pull/431) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- Android credentials setup now on Graphql API. ([#434](https://github.com/expo/eas-cli/pull/427) by [@quinlanj](https://github.com/quinlanj))
## [0.16.0](https://github.com/expo/eas-cli/releases/tag/v0.16.0) - 2021-05-26
### 🎉 New features
- Opt out of capability syncing with `EXPO_NO_CAPABILITY_SYNC=1`. ([#426](https://github.com/expo/eas-cli/pull/426) by [@brentvatne](https://github.com/brentvatne))
- Add more verbose logging around capability syncing to help debug reported issues. ([#426](https://github.com/expo/eas-cli/pull/426) by [@brentvatne](https://github.com/brentvatne))
- Add managed credentials support for multi-target iOS projects. ([#414](https://github.com/expo/eas-cli/pull/414) by [@dsokal](https://github.com/dsokal))
## [0.15.1](https://github.com/expo/eas-cli/releases/tag/v0.15.1) - 2021-05-25
### 🎉 New features
- Support auto capabilities in managed workflow using Expo config plugin introspection. ([#419](https://github.com/expo/eas-cli/pull/419) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- Fix sending `appBuildVersion` as part of the build metadata. ([#423](https://github.com/expo/eas-cli/pull/423) by [@dsokal](https://github.com/dsokal))
### 🧹 Chores
## [0.15.0](https://github.com/expo/eas-cli/releases/tag/v0.15.0) - 2021-05-20
### 🛠 Breaking changes
+4 -4
View File
@@ -3,10 +3,6 @@
1. Run `yarn release` in the repository root folder. This will create a new tag like `v1.0.0`.
1. **Wait until the `release` GitHub workflow has finished.**
![Wait for all three jobs to complete](./.gh-assets/release-workflow.png)
1. Once the release is ready to go, you can go to https://github.com/expo/eas-cli/releases and mark it as not draft.
- Remember to copy the changelog entries from `CHANGELOG.md`.
- Before hitting the submit button, make sure there are 5 assets available under the release entry.
![There are 5 assets for each release](./.gh-assets/draft-release.png)
1. Run `yarn lerna publish from-git` in the repository root folder. This will publish the packages to npm.
- This command works only if `HEAD` is on the release commit, e.g. when `git log -1` prints:
@@ -31,6 +27,10 @@
1. Update `CHANGELOG.md`.
- Add the new section with the release version in the title.
- Leave the `main` section empty (without any changelog entries).
1. Once the release is ready to go, you can go to https://github.com/expo/eas-cli/releases and mark it as not draft.
- Remember to copy the changelog entries from `CHANGELOG.md`.
- Before hitting the submit button, make sure there are 5 assets available under the release entry.
![There are 5 assets for each release](./.gh-assets/draft-release.png)
1. If any of the changelog entries have been tagged with `[EAS BUILD API]`
- Open `package.json` and note the version of `@expo/eas-build-job` used in the eas-cli.
- Find the `Publish` commit in [expo/eas-build](https://github.com/expo/eas-build/commit/) for that version. In most cases, it will be the latest `Publish` commit.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.15.1"
"version": "0.18.2"
}
+24 -27
View File
@@ -69,7 +69,7 @@ ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/account/login.ts)_
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -83,7 +83,7 @@ ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/account/logout.ts)_
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:view`
@@ -97,7 +97,7 @@ ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/account/view.ts)_
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -108,7 +108,7 @@ USAGE
$ eas analytics [STATUS]
```
_See code: [build/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/analytics.ts)_
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/analytics.ts)_
## `eas build`
@@ -124,12 +124,11 @@ OPTIONS
--local Run build locally [experimental]
--non-interactive Run command in --non-interactive mode
--profile=profile [default: release] Name of the build profile from eas.json
--skip-credentials-check Skip validation of build credentials
--skip-project-configuration Skip project configuration
--[no-]wait Wait for build(s) to complete
```
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/build/index.ts)_
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -140,7 +139,7 @@ USAGE
$ eas build:cancel [BUILD_ID]
```
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/build/cancel.ts)_
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -152,10 +151,9 @@ USAGE
OPTIONS
-p, --platform=(android|ios|all) Platform to configure
--allow-experimental Enable experimental configuration steps.
```
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/build/configure.ts)_
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:list`
@@ -168,10 +166,10 @@ USAGE
OPTIONS
--limit=limit
--platform=(all|android|ios)
--status=(in-queue|in-progress|errored|finished|canceled)
--status=(new|in-queue|in-progress|errored|finished|canceled)
```
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/build/list.ts)_
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:view [BUILD_ID]`
@@ -182,7 +180,7 @@ USAGE
$ eas build:view [BUILD_ID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/build/view.ts)_
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/build/view.ts)_
## `eas credentials`
@@ -193,7 +191,7 @@ USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/credentials.ts)_
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/credentials.ts)_
## `eas device:create`
@@ -204,7 +202,7 @@ USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/device/create.ts)_
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:list`
@@ -218,7 +216,7 @@ OPTIONS
--apple-team-id=apple-team-id
```
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/device/list.ts)_
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:view [UDID]`
@@ -229,7 +227,7 @@ USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/device/view.ts)_
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -262,7 +260,7 @@ OPTIONS
--value=value Value of the secret
```
_See code: [build/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/secret/create.ts)_
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/secret/create.ts)_
## `eas secret:delete`
@@ -279,7 +277,7 @@ DESCRIPTION
Unsure where to find the secret's ID? Run eas secrets:list
```
_See code: [build/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/secret/delete.ts)_
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/secret/delete.ts)_
## `eas secret:list`
@@ -290,7 +288,7 @@ USAGE
$ eas secret:list
```
_See code: [build/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/secret/list.ts)_
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/secret/list.ts)_
## `eas submit --platform=(android|ios)`
@@ -316,8 +314,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)
@@ -370,7 +367,7 @@ EXAMPLES
and provide its App ID
```
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/submit.ts)_
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/submit.ts)_
## `eas webhook:create`
@@ -389,7 +386,7 @@ OPTIONS
--url=url Webhook URL
```
_See code: [build/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/webhook/create.ts)_
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -403,7 +400,7 @@ ARGUMENTS
ID ID of the webhook to delete
```
_See code: [build/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/webhook/delete.ts)_
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -417,7 +414,7 @@ OPTIONS
--event=(BUILD) Event type that triggers the webhook
```
_See code: [build/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/webhook/list.ts)_
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -437,7 +434,7 @@ OPTIONS
--url=url Webhook URL
```
_See code: [build/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/webhook/update.ts)_
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -451,5 +448,5 @@ ARGUMENTS
ID ID of the webhook to view
```
_See code: [build/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v0.15.1/build/commands/webhook/view.ts)_
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v0.18.2/packages/eas-cli/src/commands/webhook/view.ts)_
<!-- commandsstop -->
+413 -29
View File
@@ -1696,6 +1696,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "subscription",
"description": "Subscription info visible to members that have VIEWER role",
"args": [],
"type": {
"kind": "OBJECT",
"name": "SubscriptionDetails",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appleTeams",
"description": "iOS credentials for account",
@@ -3863,6 +3875,12 @@
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "NEW",
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "IN_QUEUE",
"description": "",
@@ -4160,6 +4178,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "channel",
"description": "",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metrics",
"description": "",
@@ -6564,6 +6594,18 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appleAppSpecificPasswordId",
"description": "",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -7898,6 +7940,30 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iosAppCredentialsList",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "IosAppCredentials",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -14950,13 +15016,9 @@
"name": "appleTeamName",
"description": "",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
@@ -15294,6 +15356,37 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deleteBuild",
"description": "Delete an EAS Build build",
"args": [
{
"name": "buildId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Build",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "cancel",
"description": "Cancel an EAS Build build",
@@ -15580,6 +15673,16 @@
},
"defaultValue": null
},
{
"name": "updates",
"description": "",
"type": {
"kind": "INPUT_OBJECT",
"name": "BuildUpdatesInput",
"ofType": null
},
"defaultValue": null
},
{
"name": "secrets",
"description": "",
@@ -15703,6 +15806,27 @@
],
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "BuildUpdatesInput",
"description": "",
"fields": null,
"inputFields": [
{
"name": "channel",
"description": "",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AndroidJobSecretsInput",
@@ -16044,6 +16168,16 @@
},
"defaultValue": null
},
{
"name": "channel",
"description": "",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "distribution",
"description": "",
@@ -16304,6 +16438,16 @@
},
"defaultValue": null
},
{
"name": "updates",
"description": "",
"type": {
"kind": "INPUT_OBJECT",
"name": "BuildUpdatesInput",
"ofType": null
},
"defaultValue": null
},
{
"name": "secrets",
"description": "",
@@ -16432,6 +16576,16 @@
},
"defaultValue": null
},
{
"name": "updates",
"description": "",
"type": {
"kind": "INPUT_OBJECT",
"name": "BuildUpdatesInput",
"ofType": null
},
"defaultValue": null
},
{
"name": "distribution",
"description": "",
@@ -16806,6 +16960,16 @@
},
"defaultValue": null
},
{
"name": "updates",
"description": "",
"type": {
"kind": "INPUT_OBJECT",
"name": "BuildUpdatesInput",
"ofType": null
},
"defaultValue": null
},
{
"name": "distribution",
"description": "",
@@ -16933,9 +17097,13 @@
}
],
"type": {
"kind": "OBJECT",
"name": "IosAppBuildCredentials",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "IosAppBuildCredentials",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
@@ -16974,9 +17142,13 @@
}
],
"type": {
"kind": "OBJECT",
"name": "IosAppBuildCredentials",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "IosAppBuildCredentials",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
@@ -17015,9 +17187,13 @@
}
],
"type": {
"kind": "OBJECT",
"name": "IosAppBuildCredentials",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "IosAppBuildCredentials",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
@@ -17134,9 +17310,13 @@
}
],
"type": {
"kind": "OBJECT",
"name": "IosAppCredentials",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "IosAppCredentials",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
@@ -17175,9 +17355,58 @@
}
],
"type": {
"kind": "OBJECT",
"name": "IosAppCredentials",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "IosAppCredentials",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "setAppSpecificPassword",
"description": "Set the app-specific password to be used for an iOS app",
"args": [
{
"name": "id",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "appSpecificPasswordId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "IosAppCredentials",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
@@ -17198,13 +17427,9 @@
"name": "appleTeamId",
"description": "",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
@@ -17217,6 +17442,16 @@
"ofType": null
},
"defaultValue": null
},
{
"name": "appSpecificPasswordId",
"description": "",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
@@ -17612,6 +17847,37 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createIosSubmission",
"description": "Create an iOS EAS Submit submission",
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "CreateIosSubmissionInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "CreateSubmissionResult",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -17699,6 +17965,118 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "CreateIosSubmissionInput",
"description": "",
"fields": null,
"inputFields": [
{
"name": "appId",
"description": "",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "config",
"description": "",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "NewIosSubmissionConfig",
"ofType": null
}
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "NewIosSubmissionConfig",
"description": "",
"fields": null,
"inputFields": [
{
"name": "appleAppSpecificPasswordId",
"description": "",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "appleAppSpecificPassword",
"description": "",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "archiveUrl",
"description": "",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "appleIdUsername",
"description": "",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "ascAppIdentifier",
"description": "",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "UpdateChannelMutation",
@@ -19897,6 +20275,12 @@
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "DEV_CLIENT_USERS",
"description": "",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
+8 -6
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.15.1",
"version": "0.18.2",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -12,9 +12,9 @@
"@amplitude/node": "1.5.0",
"@expo/apple-utils": "0.0.0-alpha.20",
"@expo/config": "3.3.42",
"@expo/config-plugins": "1.0.32",
"@expo/eas-build-job": "0.2.35",
"@expo/eas-json": "^0.15.1",
"@expo/config-plugins": "2.0.2",
"@expo/eas-build-job": "0.2.37",
"@expo/eas-json": "^0.18.2",
"@expo/json-file": "8.2.25",
"@expo/pkcs12": "0.0.4",
"@expo/plist": "0.0.11",
@@ -41,6 +41,7 @@
"got": "11.5.2",
"graphql": "15.3.0",
"graphql-tag": "2.11.0",
"ignore": "5.1.8",
"indent-string": "4.0.0",
"jks-js": "1.0.0",
"keychain": "1.3.0",
@@ -56,6 +57,7 @@
"progress": "2.0.3",
"prompts": "2.3.2",
"qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0",
"semver": "7.3.4",
"strip-ansi": "6.0.0",
"tar": "6.0.5",
@@ -166,12 +168,12 @@
"repository": "expo/eas-cli",
"scripts": {
"postpack": "rm -f oclif.manifest.json",
"prepack": "rm -rf build && yarn build && yarn oclif-dev manifest && yarn oclif-dev readme",
"prepack": "rm -rf build && yarn build && yarn oclif-dev manifest && yarn oclif-dev readme && node patch-readme",
"build": "tsc --project tsconfig.build.json",
"watch": "yarn build --watch --preserveWatchOutput",
"typecheck": "tsc",
"test": "jest",
"version": "yarn oclif-dev readme && git add README.md",
"version": "yarn oclif-dev readme && node patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml"
},
"types": "build/index.d.ts",
+9
View File
@@ -0,0 +1,9 @@
const fs = require('fs');
// Patch `oclif-dev readme` path and link generation
let readmeContent = fs.readFileSync('README.md', 'utf8');
readmeContent = readmeContent.replace(/\[build\//g, '[src/');
readmeContent = readmeContent.replace(/build\/commands/g, 'packages/eas-cli/src/commands');
fs.writeFileSync('README.md', readmeContent);
console.log('Patched README path generation');
+6 -1
View File
@@ -96,6 +96,11 @@ function getContext() {
os_version: os.release(),
device_brand: platform,
device_model: platform,
app_version: packageJSON?.version,
source_version: packageJSON?.version,
source: 'eas cli',
};
}
export enum AnalyticsEvent {
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
}
@@ -1,5 +1,5 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import { AndroidConfig, AndroidManifest } from '@expo/config-plugins';
import fs from 'fs-extra';
import Log from '../../log';
@@ -22,12 +22,7 @@ export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig)
}
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
androidManifestPath
);
const androidManifest = await getAndroidManifestAsync(projectDir);
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
const result = AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
@@ -52,10 +47,7 @@ export async function syncUpdatesConfigurationAsync(
}
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
let androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(androidManifestPath);
let androidManifest = await getAndroidManifestAsync(projectDir);
if (!AndroidConfig.Updates.areVersionsSynced(exp, androidManifest)) {
androidManifest = AndroidConfig.Updates.setVersionsConfig(exp, androidManifest);
@@ -78,13 +70,7 @@ async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig)
throw new Error(`Missing ${gradleScriptApply} in ${buildGradlePath}`);
}
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
androidManifestPath
);
const androidManifest = await getAndroidManifestAsync(projectDir);
if (!AndroidConfig.Updates.isMainApplicationMetaDataSet(androidManifest)) {
throw new Error('Missing values in AndroidManifest.xml');
@@ -93,13 +79,7 @@ async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig)
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
);
const androidManifest = await getAndroidManifestAsync(projectDir);
return AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidConfig.Updates.Config.RELEASE_CHANNEL
@@ -108,3 +88,28 @@ export async function readReleaseChannelSafelyAsync(projectDir: string): Promise
return null;
}
}
export async function readChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const androidManifest = await getAndroidManifestAsync(projectDir);
const stringifiedRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY' //TODO-JJ AndroidConfig.Updates.Config.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY once https://github.com/expo/expo-cli/pull/3571 is published
);
if (!stringifiedRequestHeaders) {
return null;
}
return JSON.parse(stringifiedRequestHeaders)['expo-channel-name'] ?? null;
} catch (err) {
console.log({ err });
return null;
}
}
async function getAndroidManifestAsync(projectDir: string): Promise<AndroidManifest> {
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
return AndroidConfig.Manifest.readAndroidManifestAsync(androidManifestPath);
}
@@ -0,0 +1,108 @@
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import { readChannelSafelyAsync } from '../UpdatesModule';
jest.mock('fs');
afterAll(() => {
// 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());
});
const testChannel = 'test-channel';
describe(readChannelSafelyAsync, () => {
it('retrieves the channel when it is defined natively', async () => {
vol.fromJSON(
{
'./android/app/src/main/AndroidManifest.xml': AndroidManifestWithChannel,
},
'/expo-project'
);
const channel = await readChannelSafelyAsync('/expo-project');
expect(channel).toBe(testChannel);
});
it('returns null if the channel is not defined natively', async () => {
vol.fromJSON(
{
'./android/app/src/main/AndroidManifest.xml': AndroidManifestNoChannel,
},
'/expo-project'
);
const channel = await readChannelSafelyAsync('/expo-project');
expect(channel).toBeNull();
});
});
const AndroidManifestWithChannel = `
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.expo.mycoolapp">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="true"
android:theme="@style/AppTheme">
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value='{"expo-channel-name":"${testChannel}"}'/>
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>
</manifest>
`;
const AndroidManifestNoChannel = `
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.expo.mycoolapp">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>
</manifest>
`;
+18 -6
View File
@@ -2,6 +2,7 @@ import { Android, Metadata, Workflow } from '@expo/eas-build-job';
import { EasConfig } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import path from 'path';
import AndroidCredentialsProvider, {
@@ -10,8 +11,12 @@ import AndroidCredentialsProvider, {
import { createCredentialsContextAsync } from '../../credentials/context';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import Log from '../../log';
import { getOrConfigureApplicationIdAsync } from '../../project/android/applicationId';
import {
ensureApplicationIdIsDefinedForManagedProjectAsync,
getApplicationId,
} from '../../project/android/applicationId';
import { toggleConfirmAsync } from '../../prompts';
import { findAccountByName } from '../../user/Account';
import { CredentialsResult, prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
import { transformMetadata } from '../graphql';
@@ -63,12 +68,12 @@ This means that it will most likely produce an AAB and you will not be able to i
}
}
// this function throws if application id is invalid
await getOrConfigureApplicationIdAsync(commandCtx.projectDir, commandCtx.exp);
if (buildCtx.buildProfile.workflow === Workflow.MANAGED) {
await ensureApplicationIdIsDefinedForManagedProjectAsync(commandCtx.projectDir, commandCtx.exp);
}
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {},
ensureCredentialsAsync: ensureAndroidCredentialsAsync,
ensureProjectConfiguredAsync: async () => {
if (buildCtx.buildProfile.workflow === Workflow.GENERIC) {
@@ -114,16 +119,23 @@ async function ensureAndroidCredentialsAsync(
if (!shouldProvideCredentials(ctx)) {
return;
}
const androidApplicationIdentifier = getApplicationId(
ctx.commandCtx.projectDir,
ctx.commandCtx.exp
);
const provider = new AndroidCredentialsProvider(
await createCredentialsContextAsync(ctx.commandCtx.projectDir, {
nonInteractive: ctx.commandCtx.nonInteractive,
}),
{
app: {
account: nullthrows(
findAccountByName(ctx.commandCtx.user.accounts, ctx.commandCtx.accountName),
`You do not have access to account: ${ctx.commandCtx.accountName}`
),
projectName: ctx.commandCtx.projectName,
accountName: ctx.commandCtx.accountName,
androidApplicationIdentifier,
},
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
}
);
const { credentialsSource } = ctx.buildProfile;
@@ -3,17 +3,17 @@ import { AndroidConfig } from '@expo/config-plugins';
import Log from '../../log';
import {
getOrConfigureApplicationIdAsync,
ensureApplicationIdIsDefinedForManagedProjectAsync,
warnIfAndroidPackageDefinedInAppConfigForGenericProject,
} from '../../project/android/applicationId';
import { gitAddAsync } from '../../utils/git';
import vcs from '../../vcs';
import { ConfigureContext } from '../context';
import { isExpoUpdatesInstalled } from '../utils/updates';
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasAndroidNativeProject) {
await getOrConfigureApplicationIdAsync(ctx.projectDir, ctx.exp);
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
return;
}
@@ -22,7 +22,7 @@ export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void
await AndroidConfig.EasBuild.configureEasBuildAsync(ctx.projectDir);
const easGradlePath = AndroidConfig.EasBuild.getEasBuildGradlePath(ctx.projectDir);
await gitAddAsync(easGradlePath, { intentToAdd: true });
await vcs.trackFileAsync(easGradlePath);
if (isExpoUpdatesInstalled(ctx.projectDir)) {
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
@@ -12,6 +12,7 @@ export function transformGenericJob(job: Android.GenericJob): AndroidGenericJobI
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
updates: job.updates,
secrets: job.secrets,
builderEnvironment: job.builderEnvironment,
cache: job.cache,
@@ -25,6 +26,7 @@ export function transformManagedJob(job: Android.ManagedJob): AndroidManagedJobI
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
updates: job.updates,
secrets: job.secrets,
builderEnvironment: job.builderEnvironment,
cache: job.cache,
@@ -3,10 +3,9 @@ import { AndroidGenericBuildProfile, AndroidManagedBuildProfile } from '@expo/ea
import path from 'path';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { readEnvironmentSecretsAsync } from '../../credentials/credentialsJson/read';
import { getUsername } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { gitRootDirectoryAsync } from '../../utils/git';
import vcs from '../../vcs';
import { BuildContext } from '../context';
import { Platform } from '../types';
@@ -39,7 +38,6 @@ interface CommonJobProperties {
buildCredentials?: {
keystore: Android.Keystore;
};
environmentSecrets?: Record<string, string>;
};
}
@@ -47,8 +45,7 @@ async function prepareJobCommonAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Partial<CommonJobProperties>> {
const environmentSecrets = await readEnvironmentSecretsAsync(ctx.commandCtx.projectDir);
const credentials = jobData.credentials;
const { credentials } = jobData;
const buildCredentials = credentials
? {
buildCredentials: {
@@ -78,7 +75,6 @@ async function prepareJobCommonAsync(
clear: ctx.commandCtx.clearCache,
},
secrets: {
...(environmentSecrets ? { environmentSecrets } : {}),
...buildCredentials,
},
};
@@ -90,13 +86,14 @@ async function prepareGenericJobAsync(
buildProfile: AndroidGenericBuildProfile
): Promise<Partial<Android.GenericJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
path.relative(await vcs.getRootPathAsync(), ctx.commandCtx.projectDir) || '.';
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.GENERIC,
gradleCommand: buildProfile.gradleCommand,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
updates: { channel: buildProfile.channel },
projectRootDirectory,
};
}
@@ -107,7 +104,7 @@ async function prepareManagedJobAsync(
buildProfile: AndroidManagedBuildProfile
): Promise<Partial<Android.ManagedJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
path.relative(await vcs.getRootPathAsync(), ctx.commandCtx.projectDir) || '.';
const username = getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync());
return {
...(await prepareJobCommonAsync(ctx, jobData)),
@@ -115,6 +112,7 @@ async function prepareManagedJobAsync(
username,
buildType: buildProfile.buildType,
releaseChannel: buildProfile.releaseChannel,
updates: { channel: buildProfile.channel },
projectRootDirectory,
};
}
+15 -32
View File
@@ -9,6 +9,7 @@ import { promptAsync } from '../prompts';
import { uploadAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import vcs from '../vcs';
import { requestedPlatformDisplayNames } from './constants';
import { BuildContext } from './context';
import { runLocalBuildAsync } from './local';
@@ -16,46 +17,34 @@ import { collectMetadata } from './metadata';
import { Platform, TrackingContext } from './types';
import Analytics, { Event } from './utils/analytics';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
commitChangedFilesAsync,
commitPromptAsync,
isGitStatusCleanAsync,
makeProjectTarballAsync,
showDiffAsync,
} from './utils/repository';
import { commitPromptAsync, makeProjectTarballAsync } from './utils/repository';
export interface CredentialsResult<Credentials> {
source: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
credentials: Credentials;
}
interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration, TJob extends Job> {
export interface JobData<Credentials> {
credentials?: Credentials;
projectArchive: ArchiveSource;
}
interface Builder<TPlatform extends Platform, Credentials, TJob extends Job> {
ctx: BuildContext<TPlatform>;
projectConfiguration: ProjectConfiguration;
ensureCredentialsAsync(
ctx: BuildContext<TPlatform>
): Promise<CredentialsResult<Credentials> | undefined>;
ensureProjectConfiguredAsync(ctx: BuildContext<TPlatform>): Promise<void>;
prepareJobAsync(
ctx: BuildContext<TPlatform>,
jobData: {
credentials?: Credentials;
projectConfiguration?: ProjectConfiguration;
projectArchive: ArchiveSource;
}
): Promise<Job>;
prepareJobAsync(ctx: BuildContext<TPlatform>, jobData: JobData<Credentials>): Promise<Job>;
sendBuildRequestAsync(appId: string, job: TJob, metadata: Metadata): Promise<BuildResult>;
}
export async function prepareBuildRequestForPlatformAsync<
TPlatform extends Platform,
Credentials,
ProjectConfiguration,
TJob extends Job
>(
builder: Builder<TPlatform, Credentials, ProjectConfiguration, TJob>
): Promise<() => Promise<string | undefined>> {
>(builder: Builder<TPlatform, Credentials, TJob>): Promise<() => Promise<string | undefined>> {
const credentialsResult = await withAnalyticsAsync(
async () => await builder.ensureCredentialsAsync(builder.ctx),
{
@@ -72,7 +61,7 @@ export async function prepareBuildRequestForPlatformAsync<
});
}
if (!(await isGitStatusCleanAsync())) {
if (await vcs.hasUncommittedChangesAsync()) {
Log.addNewLineIfNone();
await reviewAndCommitChangesAsync(
`[EAS Build] Run EAS Build for ${
@@ -100,7 +89,6 @@ export async function prepareBuildRequestForPlatformAsync<
const job = await builder.prepareJobAsync(builder.ctx, {
projectArchive,
credentials: credentialsResult?.credentials,
projectConfiguration: builder.projectConfiguration,
});
return async () => {
@@ -171,13 +159,8 @@ async function uploadProjectAsync<TPlatform extends Platform>(
}
}
async function sendBuildRequestAsync<
TPlatform extends Platform,
Credentials,
ProjectConfiguration,
TJob extends Job
>(
builder: Builder<TPlatform, Credentials, ProjectConfiguration, TJob>,
async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJob extends Job>(
builder: Builder<TPlatform, Credentials, TJob>,
job: TJob,
metadata: Metadata
): Promise<string> {
@@ -237,7 +220,7 @@ async function reviewAndCommitChangesAsync(
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
): Promise<void> {
if (process.env.EAS_BUILD_AUTOCOMMIT) {
await commitChangedFilesAsync(initialCommitMessage);
await vcs.commitAsync({ commitMessage: initialCommitMessage, commitAllFiles: false });
Log.withTick('Committed changes.');
return;
}
@@ -270,7 +253,7 @@ async function reviewAndCommitChangesAsync(
await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes.');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await showDiffAsync();
await vcs.showDiffAsync();
await reviewAndCommitChangesAsync(initialCommitMessage, {
nonInteractive,
askedFirstTime: false,
+40 -25
View File
@@ -6,18 +6,12 @@ import path from 'path';
import Log from '../log';
import { promptAsync } from '../prompts';
import { ensureLoggedInAsync } from '../user/actions';
import { gitAddAsync } from '../utils/git';
import vcs from '../vcs';
import { configureAndroidAsync } from './android/configure';
import { ConfigureContext } from './context';
import { configureIosAsync } from './ios/configure';
import { RequestedPlatform } from './types';
import {
commitPromptAsync,
ensureGitRepoExistsAsync,
isGitStatusCleanAsync,
maybeBailOnGitStatusAsync,
showDiffAsync,
} from './utils/repository';
import { commitPromptAsync, maybeBailOnRepoStatusAsync } from './utils/repository';
const configureCommitMessage = {
[RequestedPlatform.Android]: 'Configure EAS Build for Android',
@@ -28,10 +22,9 @@ const configureCommitMessage = {
export async function configureAsync(options: {
platform: RequestedPlatform;
projectDir: string;
allowExperimental: boolean;
}): Promise<void> {
await ensureGitRepoExistsAsync();
await maybeBailOnGitStatusAsync();
await vcs.ensureRepoExistsAsync();
await maybeBailOnRepoStatusAsync();
const { exp } = getConfig(options.projectDir, { skipSDKVersionRequirement: true });
@@ -39,7 +32,6 @@ export async function configureAsync(options: {
user: await ensureLoggedInAsync(),
projectDir: options.projectDir,
exp,
allowExperimental: options.allowExperimental,
requestedPlatform: options.platform,
shouldConfigureAndroid: [RequestedPlatform.All, RequestedPlatform.Android].includes(
options.platform
@@ -58,7 +50,7 @@ export async function configureAsync(options: {
await configureIosAsync(ctx);
}
if (!(await isGitStatusCleanAsync())) {
if (await vcs.hasUncommittedChangesAsync()) {
Log.newLine();
await reviewAndCommitChangesAsync(configureCommitMessage[options.platform]);
} else {
@@ -67,6 +59,37 @@ export async function configureAsync(options: {
}
}
const MANAGED_DEFAULTS = {
release: {
workflow: 'managed',
},
development: {
workflow: 'managed',
buildType: 'development-client',
distribution: 'internal',
},
};
const ANDROID_GENERIC_DEFAULTS = {
release: {
workflow: 'generic',
},
development: {
workflow: 'generic',
gradleCommand: ':app:assembleDebug',
distribution: 'internal',
},
};
const IOS_GENERIC_DEFAULTS = {
release: {
workflow: 'generic',
},
development: {
workflow: 'generic',
schemeBuildConfiguration: 'Debug',
distribution: 'internal',
},
};
export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<void> {
const easJsonPath = path.join(ctx.projectDir, 'eas.json');
let existingEasJson;
@@ -101,27 +124,19 @@ export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<v
...existingEasJson,
...(shouldInitAndroid
? {
android: {
release: {
workflow: ctx.hasAndroidNativeProject ? 'generic' : 'managed',
},
},
android: ctx.hasAndroidNativeProject ? ANDROID_GENERIC_DEFAULTS : MANAGED_DEFAULTS,
}
: null),
...(shouldInitIOS
? {
ios: {
release: {
workflow: ctx.hasIosNativeProject ? 'generic' : 'managed',
},
},
ios: ctx.hasIosNativeProject ? IOS_GENERIC_DEFAULTS : MANAGED_DEFAULTS,
}
: null),
},
};
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
await gitAddAsync(easJsonPath, { intentToAdd: true });
await vcs.trackFileAsync(easJsonPath);
Log.withTick(`${existingEasJson ? 'Updated' : 'Generated'} eas.json`);
}
@@ -155,7 +170,7 @@ async function reviewAndCommitChangesAsync(
await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await showDiffAsync();
await vcs.showDiffAsync();
await reviewAndCommitChangesAsync(initialCommitMessage, false);
}
}
+38 -5
View File
@@ -1,5 +1,8 @@
import { ExpoConfig } from '@expo/config';
import { Workflow } from '@expo/eas-build-job';
import { AndroidBuildProfile, EasConfig, IosBuildProfile } from '@expo/eas-json';
import JsonFile from '@expo/json-file';
import resolveFrom from 'resolve-from';
import { v4 as uuidv4 } from 'uuid';
import { getProjectAccountName } from '../project/projectUtils';
@@ -21,7 +24,6 @@ export interface CommandContext {
nonInteractive: boolean;
local: boolean;
clearCache: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
}
@@ -35,7 +37,6 @@ export async function createCommandContextAsync({
nonInteractive = false,
local,
clearCache = false,
skipCredentialsCheck = false,
skipProjectConfiguration = false,
waitForBuildEnd,
}: {
@@ -47,7 +48,6 @@ export async function createCommandContextAsync({
nonInteractive: boolean;
local: boolean;
clearCache: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
}): Promise<CommandContext> {
@@ -67,7 +67,6 @@ export async function createCommandContextAsync({
nonInteractive,
local,
clearCache,
skipCredentialsCheck,
skipProjectConfiguration,
waitForBuildEnd,
};
@@ -77,7 +76,6 @@ export interface ConfigureContext {
user: Actor;
projectDir: string;
exp: ExpoConfig;
allowExperimental: boolean;
requestedPlatform: RequestedPlatform;
shouldConfigureAndroid: boolean;
shouldConfigureIos: boolean;
@@ -111,6 +109,7 @@ export function createBuildContext<T extends Platform>({
}
const accountId = findAccountByName(commandCtx.user.accounts, commandCtx.accountName)?.id;
const devClientProperties = getDevClientEventProperties(platform, commandCtx, buildProfile);
const trackingCtx = {
tracking_id: uuidv4(),
platform,
@@ -118,6 +117,7 @@ export function createBuildContext<T extends Platform>({
account_name: commandCtx.accountName,
project_id: commandCtx.projectId,
project_type: buildProfile.workflow,
...devClientProperties,
};
Analytics.logEvent(Event.BUILD_COMMAND, trackingCtx);
return {
@@ -127,3 +127,36 @@ export function createBuildContext<T extends Platform>({
buildProfile,
};
}
function getDevClientEventProperties(
platform: Platform,
commandCtx: CommandContext,
buildProfile: AndroidBuildProfile | IosBuildProfile
): Partial<TrackingContext> {
let includesDevClient;
const version = tryGetDevClientVersion(commandCtx.projectDir);
if (buildProfile.workflow === Workflow.MANAGED) {
includesDevClient = buildProfile.buildType === 'development-client';
} else if (platform === Platform.ANDROID && 'gradleCommand' in buildProfile) {
includesDevClient = Boolean(version && buildProfile.gradleCommand?.includes('Debug'));
} else if (platform === Platform.IOS && 'schemeBuildConfiguration' in buildProfile) {
includesDevClient = Boolean(version && buildProfile.schemeBuildConfiguration === 'Debug');
} else {
includesDevClient = false;
}
if (version) {
return { dev_client: includesDevClient, dev_client_version: version };
} else {
return { dev_client: includesDevClient };
}
}
function tryGetDevClientVersion(projectDir: string): string | null {
try {
const pkg = JsonFile.read(resolveFrom(projectDir, 'expo-dev-client/package.json'));
return pkg.version?.toString() ?? null;
} catch {
return null;
}
}
+9 -3
View File
@@ -7,16 +7,17 @@ import { BuildFragment, BuildStatus } from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log from '../log';
import { sleep } from '../utils/promise';
import vcs from '../vcs';
import { prepareAndroidBuildAsync } from './android/build';
import { CommandContext } from './context';
import { prepareIosBuildAsync } from './ios/build';
import { Platform, RequestedPlatform } from './types';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureGitRepoExistsAsync, ensureGitStatusIsCleanAsync } from './utils/repository';
import { ensureRepoIsCleanAsync } from './utils/repository';
export async function buildAsync(commandCtx: CommandContext): Promise<void> {
await ensureGitRepoExistsAsync();
await ensureGitStatusIsCleanAsync(commandCtx.nonInteractive);
await vcs.ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(commandCtx.nonInteractive);
const scheduledBuilds = await startBuildsAsync(commandCtx);
if (commandCtx.local) {
@@ -93,6 +94,9 @@ async function waitForBuildEndAsync(
case BuildStatus.Finished:
spinner.succeed('Build finished');
return builds;
case BuildStatus.New:
spinner.text = 'Build created';
break;
case BuildStatus.InQueue:
spinner.text = 'Build queued...';
break;
@@ -130,6 +134,7 @@ async function waitForBuildEndAsync(
spinner.fail('Some of the builds were canceled or failed.');
return builds;
} else {
const newBuilds = builds.filter(build => build?.status === BuildStatus.New).length;
const inQueue = builds.filter(build => build?.status === BuildStatus.InQueue).length;
const inProgress = builds.filter(build => build?.status === BuildStatus.InProgress).length;
const errored = builds.filter(build => build?.status === BuildStatus.Errored).length;
@@ -137,6 +142,7 @@ async function waitForBuildEndAsync(
const canceled = builds.filter(build => build?.status === BuildStatus.Canceled).length;
const unknownState = builds.length - inQueue - inProgress - errored - finished;
spinner.text = [
newBuilds && `Builds created: ${newBuilds}`,
inQueue && `Builds in queue: ${inQueue}`,
inProgress && `Builds in progress: ${inProgress}`,
canceled && `Builds canceled: ${canceled}`,
@@ -5,7 +5,7 @@ import fs from 'fs-extra';
import Log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { gitAddAsync } from '../../utils/git';
import vcs from '../../vcs';
import { ensureValidVersions } from '../utils/updates';
import { readPlistAsync, writePlistAsync } from './plist';
@@ -84,7 +84,7 @@ async function writeExpoPlistAsync(
): Promise<void> {
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
await writePlistAsync(expoPlistPath, expoPlist);
await gitAddAsync(expoPlistPath, { intentToAdd: true });
await vcs.trackFileAsync(expoPlistPath);
}
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
@@ -95,3 +95,13 @@ export async function readReleaseChannelSafelyAsync(projectDir: string): Promise
return null;
}
}
export async function readChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const expoPlist: any = await readExpoPlistAsync(projectDir); // TODO-JJ remove any once IOSConfig.ExpoPlist is updated to include `EXUpdatesRequestHeaders : Record<string,string>`
const updatesRequestHeaders = expoPlist['EXUpdatesRequestHeaders'] ?? {}; //TODO-JJ 'EXUpdatesRequestHeaders' IosConfig.Updates.Config.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY once https://github.com/expo/expo-cli/pull/3571 is published
return updatesRequestHeaders['expo-channel-name'] ?? null;
} catch (err) {
return null;
}
}
@@ -1,3 +0,0 @@
export function getBundleIdentifier(): string {
return 'com.bundle.id';
}
@@ -0,0 +1,88 @@
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import { readChannelSafelyAsync } from '../UpdatesModule';
jest.mock('fs');
afterAll(() => {
// 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());
});
const testChannel = 'test-channel';
describe(readChannelSafelyAsync, () => {
it('retrieves the channel when it is defined natively', async () => {
vol.fromJSON(
{
'ios/expo-project/Supporting/Expo.plist': ExpoPlistWithChannel,
'ios/expo-project/AppDelegate.m': '',
},
'/expo-project'
);
const channel = await readChannelSafelyAsync('/expo-project');
expect(channel).toBe(testChannel);
});
it('returns null if the channel is not defined natively', async () => {
vol.fromJSON(
{
'ios/expo-project/Supporting/Expo.plist': ExpoPlistWithOutChannel,
'ios/expo-project/AppDelegate.m': '',
},
'/expo-project'
);
const channel = await readChannelSafelyAsync('/expo-project');
expect(channel).toBeNull();
});
});
const ExpoPlistWithChannel = `
<plist version="1.0">
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<true/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesReleaseChannel</key>
<string>default</string>
<key>EXUpdatesSDKVersion</key>
<string>40.0.0</string>
<key>EXUpdatesURL</key>
<string>https://exp.host/@user/expo-project</string>
<key>EXUpdatesRequestHeaders</key>
<dict>
<key>expo-channel-name</key>
<string>${testChannel}</string>
</dict>
</dict>
</plist>
`;
const ExpoPlistWithOutChannel = `
<plist version="1.0">
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<true/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesReleaseChannel</key>
<string>default</string>
<key>EXUpdatesSDKVersion</key>
<string>40.0.0</string>
<key>EXUpdatesURL</key>
<string>https://exp.host/@user/expo-project</string>
</dict>
</plist>
`;
+37 -68
View File
@@ -1,23 +1,22 @@
import { IOSConfig } from '@expo/config-plugins';
import { Ios, Metadata, Workflow } from '@expo/eas-build-job';
import { Ios, Job, Metadata, Workflow } from '@expo/eas-build-job';
import { EasConfig } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import sortBy from 'lodash/sortBy';
import path from 'path';
import { IosCredentials } from '../../credentials/ios/types';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import Log from '../../log';
import { getOrConfigureBundleIdentifierAsync } from '../../project/ios/bundleIdentifier';
import { promptAsync } from '../../prompts';
import { prepareBuildRequestForPlatformAsync } from '../build';
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
import { resolveTargetsAsync } from '../../project/ios/target';
import { JobData, prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
import { transformMetadata } from '../graphql';
import { Platform } from '../types';
import { validateAndSyncProjectConfigurationAsync } from './configure';
import { ensureIosCredentialsAsync } from './credentials';
import { transformGenericJob, transformManagedJob } from './graphql';
import { prepareJobAsync, sanitizedTargetName } from './prepareJob';
import { prepareJobAsync } from './prepareJob';
export async function prepareIosBuildAsync(
commandCtx: CommandContext,
@@ -28,9 +27,10 @@ export async function prepareIosBuildAsync(
platform: Platform.IOS,
easConfig,
});
const { buildProfile } = buildCtx;
if (
buildCtx.buildProfile.workflow === Workflow.GENERIC &&
buildProfile.workflow === Workflow.GENERIC &&
!(await fs.pathExists(path.join(commandCtx.projectDir, 'ios')))
) {
throw new Error(
@@ -40,42 +40,47 @@ export async function prepareIosBuildAsync(
);
}
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
if (buildProfile.workflow === Workflow.MANAGED) {
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(
commandCtx.projectDir,
commandCtx.exp
);
} 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');
}
}
// this function throws if bundle id is invalid
await getOrConfigureBundleIdentifierAsync(commandCtx.projectDir, commandCtx.exp);
const xcodeBuildContext = await resolveXcodeBuildContextAsync(
{
projectDir: commandCtx.projectDir,
nonInteractive: commandCtx.nonInteractive,
exp: commandCtx.exp,
},
buildProfile
);
const targets = await resolveTargetsAsync(
{
projectDir: commandCtx.projectDir,
exp: commandCtx.exp,
},
xcodeBuildContext
);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {
iosBuildScheme,
iosApplicationTarget,
ensureCredentialsAsync: async (ctx: BuildContext<Platform.IOS>) => {
return ensureIosCredentialsAsync(ctx, targets);
},
ensureCredentialsAsync: ensureIosCredentialsAsync,
ensureProjectConfiguredAsync: async () => {
await validateAndSyncProjectConfigurationAsync({
projectDir: commandCtx.projectDir,
exp: commandCtx.exp,
buildProfile: buildCtx.buildProfile,
buildProfile,
});
},
prepareJobAsync,
prepareJobAsync: async (
ctx: BuildContext<Platform.IOS>,
jobData: JobData<IosCredentials>
): Promise<Job> => {
return await prepareJobAsync(ctx, { ...jobData, buildScheme: xcodeBuildContext.buildScheme });
},
sendBuildRequestAsync: async (
appId: string,
job: Ios.Job,
@@ -100,39 +105,3 @@ export async function prepareIosBuildAsync(
},
});
}
async function resolveSchemeAsync(ctx: BuildContext<Platform.IOS>): Promise<string> {
const schemes = IOSConfig.BuildScheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
if (schemes.length === 1) {
return schemes[0];
}
const sortedSchemes = sortBy(schemes);
Log.newLine();
Log.log(
`We've found multiple schemes in your Xcode project: ${chalk.bold(sortedSchemes.join(', '))}`
);
Log.log(
`You can specify the scheme you want to build at ${chalk.bold(
`builds.ios.${ctx.commandCtx.profile}.scheme`
)} in eas.json.`
);
if (ctx.commandCtx.nonInteractive) {
const withoutTvOS = sortedSchemes.filter(i => !i.includes('tvOS'));
const scheme = withoutTvOS.length > 0 ? withoutTvOS[0] : sortedSchemes[0];
Log.log(
`You've run Expo CLI in non-interactive mode, choosing the ${chalk.bold(scheme)} scheme.`
);
Log.newLine();
return scheme;
} else {
const { selectedScheme } = await promptAsync({
type: 'select',
name: 'selectedScheme',
message: 'Which scheme would you like to build now?',
choices: sortedSchemes.map(scheme => ({ title: scheme, value: scheme })),
});
Log.newLine();
return selectedScheme as string;
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { IosBuildProfile, VersionAutoIncrement } from '@expo/eas-json';
import Log from '../../log';
import {
getOrConfigureBundleIdentifierAsync,
ensureBundleIdentifierIsDefinedForManagedProjectAsync,
warnIfBundleIdentifierDefinedInAppConfigForGenericProject,
} from '../../project/ios/bundleIdentifier';
import { ConfigureContext } from '../context';
@@ -14,7 +14,7 @@ import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './ver
export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasIosNativeProject) {
await getOrConfigureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
return;
}
+23 -56
View File
@@ -1,75 +1,42 @@
import { Workflow } from '@expo/eas-build-job';
import { CredentialsSource, IosDistributionType, IosEnterpriseProvisioning } from '@expo/eas-json';
import { createCredentialsContextAsync } from '../../credentials/context';
import IosCredentialsProvider, {
IosCredentials,
} from '../../credentials/ios/IosCredentialsProvider';
import { getAppLookupParamsFromContext } from '../../credentials/ios/actions/BuildCredentialsUtils';
import { IosCapabilitiesOptions } from '../../credentials/ios/appstore/ensureAppExists';
import IosCredentialsProvider from '../../credentials/ios/IosCredentialsProvider';
import { getAppFromContext } from '../../credentials/ios/actions/BuildCredentialsUtils';
import { resolveEntitlementsJsonAsync } from '../../credentials/ios/appstore/entitlements';
import { AppLookupParams } from '../../credentials/ios/credentials';
import { getBundleIdentifier } from '../../project/ios/bundleIdentifier';
import { IosCredentials, Target } from '../../credentials/ios/types';
import { CredentialsResult } from '../build';
import { BuildContext } from '../context';
import { Platform } from '../types';
import { logCredentialsSource } from '../utils/credentials';
export async function ensureIosCredentialsAsync(
ctx: BuildContext<Platform.IOS>
buildCtx: BuildContext<Platform.IOS>,
targets: Target[]
): Promise<CredentialsResult<IosCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
if (!shouldProvideCredentials(buildCtx)) {
return;
}
const bundleIdentifier = getBundleIdentifier(ctx.commandCtx.projectDir, ctx.commandCtx.exp);
return await resolveIosCredentialsAsync(ctx.commandCtx.projectDir, {
app: {
accountName: ctx.commandCtx.accountName,
projectName: ctx.commandCtx.projectName,
bundleIdentifier,
},
const { projectDir } = buildCtx.commandCtx;
const ctx = await createCredentialsContextAsync(projectDir, {
nonInteractive: buildCtx.commandCtx.nonInteractive,
});
const provider = new IosCredentialsProvider(ctx, {
app: getAppFromContext(ctx),
targets,
iosCapabilitiesOptions: {
entitlements: await resolveEntitlementsJsonAsync(
ctx.commandCtx.projectDir,
ctx.buildProfile.workflow
buildCtx.commandCtx.projectDir,
buildCtx.buildProfile.workflow
),
},
workflow: ctx.buildProfile.workflow,
credentialsSource: ctx.buildProfile.credentialsSource,
distribution: ctx.buildProfile.distribution ?? 'store',
enterpriseProvisioning: ctx.buildProfile.enterpriseProvisioning,
nonInteractive: ctx.commandCtx.nonInteractive,
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
distribution: buildCtx.buildProfile.distribution ?? 'store',
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
});
}
interface ResolveCredentialsParams {
app: AppLookupParams;
workflow: Workflow;
credentialsSource: CredentialsSource;
distribution: IosDistributionType;
enterpriseProvisioning?: IosEnterpriseProvisioning;
iosCapabilitiesOptions?: IosCapabilitiesOptions;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
}
const { credentialsSource } = buildCtx.buildProfile;
export async function resolveIosCredentialsAsync(
projectDir: string,
params: ResolveCredentialsParams
): Promise<CredentialsResult<IosCredentials>> {
const ctx = await createCredentialsContextAsync(projectDir, {
nonInteractive: params.nonInteractive,
});
const app = getAppLookupParamsFromContext(ctx);
const provider = new IosCredentialsProvider(ctx, {
app,
iosCapabilitiesOptions: params.iosCapabilitiesOptions,
distribution: params.distribution,
enterpriseProvisioning: params.enterpriseProvisioning,
skipCredentialsCheck: params.skipCredentialsCheck,
});
const { credentialsSource } = params;
logCredentialsSource(credentialsSource, Platform.IOS);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
@@ -77,6 +44,6 @@ export async function resolveIosCredentialsAsync(
};
}
function shouldProvideCredentials(ctx: BuildContext<Platform.IOS>): boolean {
return ctx.buildProfile.distribution !== 'simulator';
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
return buildCtx.buildProfile.distribution !== 'simulator';
}
@@ -15,6 +15,7 @@ export function transformGenericJob(job: Ios.GenericJob): IosGenericJobInput {
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
updates: job.updates,
distribution: job.distribution && transformDistributionType(job.distribution),
secrets: transformIosSecrets(job.secrets),
builderEnvironment: job.builderEnvironment,
@@ -30,6 +31,7 @@ export function transformManagedJob(job: Ios.ManagedJob): IosManagedJobInput {
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
updates: job.updates,
distribution: job.distribution && transformDistributionType(job.distribution),
secrets: transformIosSecrets(job.secrets),
builderEnvironment: job.builderEnvironment,
+30 -52
View File
@@ -1,27 +1,19 @@
import { ArchiveSource, Cache, Ios, Job, Workflow, sanitizeJob } from '@expo/eas-build-job';
import { IosGenericBuildProfile, IosManagedBuildProfile } from '@expo/eas-json';
import assert from 'assert';
import path from 'path';
import semver from 'semver';
import {
IosCredentials,
IosTargetCredentials,
isCredentialsMap,
readEnvironmentSecretsAsync,
} from '../../credentials/credentialsJson/read';
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
import { getUsername } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { gitRootDirectoryAsync } from '../../utils/git';
import vcs from '../../vcs';
import { BuildContext } from '../context';
import { Platform } from '../types';
interface JobData {
projectArchive: ArchiveSource;
credentials?: IosCredentials;
projectConfiguration: {
iosBuildScheme?: string;
iosApplicationTarget?: string;
};
buildScheme: string;
}
export async function prepareJobAsync(
@@ -43,7 +35,6 @@ interface CommonJobProperties {
platform: Platform.IOS;
projectArchive: ArchiveSource;
builderEnvironment: Ios.BuilderEnvironment;
releaseChannel?: string;
distribution?: Ios.DistributionType;
cache: Cache;
secrets: {
@@ -54,28 +45,14 @@ interface CommonJobProperties {
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.IOS>,
{
credentials,
targetName,
projectArchive,
}: { credentials?: IosCredentials; targetName?: string; projectArchive: ArchiveSource }
{ credentials, projectArchive }: { credentials?: IosCredentials; projectArchive: ArchiveSource }
): Promise<Partial<CommonJobProperties>> {
const environmentSecrets = await readEnvironmentSecretsAsync(ctx.commandCtx.projectDir);
let buildCredentials: CommonJobProperties['secrets']['buildCredentials'] = {};
if (credentials && isCredentialsMap(credentials)) {
const targets = Object.keys(credentials);
for (const target of targets) {
buildCredentials[target] = prepareTargetCredentials(credentials[target]);
const buildCredentials: CommonJobProperties['secrets']['buildCredentials'] = {};
if (credentials) {
const targetNames = Object.keys(credentials);
for (const targetName of targetNames) {
buildCredentials[targetName] = prepareTargetCredentials(credentials[targetName]);
}
} 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 {
@@ -83,7 +60,7 @@ async function prepareJobCommonAsync(
projectArchive,
distribution: ctx.buildProfile.distribution,
builderEnvironment: {
image: ctx.buildProfile.image,
image: resolveImage(ctx),
node: ctx.buildProfile.node,
yarn: ctx.buildProfile.yarn,
bundler: ctx.buildProfile.bundler,
@@ -97,18 +74,28 @@ async function prepareJobCommonAsync(
clear: ctx.commandCtx.clearCache,
},
secrets: {
...(environmentSecrets ? { environmentSecrets } : {}),
buildCredentials,
},
};
}
function prepareTargetCredentials(targetCredentials: IosTargetCredentials): Ios.TargetCredentials {
function resolveImage(ctx: BuildContext<Platform.IOS>): Ios.BuilderEnvironment['image'] {
// see https://linear.app/expo/issue/ENG-1396/make-default-image-dependent-on-sdk-version
if (!ctx.buildProfile.image && ctx.commandCtx.exp.sdkVersion) {
const majorSdkVersion = semver.major(ctx.commandCtx.exp.sdkVersion);
if (majorSdkVersion <= 41) {
return 'macos-catalina-10.15-xcode-12.4';
}
}
return ctx.buildProfile.image ?? 'default';
}
function prepareTargetCredentials(targetCredentials: TargetCredentials): Ios.TargetCredentials {
return {
provisioningProfileBase64: targetCredentials.provisioningProfile,
distributionCertificate: {
dataBase64: targetCredentials.distributionCertificate.certP12,
password: targetCredentials.distributionCertificate.certPassword,
dataBase64: targetCredentials.distributionCertificate.certificateP12,
password: targetCredentials.distributionCertificate.certificatePassword,
},
};
}
@@ -119,18 +106,18 @@ async function prepareGenericJobAsync(
buildProfile: IosGenericBuildProfile
): Promise<Partial<Ios.GenericJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
path.relative(await vcs.getRootPathAsync(), ctx.commandCtx.projectDir) || '.';
return {
...(await prepareJobCommonAsync(ctx, {
credentials: jobData.credentials,
targetName: jobData.projectConfiguration.iosApplicationTarget,
projectArchive: jobData.projectArchive,
})),
type: Workflow.GENERIC,
scheme: jobData.projectConfiguration.iosBuildScheme,
scheme: jobData.buildScheme,
buildConfiguration: buildProfile.schemeBuildConfiguration,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
updates: { channel: buildProfile.channel },
projectRootDirectory,
};
}
@@ -141,27 +128,18 @@ async function prepareManagedJobAsync(
buildProfile: IosManagedBuildProfile
): Promise<Partial<Ios.ManagedJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
path.relative(await vcs.getRootPathAsync(), ctx.commandCtx.projectDir) || '.';
const username = getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync());
return {
...(await prepareJobCommonAsync(ctx, {
credentials: jobData.credentials,
targetName: jobData.projectConfiguration.iosApplicationTarget,
projectArchive: jobData.projectArchive,
})),
type: Workflow.MANAGED,
buildType: buildProfile.buildType,
username,
releaseChannel: buildProfile.releaseChannel,
updates: { channel: buildProfile.channel },
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, '');
}
+59 -19
View File
@@ -5,11 +5,17 @@ import { getApplicationId } from '../project/android/applicationId';
import { getBundleIdentifier } from '../project/ios/bundleIdentifier';
import { getUsername } from '../project/projectUtils';
import { ensureLoggedInAsync } from '../user/actions';
import { gitCommitHashAsync } from '../utils/git';
import { readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync } from './android/UpdatesModule';
import vcs from '../vcs';
import {
readChannelSafelyAsync as readAndroidChannelSafelyAsync,
readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync,
} from './android/UpdatesModule';
import { readVersionCode, readVersionName } from './android/version';
import { BuildContext } from './context';
import { readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync } from './ios/UpdatesModule';
import {
readChannelSafelyAsync as readIosChannelSafelyAsync,
readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync,
} from './ios/UpdatesModule';
import { readBuildNumberAsync, readShortVersionAsync } from './ios/version';
import { Platform } from './types';
import { isExpoUpdatesInstalled } from './utils/updates';
@@ -29,7 +35,11 @@ export async function collectMetadata<T extends Platform>(
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
}
): Promise<Metadata> {
const channelOrReleaseChannel = await resolveChannelOrReleaseChannelAsync(ctx);
return {
// TODO: type error fixed in https://github.com/expo/eas-build/pull/34
// remove @ts-expect-error after upgrading @expo/eas-build-job
// @ts-expect-error error TS2322: not assignable to type 'Record<string, string | number>'
trackingContext: ctx.trackingCtx,
appVersion: await resolveAppVersionAsync(ctx),
appBuildVersion: await resolveAppBuildVersionAsync(ctx),
@@ -37,12 +47,12 @@ export async function collectMetadata<T extends Platform>(
workflow: ctx.buildProfile.workflow,
credentialsSource,
sdkVersion: ctx.commandCtx.exp.sdkVersion,
releaseChannel: await resolveReleaseChannel(ctx),
...channelOrReleaseChannel,
distribution: ctx.buildProfile.distribution ?? 'store',
appName: ctx.commandCtx.exp.name,
appIdentifier: resolveAppIdentifier(ctx),
buildProfile: ctx.commandCtx.profile,
gitCommitHash: await gitCommitHashAsync(),
gitCommitHash: await vcs.getCommitHashAsync(),
username: getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync()),
};
}
@@ -76,22 +86,52 @@ function resolveAppIdentifier<T extends Platform>(ctx: BuildContext<T>): string
}
}
async function resolveReleaseChannel<T extends Platform>(
async function resolveChannelOrReleaseChannelAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<{ channel: string } | { releaseChannel: string } | null> {
if (!isExpoUpdatesInstalled(ctx.commandCtx.projectDir)) {
return null;
}
if (ctx.buildProfile.channel) {
return { channel: ctx.buildProfile.channel };
}
if (ctx.buildProfile.releaseChannel) {
return { releaseChannel: ctx.buildProfile.releaseChannel };
}
const channel = await getNativeChannelAsync(ctx);
if (channel) {
return { channel };
}
const releaseChannel = await getNativeReleaseChannelAsync(ctx);
return { releaseChannel };
}
async function getNativeReleaseChannelAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<string> {
switch (ctx.platform) {
case Platform.ANDROID: {
return (await readAndroidReleaseChannelSafelyAsync(ctx.commandCtx.projectDir)) ?? 'default';
}
case Platform.IOS: {
return (await readIosReleaseChannelSafelyAsync(ctx.commandCtx.projectDir)) ?? 'default';
}
default:
return 'default';
}
}
async function getNativeChannelAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<string | undefined> {
if (!isExpoUpdatesInstalled(ctx.commandCtx.projectDir)) {
return undefined;
switch (ctx.platform) {
case Platform.ANDROID: {
return (await readAndroidChannelSafelyAsync(ctx.commandCtx.projectDir)) ?? undefined;
}
case Platform.IOS: {
return (await readIosChannelSafelyAsync(ctx.commandCtx.projectDir)) ?? 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';
return undefined;
}
+2 -1
View File
@@ -7,6 +7,7 @@ export enum RequestedPlatform {
}
export enum BuildStatus {
NEW = 'new',
IN_QUEUE = 'in-queue',
IN_PROGRESS = 'in-progress',
ERRORED = 'errored',
@@ -14,4 +15,4 @@ export enum BuildStatus {
CANCELED = 'canceled',
}
export type TrackingContext = Record<string, string | number>;
export type TrackingContext = Record<string, string | number | boolean>;
+10 -10
View File
@@ -18,6 +18,8 @@ export function formatGraphQLBuild(build: BuildFragment) {
label: 'Status',
get value() {
switch (build.status) {
case GraphQLBuildStatus.New:
return chalk.blue('new');
case GraphQLBuildStatus.InQueue:
return chalk.blue('in queue');
case GraphQLBuildStatus.InProgress:
@@ -48,12 +50,8 @@ export function formatGraphQLBuild(build: BuildFragment) {
{
label: 'Artifact',
get value() {
if (
build.status === GraphQLBuildStatus.InQueue ||
build.status === GraphQLBuildStatus.InProgress
) {
}
switch (build.status) {
case GraphQLBuildStatus.New:
case GraphQLBuildStatus.InQueue:
case GraphQLBuildStatus.InProgress:
return '<in progress>';
@@ -72,11 +70,13 @@ export function formatGraphQLBuild(build: BuildFragment) {
{ label: 'Started at', value: new Date(build.createdAt).toLocaleString() },
{
label: 'Finished at',
value:
build.status === GraphQLBuildStatus.InQueue ||
build.status === GraphQLBuildStatus.InProgress
? '<in progress>'
: new Date(build.updatedAt).toLocaleString(),
value: [
GraphQLBuildStatus.New,
GraphQLBuildStatus.InQueue,
GraphQLBuildStatus.InProgress,
].includes(build.status)
? '<in progress>'
: new Date(build.updatedAt).toLocaleString(),
},
{ label: 'Started by', value: actor ?? 'unknown' },
];
+26 -97
View File
@@ -1,4 +1,3 @@
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import fs from 'fs-extra';
import ora from 'ora';
@@ -9,55 +8,16 @@ import { v4 as uuidv4 } from 'uuid';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { formatBytes } from '../../utils/files';
import {
doesGitRepoExistAsync,
getGitDiffOutputAsync,
gitDiffAsync,
gitRootDirectoryAsync,
gitStatusAsync,
isGitInstalledAsync,
} from '../../utils/git';
import { getTmpDirectory } from '../../utils/paths';
import { endTimer, formatMilliseconds, startTimer } from '../../utils/timer';
import vcs from '../../vcs';
async function ensureGitRepoExistsAsync(): Promise<void> {
if (!(await isGitInstalledAsync())) {
throw new Error('git command not found, install it before proceeding');
}
if (await doesGitRepoExistAsync()) {
return;
}
Log.warn("It looks like you haven't initialized the git repository yet.");
Log.warn('EAS Build requires you to use a git repository for your project.');
const confirmInit = await confirmAsync({
message: `Would you like to run 'git init' in the current directory?`,
});
if (!confirmInit) {
throw new Error(
'A git repository is required for building your project. Initialize it and run this command again.'
);
}
await spawnAsync('git', ['init']);
Log.log("We're going to make an initial commit for you repository.");
await commitPromptAsync({ initialCommitMessage: 'Initial commit', commitAllFiles: true });
}
async function isGitStatusCleanAsync(): Promise<boolean> {
const changes = await gitStatusAsync({ showUntracked: true });
return changes.length === 0;
}
async function maybeBailOnGitStatusAsync(): Promise<void> {
if (await isGitStatusCleanAsync()) {
export async function maybeBailOnRepoStatusAsync(): Promise<void> {
if (!(await vcs.hasUncommittedChangesAsync())) {
return;
}
Log.addNewLineIfNone();
Log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
Log.warn(`${chalk.bold('Warning!')} Your repository working tree is dirty.`);
Log.log(
`It's recommended to ${chalk.bold(
'commit all your changes before proceeding'
@@ -72,12 +32,12 @@ async function maybeBailOnGitStatusAsync(): Promise<void> {
}
}
async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void> {
if (await isGitStatusCleanAsync()) {
export async function ensureRepoIsCleanAsync(nonInteractive = false): Promise<void> {
if (!(await vcs.hasUncommittedChangesAsync())) {
return;
}
Log.addNewLineIfNone();
Log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
Log.warn(`${chalk.bold('Warning!')} Your repository 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'
@@ -96,7 +56,24 @@ async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void
}
}
async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
export async function commitPromptAsync({
initialCommitMessage,
commitAllFiles,
}: {
initialCommitMessage?: string;
commitAllFiles?: boolean;
} = {}): Promise<void> {
const { message } = await promptAsync({
type: 'text',
name: 'message',
message: 'Commit message:',
initial: initialCommitMessage,
validate: (input: string) => input !== '',
});
await vcs.commitAsync({ commitAllFiles, commitMessage: message });
}
export async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
const spinner = ora('Compressing project files');
await fs.mkdirp(getTmpDirectory());
@@ -118,14 +95,7 @@ async function makeProjectTarballAsync(): Promise<{ path: string; size: number }
startTimer(compressTimerLabel);
try {
await spawnAsync('git', [
'clone',
'--no-hardlinks',
'--depth',
'1',
`file://${await gitRootDirectoryAsync()}`,
shallowClonePath,
]);
await vcs.makeShallowCopyAsync(shallowClonePath);
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.',
]);
@@ -151,44 +121,3 @@ async function makeProjectTarballAsync(): Promise<{ path: string; size: number }
return { size, path: tarPath };
}
async function showDiffAsync() {
const outputTooLarge = (await getGitDiffOutputAsync()).split(/\r\n|\r|\n/).length > 100;
await gitDiffAsync({ withPager: outputTooLarge });
}
async function commitPromptAsync({
initialCommitMessage,
commitAllFiles,
}: {
initialCommitMessage?: string;
commitAllFiles?: boolean;
} = {}): Promise<void> {
const { message } = await promptAsync({
type: 'text',
name: 'message',
message: 'Commit message:',
initial: initialCommitMessage,
validate: (input: string) => input !== '',
});
if (commitAllFiles) {
await spawnAsync('git', ['add', '-A']);
}
await commitChangedFilesAsync(message);
}
async function commitChangedFilesAsync(message: string): Promise<void> {
await spawnAsync('git', ['add', '-u']);
await spawnAsync('git', ['commit', '-m', message]);
}
export {
isGitStatusCleanAsync,
showDiffAsync,
commitPromptAsync,
commitChangedFilesAsync,
ensureGitRepoExistsAsync,
ensureGitStatusIsCleanAsync,
maybeBailOnGitStatusAsync,
makeProjectTarballAsync,
};
@@ -0,0 +1,38 @@
import { Command } from '@oclif/command';
import {
AnalyticsEvent,
flushAsync as flushAnalyticsAsync,
initAsync as initAnalyticsAsync,
logEvent,
} from '../analytics';
import { getUserAsync } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
export default abstract class EasCommand extends Command {
/**
* When user data is unavailable locally, determines if the command will
* force the user to log in
*/
protected requiresAuthentication = true;
async init() {
await initAnalyticsAsync();
if (this.requiresAuthentication) {
await ensureLoggedInAsync();
} else {
await getUserAsync();
}
logEvent(AnalyticsEvent.ACTION, {
// id is assigned by oclif in constructor based on the filepath:
// commands/submit === submit, commands/build/list === build:list
action: `eas ${this.id}`,
});
}
async finally(err: Error) {
await flushAnalyticsAsync();
return super.finally(err);
}
}
@@ -0,0 +1,78 @@
import { flushAsync, initAsync, logEvent } from '../../analytics';
import { jester as mockJester } from '../../credentials/__tests__/fixtures-constants';
import { getUserAsync } from '../../user/User';
import { ensureLoggedInAsync } from '../../user/actions';
import EasCommand from '../EasCommand';
import TestEasCommand from './TestEasCommand';
describe(EasCommand.name, () => {
beforeAll(() => {
TestEasCommand.prototype.authValue = jest.fn().mockImplementation(() => true);
jest.mock('../../user/User', () => ({ getUserAsync: jest.fn(() => mockJester) }));
jest.mock('../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
jest.mock('../../analytics', () => {
const { AnalyticsEvent } = jest.requireActual('../../analytics');
return {
AnalyticsEvent,
logEvent: jest.fn(),
initAsync: jest.fn(),
flushAsync: jest.fn(),
};
});
});
afterEach(() => {
(ensureLoggedInAsync as jest.Mock).mockClear();
(initAsync as jest.Mock).mockClear();
(flushAsync as jest.Mock).mockClear();
(logEvent as jest.Mock).mockClear();
});
describe('without exceptions', () => {
it('ensures the user is logged in', async () => {
await TestEasCommand.run();
expect(ensureLoggedInAsync).toHaveReturnedWith(mockJester);
});
it('ensures the user data is read from cache', async () => {
(TestEasCommand.prototype.authValue as jest.Mock).mockImplementationOnce(() => false);
await TestEasCommand.run();
expect(getUserAsync).toHaveReturnedWith(mockJester);
});
it('initializes analytics', async () => {
await TestEasCommand.run();
expect(initAsync).toHaveBeenCalled();
});
it('flushes analytics', async () => {
await TestEasCommand.run();
expect(flushAsync).toHaveBeenCalled();
});
it('logs events', async () => {
await TestEasCommand.run();
expect(logEvent).toHaveBeenCalledWith('action', {
action: `eas ${TestEasCommand.id}`,
});
});
});
describe('after exceptions', () => {
it('flushes analytics', async () => {
try {
await TestEasCommand.run().then(() => {
throw new Error('foo');
});
} catch (error) {}
expect(flushAsync).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,13 @@
import EasCommand from '../EasCommand';
export default class TestEasCommand extends EasCommand {
requiresAuthentication = this.authValue();
authValue() {
return false;
}
async run() {}
}
TestEasCommand.id = 'TestEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
@@ -16,7 +16,7 @@ import {
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { getBranchNameAsync } from '../../utils/git';
import vcs from '../../vcs';
export async function createUpdateBranchOnAppAsync({
appId,
@@ -93,7 +93,7 @@ export default class BranchCreate extends Command {
name: 'name',
message: 'Please name the branch:',
initial:
(await getBranchNameAsync()) || `branch-${Math.random().toString(36).substr(2, 4)}`,
(await vcs.getBranchNameAsync()) || `branch-${Math.random().toString(36).substr(2, 4)}`,
validate: value => (value ? true : validationMessage),
}));
}
@@ -33,7 +33,7 @@ import {
} from '../../project/publish';
import { promptAsync, selectAsync } from '../../prompts';
import { formatUpdate } from '../../update/utils';
import { getLastCommitMessageAsync } from '../../utils/git';
import vcs from '../../vcs';
import { listBranchesAsync } from './list';
import { viewUpdateBranchAsync } from './view';
@@ -284,7 +284,7 @@ export default class BranchPublish extends Command {
message: `Please enter a publication message.`,
initial: republish
? `Republish "${oldMessage!}" - group: ${group}`
: (await getLastCommitMessageAsync())?.trim(),
: (await vcs.getLastCommitMessageAsync())?.trim(),
validate: value => (value ? true : validationMessage),
}));
}
+12 -3
View File
@@ -47,7 +47,15 @@ function formatUnfinishedBuild(
): string {
const platform = appPlatformEmojis[build.platform];
const startTime = new Date(build.createdAt).toLocaleString();
const status = chalk.blue(build.status === BuildStatus.InQueue ? 'in-queue' : 'in-progress');
let statusText: string;
if (build.status === BuildStatus.New) {
statusText = 'new';
} else if (build.status === BuildStatus.InQueue) {
statusText = 'in queue';
} else {
statusText = 'in progress';
}
const status = chalk.blue(statusText);
return `${platform} Started at: ${startTime}, Status: ${status}, Id: ${build.id}`;
}
@@ -58,12 +66,13 @@ async function selectBuildToCancelAsync(
const spinner = ora().start('Fetching the uncompleted builds…');
let builds;
try {
const [inQueueBuilds, inProgressBuilds] = await Promise.all([
const [newBuilds, inQueueBuilds, inProgressBuilds] = await Promise.all([
BuildQuery.allForAppAsync(projectId, { status: BuildStatus.New }),
BuildQuery.allForAppAsync(projectId, { status: BuildStatus.InQueue }),
BuildQuery.allForAppAsync(projectId, { status: BuildStatus.InProgress }),
]);
spinner.stop();
builds = [...inQueueBuilds, ...inProgressBuilds];
builds = [...newBuilds, ...inQueueBuilds, ...inProgressBuilds];
} catch (error) {
spinner.fail(
`Something went wrong and we couldn't fetch the builds for the project ${projectFullName}.`
@@ -17,10 +17,6 @@ export default class BuildConfigure extends Command {
char: 'p',
options: ['android', 'ios', 'all'],
}),
'allow-experimental': flags.boolean({
description: 'Enable experimental configuration steps.',
default: false,
}),
};
async run() {
@@ -32,20 +28,10 @@ export default class BuildConfigure extends Command {
const platform =
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
const allowExperimental = flags['allow-experimental'];
if (allowExperimental) {
Log.warn(
`Project configuration will execute some additional steps that might fail if structure of your native project is significantly different from ${chalk.bold(
'expo eject'
)} or ${chalk.bold('expo init')}`
);
}
await ensureLoggedInAsync();
await configureAsync({
platform,
allowExperimental,
projectDir: (await findProjectRootAsync()) ?? process.cwd(),
});
+51 -55
View File
@@ -1,18 +1,14 @@
import { ExpoConfig, getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import { flags } from '@oclif/command';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import {
flushAsync as flushAnalyticsAsync,
initAsync as initAnalyticsAsync,
} from '../../analytics';
import { configureAsync } from '../../build/configure';
import { createCommandContextAsync } from '../../build/context';
import { buildAsync } from '../../build/create';
import { RequestedPlatform } from '../../build/types';
import { isGitStatusCleanAsync } from '../../build/utils/repository';
import EasCommand from '../../commandUtils/EasCommand';
import Log, { learnMore } from '../../log';
import {
isEasEnabledForProjectAsync,
@@ -20,16 +16,16 @@ import {
} from '../../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
import vcs from '../../vcs';
export default class Build extends Command {
export default class Build extends EasCommand {
static description = 'Start a build';
static flags = {
platform: flags.enum({ char: 'p', options: ['android', 'ios', 'all'] }),
'skip-credentials-check': flags.boolean({
default: false,
description: 'Skip validation of build credentials',
hidden: true,
}),
'skip-project-configuration': flags.boolean({
default: false,
@@ -60,51 +56,52 @@ export default class Build extends Command {
async run(): Promise<void> {
const { flags } = this.parse(Build);
await initAnalyticsAsync(); // TODO: run in oclif hook for every command
try {
await ensureLoggedInAsync();
const nonInteractive = flags['non-interactive'];
if (!flags.platform && nonInteractive) {
throw new Error('--platform is required when building in non-interactive mode');
}
const platform =
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
let { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!flags.local && !(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
if (flags.local && !verifyOptionsForLocalBuilds(platform)) {
process.exitCode = 1;
return;
}
exp = (await ensureProjectConfiguredAsync(projectDir)) ?? exp;
const commandCtx = await createCommandContextAsync({
requestedPlatform: platform,
profile: flags.profile,
exp,
projectDir,
projectId,
nonInteractive,
clearCache: flags['clear-cache'],
skipCredentialsCheck: flags['skip-credentials-check'],
local: flags.local,
skipProjectConfiguration: flags['skip-project-configuration'],
waitForBuildEnd: flags.wait,
});
await buildAsync(commandCtx);
} finally {
await flushAnalyticsAsync(); // TODO: run in oclif hook for every command
const nonInteractive = flags['non-interactive'];
if (!flags.platform && nonInteractive) {
throw new Error('--platform is required when building in non-interactive mode');
}
const platform =
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
if (flags['skip-credentials-check']) {
Log.warnDeprecatedFlag(
'skip-credentials-check',
'Build credential validation is always skipped with the --non-interactive flag. You can also skip interactively.'
);
Log.newLine();
}
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
let { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!flags.local && !(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
if (flags.local && !verifyOptionsForLocalBuilds(platform)) {
process.exitCode = 1;
return;
}
exp = (await ensureProjectConfiguredAsync(projectDir)) ?? exp;
const commandCtx = await createCommandContextAsync({
requestedPlatform: platform,
profile: flags.profile,
exp,
projectDir,
projectId,
nonInteractive,
clearCache: flags['clear-cache'],
local: flags.local,
skipProjectConfiguration: flags['skip-project-configuration'],
waitForBuildEnd: flags.wait,
});
await buildAsync(commandCtx);
}
}
@@ -154,11 +151,10 @@ async function ensureProjectConfiguredAsync(projectDir: string): Promise<ExpoCon
await configureAsync({
projectDir,
platform: RequestedPlatform.All,
allowExperimental: false,
});
if (!(await isGitStatusCleanAsync())) {
if (await vcs.hasUncommittedChangesAsync()) {
throw new Error(
'Build process requires clean git working tree, please commit all your changes and run `eas build` again'
'Build process requires clean working tree, please commit all your changes and run `eas build` again'
);
}
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
@@ -23,6 +23,7 @@ export default class BuildList extends Command {
}),
status: flags.enum({
options: [
BuildStatus.NEW,
BuildStatus.IN_QUEUE,
BuildStatus.IN_PROGRESS,
BuildStatus.ERRORED,
@@ -92,6 +93,8 @@ const toAppPlatform = (requestedPlatform?: RequestedPlatform): AppPlatform | und
const toGraphQLBuildStatus = (buildStatus: BuildStatus): GraphQLBuildStatus | undefined => {
if (!buildStatus) {
return undefined;
} else if (buildStatus === BuildStatus.NEW) {
return GraphQLBuildStatus.New;
} else if (buildStatus === BuildStatus.IN_QUEUE) {
return GraphQLBuildStatus.InQueue;
} else if (buildStatus === BuildStatus.IN_PROGRESS) {
+1 -2
View File
@@ -1,6 +1,5 @@
import { Command } from '@oclif/command';
import { CredentialsManager } from '../credentials/CredentialsManager';
import { createCredentialsContextAsync } from '../credentials/context';
import { SelectPlatform } from '../credentials/manager/SelectPlatform';
@@ -9,6 +8,6 @@ export default class Credentials extends Command {
async run() {
const ctx = await createCredentialsContextAsync(process.cwd(), {});
new CredentialsManager(ctx).runActionAsync(new SelectPlatform());
await new SelectPlatform().runAsync(ctx);
}
}
+3 -5
View File
@@ -1,6 +1,7 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import { flags } from '@oclif/command';
import EasCommand from '../commandUtils/EasCommand';
import { AppPlatform } from '../graphql/generated';
import { learnMore } from '../log';
import { isEasEnabledForProjectAsync, warnEasUnavailable } from '../project/isEasEnabledForProject';
@@ -9,13 +10,12 @@ import { promptAsync } from '../prompts';
import AndroidSubmitCommand from '../submissions/android/AndroidSubmitCommand';
import IosSubmitCommand from '../submissions/ios/IosSubmitCommand';
import { AndroidSubmitCommandFlags, IosSubmitCommandFlags } from '../submissions/types';
import { ensureLoggedInAsync } from '../user/actions';
const COMMON_FLAGS = '';
const ANDROID_FLAGS = 'Android specific options';
const IOS_FLAGS = 'iOS specific options';
export default class BuildSubmit extends Command {
export default class BuildSubmit extends EasCommand {
static description = 'Submits build artifact to app store';
static usage = 'submit --platform=(android|ios)';
static aliases = ['build:submit'];
@@ -160,8 +160,6 @@ export default class BuildSubmit extends Command {
},
} = this.parse(BuildSubmit);
await ensureLoggedInAsync();
const platform =
(flags.platform?.toUpperCase() as AppPlatform | undefined) ??
(await promptForPlatformAsync());
@@ -1,13 +0,0 @@
import { Context } from './context';
export interface Action<T = void> {
runAsync(manager: CredentialsManager, ctx: Context): Promise<T>;
}
export class CredentialsManager {
constructor(private ctx: Context) {}
public async runActionAsync<T>(action: Action<T>): Promise<T> {
return await action.runAsync(this, this.ctx);
}
}
@@ -1,7 +0,0 @@
import { getAndroidApiMock } from '../__tests__/fixtures-android';
import { createCtxMock } from '../__tests__/fixtures-context';
export const createCredentialsContextAsync = () =>
createCtxMock({
android: getAndroidApiMock(),
});
@@ -1,77 +0,0 @@
import {
AndroidAppBuildCredentialsFragment,
AndroidKeystoreFragment,
AndroidKeystoreType,
AppFragment,
CommonAndroidAppCredentialsFragment,
} from '../../graphql/generated';
import { testKeystore } from './fixtures-android';
import { testKeystoreBase64 } from './fixtures-base64-data';
export const testAppFragment: AppFragment = {
id: 'test-app-id',
fullName: '@testuser/testapp',
slug: 'testapp',
};
export const testJksAndroidKeystoreFragment: AndroidKeystoreFragment = {
id: 'test-id',
type: AndroidKeystoreType.Jks,
keystore: testKeystoreBase64,
keystorePassword: testKeystore.keystorePassword,
keyAlias: testKeystore.keyAlias,
keyPassword: testKeystore.keyPassword,
md5CertificateFingerprint: 'test-md5',
sha1CertificateFingerprint: 'test-sha1',
sha256CertificateFingerprint: 'test-sha256',
createdAt: new Date(),
updatedAt: new Date(),
};
export const testLegacyAndroidBuildCredentialsFragment: AndroidAppBuildCredentialsFragment = {
id: 'test-id',
isDefault: true,
isLegacy: true,
name: 'legacy',
androidKeystore: testJksAndroidKeystoreFragment,
};
export const testLegacyAndroidAppCredentialsFragment: CommonAndroidAppCredentialsFragment = {
id: 'test-id',
applicationIdentifier: null,
isLegacy: true,
app: testAppFragment,
androidAppBuildCredentialsList: [testLegacyAndroidBuildCredentialsFragment],
};
export const testAndroidBuildCredentialsFragment: AndroidAppBuildCredentialsFragment = {
id: 'test-id',
isDefault: true,
isLegacy: false,
name: 'Google App Store Build Credentials',
androidKeystore: testJksAndroidKeystoreFragment,
};
export const testAndroidAppCredentialsFragment: CommonAndroidAppCredentialsFragment = {
id: 'test-id',
applicationIdentifier: null,
isLegacy: false,
app: testAppFragment,
androidAppBuildCredentialsList: [testLegacyAndroidBuildCredentialsFragment],
};
export function getNewAndroidApiMockWithoutCredentials() {
return {
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(),
getAndroidAppBuildCredentialsListAsync: jest.fn(() => []),
getLegacyAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(),
getLegacyAndroidAppBuildCredentialsAsync: jest.fn(),
createOrGetExistingAndroidAppCredentialsWithBuildCredentialsAsync: jest.fn(),
updateAndroidAppBuildCredentialsAsync: jest.fn(),
createAndroidAppBuildCredentialsAsync: jest.fn(),
getDefaultAndroidAppBuildCredentialsAsync: jest.fn(),
getAndroidAppBuildCredentialsByNameAsync: jest.fn(),
createOrUpdateAndroidAppBuildCredentialsByNameAsync: jest.fn(),
createKeystoreAsync: jest.fn(),
};
}
@@ -1,11 +1,19 @@
import { AndroidCredentials, Keystore } from '../android/credentials';
import {
AndroidAppBuildCredentialsFragment,
AndroidFcmFragment,
AndroidFcmVersion,
AndroidKeystoreFragment,
AndroidKeystoreType,
AppFragment,
CommonAndroidAppCredentialsFragment,
} from '../../graphql/generated';
import { Keystore } from '../android/credentials';
import {
testKeystore2Base64,
testKeystoreBase64,
testPKCS12KeystoreBase64,
testPKCS12KeystoreEmptyPasswordBase64,
} from './fixtures-base64-data';
import { testExperienceName, testJester2ExperienceName } from './fixtures-constants';
export const testKeystore: Keystore = {
keystore: testKeystoreBase64,
@@ -39,56 +47,88 @@ export const testPKCS12EmptyPasswordKeystore: Keystore = {
keyAlias: 'test-alias',
};
export const testPushCredentials = {
fcmApiKey: 'examplefcmapikey',
export const testAppFragment: AppFragment = {
id: 'test-app-id',
fullName: '@testuser/testapp',
slug: 'testapp',
};
export const testAppCredentials = {
experienceName: testExperienceName,
keystore: testKeystore,
pushCredentials: testPushCredentials,
export const testLegacyAndroidFcmFragment: AndroidFcmFragment = {
id: 'test-id',
snippet: {
firstFourCharacters: 'abcd',
lastFourCharacters: 'efgh',
},
credential: 'abcdxxxxxxefgh',
version: AndroidFcmVersion.Legacy,
createdAt: new Date(),
updatedAt: new Date(),
};
export const testJester2AppCredentials = {
experienceName: testJester2ExperienceName,
keystore: testKeystore2,
pushCredentials: testPushCredentials,
export const testJksAndroidKeystoreFragment: AndroidKeystoreFragment = {
id: 'test-id',
type: AndroidKeystoreType.Jks,
keystore: testKeystoreBase64,
keystorePassword: testKeystore.keystorePassword,
keyAlias: testKeystore.keyAlias,
keyPassword: testKeystore.keyPassword,
md5CertificateFingerprint: 'test-md5',
sha1CertificateFingerprint: 'test-sha1',
sha256CertificateFingerprint: 'test-sha256',
createdAt: new Date(),
updatedAt: new Date(),
};
export const testAllCredentials: { [key: string]: AndroidCredentials } = {
[testExperienceName]: testAppCredentials,
export const testLegacyAndroidBuildCredentialsFragment: AndroidAppBuildCredentialsFragment = {
id: 'test-id',
isDefault: true,
isLegacy: true,
name: 'legacy',
androidKeystore: testJksAndroidKeystoreFragment,
};
export function getApiClientWrapperMock() {
// by default all method throw exceptions to make sure that we only call what is expected
const getUnexpectedCallMock = () =>
jest.fn(() => {
throw new Error('unexpected call');
});
export const testLegacyAndroidAppCredentialsFragment: CommonAndroidAppCredentialsFragment = {
id: 'test-id',
applicationIdentifier: null,
isLegacy: true,
app: testAppFragment,
androidFcm: testLegacyAndroidFcmFragment,
androidAppBuildCredentialsList: [testLegacyAndroidBuildCredentialsFragment],
};
export const testAndroidBuildCredentialsFragment: AndroidAppBuildCredentialsFragment = {
id: 'test-id',
isDefault: true,
isLegacy: false,
name: 'Google App Store Build Credentials',
androidKeystore: testJksAndroidKeystoreFragment,
};
export const testAndroidAppCredentialsFragment: CommonAndroidAppCredentialsFragment = {
id: 'test-id',
applicationIdentifier: null,
isLegacy: false,
app: testAppFragment,
androidFcm: testLegacyAndroidFcmFragment,
androidAppBuildCredentialsList: [testLegacyAndroidBuildCredentialsFragment],
};
export function getNewAndroidApiMock() {
return {
getAllCredentialsApi: getUnexpectedCallMock(),
getAllCredentialsForAppApi: getUnexpectedCallMock(),
updateKeystoreApi: getUnexpectedCallMock(),
updateFcmKeyApi: getUnexpectedCallMock(),
removeKeystoreApi: getUnexpectedCallMock(),
removeFcmKeyApi: getUnexpectedCallMock(),
};
}
export function getAndroidApiMock() {
return {
fetchAllAsync: jest.fn(() => testAllCredentials),
fetchKeystoreAsync: jest.fn(() => testKeystore),
updateKeystoreAsync: jest.fn(),
removeKeystoreAsync: jest.fn(),
};
}
export function getAndroidApiMockWithoutCredentials() {
return {
fetchAllAsync: jest.fn(() => []),
fetchKeystoreAsync: jest.fn(() => null),
updateKeystoreAsync: jest.fn(),
removeKeystoreAsync: jest.fn(),
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(),
getAndroidAppBuildCredentialsListAsync: jest.fn(() => []),
getLegacyAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(),
getLegacyAndroidAppBuildCredentialsAsync: jest.fn(),
createOrGetExistingAndroidAppCredentialsWithBuildCredentialsAsync: jest.fn(),
updateAndroidAppCredentialsAsync: jest.fn(),
updateAndroidAppBuildCredentialsAsync: jest.fn(),
createAndroidAppBuildCredentialsAsync: jest.fn(),
getDefaultAndroidAppBuildCredentialsAsync: jest.fn(),
getAndroidAppBuildCredentialsByNameAsync: jest.fn(),
createOrUpdateAndroidAppBuildCredentialsByNameAsync: jest.fn(),
createKeystoreAsync: jest.fn(),
createFcmAsync: jest.fn(),
deleteKeystoreAsync: jest.fn(),
deleteFcmAsync: jest.fn(),
};
}
@@ -1,18 +1,15 @@
import merge from 'lodash/merge';
import { CredentialsManager } from '../CredentialsManager';
import { Context } from '../context';
import { getAndroidApiMockWithoutCredentials } from './fixtures-android';
import { getNewAndroidApiMockWithoutCredentials } from './fixtures-android-new';
import { getNewAndroidApiMock } from './fixtures-android';
import { getAppstoreMock } from './fixtures-appstore';
import { testAppJson, testUsername } from './fixtures-constants';
import { getNewIosApiMockWithoutCredentials } from './fixtures-ios';
import { getNewIosApiMock } from './fixtures-ios';
export function createCtxMock(mockOverride: Record<string, any> = {}): Context {
const defaultMock = {
ios: getNewIosApiMockWithoutCredentials(),
android: getAndroidApiMockWithoutCredentials(),
newAndroid: getNewAndroidApiMockWithoutCredentials(),
ios: getNewIosApiMock(),
android: getNewAndroidApiMock(),
appStore: getAppstoreMock(),
bestEffortAppStoreAuthenticateAsync: jest.fn(),
ensureAppleCtx: jest.fn(),
@@ -29,14 +26,3 @@ export function createCtxMock(mockOverride: Record<string, any> = {}): Context {
};
return merge(defaultMock, mockOverride) as any;
}
export function createManagerMock(mockOverride: Record<string, any> = {}): CredentialsManager {
return merge(
{
runActionAsync: jest.fn(() => {
throw new Error('unexpected call'); // should be implemented in test directly
}),
},
mockOverride
) as any;
}
@@ -3,20 +3,15 @@ import {
AppleAppIdentifierFragment,
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
ApplePushKeyFragment,
AppleTeamFragment,
CommonIosAppCredentialsFragment,
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../graphql/generated';
import { IosAppCredentialsWithBuildCredentialsQueryResult } from '../ios/api/graphql/queries/IosAppCredentialsQuery';
import {
DistributionCertificate,
ProvisioningProfile,
PushKey,
} from '../ios/appstore/Credentials.types';
import { IosDistCredentials, IosPushCredentials } from '../ios/credentials';
import { DistributionCertificate, ProvisioningProfile } from '../ios/appstore/Credentials.types';
import { Target } from '../ios/types';
import { testProvisioningProfileBase64 } from './fixtures-base64-data';
import { testBundleIdentifier, testExperienceName } from './fixtures-constants';
export const testProvisioningProfile: ProvisioningProfile = {
provisioningProfileId: 'test-id',
@@ -40,6 +35,16 @@ export const testAppFragment: AppFragment = {
slug: 'testapp',
};
export const testPushKey: ApplePushKeyFragment = {
id: 'test-push-key-id',
keyIdentifier: 'test-key-identifier',
appleTeam: { ...testAppleTeamFragment },
updatedAt: new Date(),
iosAppCredentialsList: [],
};
export const testTargets: Target[] = [{ targetName: 'testapp', bundleIdentifier: 'foo.bar.com' }];
export const testProvisioningProfileFragment: AppleProvisioningProfileFragment = {
id: 'test-prov-prof-id-1',
expiration: new Date(),
@@ -102,11 +107,7 @@ export const testCommonIosAppCredentialsFragment: CommonIosAppCredentialsFragmen
app: testAppFragment,
appleTeam: testAppleTeamFragment,
appleAppIdentifier: testAppleAppIdentifierFragment,
iosAppBuildCredentialsList: [testIosAppBuildCredentialsFragment],
};
export const testIosAppCredentialsWithBuildCredentialsQueryResult: IosAppCredentialsWithBuildCredentialsQueryResult = {
id: 'test-app-credential-id',
pushKey: testPushKey,
iosAppBuildCredentialsList: [testIosAppBuildCredentialsFragment],
};
@@ -114,11 +115,13 @@ export const testAppleTeam = {
id: 'test-team-id',
};
export function getNewIosApiMockWithoutCredentials() {
export function getNewIosApiMock() {
return {
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(),
createOrGetIosAppCredentialsWithCommonFieldsAsync: jest.fn(),
updateIosAppCredentialsAsync: jest.fn(),
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: () => testAppleTeam,
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
getDevicesForAppleTeamAsync: jest.fn(),
@@ -130,25 +133,9 @@ export function getNewIosApiMockWithoutCredentials() {
getDistributionCertificatesForAccountAsync: jest.fn(),
createDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
};
}
export function getNewIosApiMockWithCredentials() {
return {
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: jest.fn(),
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
getDevicesForAppleTeamAsync: jest.fn(),
createProvisioningProfileAsync: jest.fn(),
getProvisioningProfileAsync: jest.fn(),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfilesAsync: jest.fn(),
getDistributionCertificateForAppAsync: jest.fn(),
getDistributionCertificatesForAccountAsync: jest.fn(),
createDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
createPushKeyAsync: jest.fn(),
getPushKeyForAppAsync: jest.fn(),
deletePushKeyAsync: jest.fn(),
};
}
@@ -158,39 +145,3 @@ export const testDistCert: DistributionCertificate = {
distCertSerialNumber: 'test-serial',
teamId: 'test-team-id',
};
export const testIosDistCredential: IosDistCredentials = {
id: 1,
type: 'dist-cert',
...testDistCert,
};
export const testPushKey: PushKey = {
apnsKeyP8: 'test-p8',
apnsKeyId: 'test-key-id',
teamId: 'test-team-id',
};
/**
* Legacy format fixtures
*/
export const testIosPushCredential: IosPushCredentials = {
id: 2,
type: 'push-key',
...testPushKey,
};
export const testAppCredential = {
experienceName: testExperienceName,
bundleIdentifier: testBundleIdentifier,
distCredentialsId: testIosDistCredential.id,
pushCredentialsId: testIosPushCredential.id,
credentials: {
...testProvisioningProfile,
},
};
export const testAllCredentialsForApp = {
...testAppCredential,
pushCredentials: testPushKey,
distCredentials: testDistCert,
};
@@ -1,24 +1,20 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import nullthrows from 'nullthrows';
import { CredentialsManager } from '../CredentialsManager';
import { AndroidAppBuildCredentialsFragment } from '../../graphql/generated';
import { Context } from '../context';
import * as credentialsJsonReader from '../credentialsJson/read';
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
import { AppLookupParams } from './api/GraphqlClient';
import { Keystore } from './credentials';
export interface AndroidCredentials {
keystore: Keystore;
}
interface AppLookupParams {
projectName: string;
accountName: string;
}
interface Options {
app: AppLookupParams;
skipCredentialsCheck?: boolean;
}
export default class AndroidCredentialsProvider {
@@ -26,11 +22,6 @@ export default class AndroidCredentialsProvider {
constructor(private ctx: Context, private options: Options) {}
private get projectFullName(): string {
const { projectName, accountName } = this.options.app;
return `@${accountName}/${projectName}`;
}
public async getCredentialsAsync(
src: CredentialsSource.LOCAL | CredentialsSource.REMOTE
): Promise<AndroidCredentials> {
@@ -43,14 +34,22 @@ export default class AndroidCredentialsProvider {
}
private async getRemoteAsync(): Promise<AndroidCredentials> {
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 EAS servers');
}
return { keystore };
const setupBuildCredentialsAction = await new SetupBuildCredentials({ app: this.options.app });
const buildCredentials = await setupBuildCredentialsAction.runAsync(this.ctx);
return this.toAndroidCredentials(buildCredentials);
}
private toAndroidCredentials(
androidBuildCredentials: AndroidAppBuildCredentialsFragment
): AndroidCredentials {
return {
keystore: {
keystore: nullthrows(androidBuildCredentials.androidKeystore?.keystore),
keystorePassword: nullthrows(androidBuildCredentials.androidKeystore?.keystorePassword),
keyAlias: nullthrows(androidBuildCredentials.androidKeystore?.keyAlias),
keyPassword: nullthrows(androidBuildCredentials.androidKeystore?.keyPassword),
},
};
}
private async getLocalAsync(): Promise<AndroidCredentials> {
@@ -0,0 +1,28 @@
import {
AndroidFcmFragment,
CommonAndroidAppCredentialsFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
export class AssignFcm {
constructor(private app: AppLookupParams) {}
public async runAsync(
ctx: Context,
fcm: AndroidFcmFragment
): Promise<CommonAndroidAppCredentialsFragment> {
const appCredentials = await ctx.android.createOrGetExistingAndroidAppCredentialsWithBuildCredentialsAsync(
this.app
);
const updatedAppCredentials = await ctx.android.updateAndroidAppCredentialsAsync(
appCredentials,
{
androidFcmId: fcm.id,
}
);
Log.succeed(`FCM API Key assigned to ${this.app.androidApplicationIdentifier}`);
return updatedAppCredentials;
}
}
@@ -1,13 +1,91 @@
import assert from 'assert';
import { nanoid } from 'nanoid';
import ora from 'ora';
import { AndroidAppBuildCredentialsFragment } from '../../../graphql/generated';
import { getApplicationId } from '../../../project/android/applicationId';
import { getProjectAccountName, getProjectConfigDescription } from '../../../project/projectUtils';
import { promptAsync } from '../../../prompts';
import { findAccountByName } from '../../../user/Account';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
/**
* Legacy credentials can be copied over to EAS if the user does not have
* EAS credentials set up yet
*/
export async function canCopyLegacyCredentialsAsync(
ctx: Context,
app: AppLookupParams
): Promise<boolean> {
const appCredentials = await ctx.android.getAndroidAppCredentialsWithCommonFieldsAsync(app);
if (appCredentials) {
return false; // modern credentials already exist
}
const legacyAppCredentials = await ctx.android.getLegacyAndroidAppCredentialsWithCommonFieldsAsync(
app
);
return !!legacyAppCredentials; // user has some legacy credentials
}
export async function promptUserAndCopyLegacyCredentialsAsync(
ctx: Context,
app: AppLookupParams
): Promise<void> {
assert(
await canCopyLegacyCredentialsAsync(ctx, app),
'User not eligible to copy classic build credentials to EAS'
);
const spinner = ora('Classic credentials detected, copying to EAS...').start();
try {
const legacyAppCredentials = await ctx.android.getLegacyAndroidAppCredentialsWithCommonFieldsAsync(
app
);
if (!legacyAppCredentials) {
return;
}
const appCredentials = await ctx.android.createOrGetExistingAndroidAppCredentialsWithBuildCredentialsAsync(
app
);
const legacyFcm = legacyAppCredentials.androidFcm;
if (legacyFcm) {
const clonedFcm = await ctx.android.createFcmAsync(
app.account,
legacyFcm.credential,
legacyFcm.version
);
await ctx.android.updateAndroidAppCredentialsAsync(appCredentials, {
androidFcmId: clonedFcm.id,
});
}
const legacyBuildCredentials = await ctx.android.getLegacyAndroidAppBuildCredentialsAsync(app);
const legacyKeystore = legacyBuildCredentials?.androidKeystore ?? null;
if (legacyKeystore) {
const clonedKeystore = await ctx.android.createKeystoreAsync(app.account, {
keystore: legacyKeystore.keystore,
keystorePassword: legacyKeystore.keystorePassword,
keyAlias: legacyKeystore.keyAlias,
keyPassword: legacyKeystore.keyPassword ?? undefined,
type: legacyKeystore.type,
});
await createOrUpdateDefaultAndroidAppBuildCredentialsAsync(ctx, app, {
androidKeystoreId: clonedKeystore.id,
});
}
} catch (e) {
spinner.fail(`Unable to migrate credentials to EAS.`);
throw e;
}
spinner.succeed('Credentials copied to EAS.');
}
export function getAppLookupParamsFromContext(ctx: Context): AppLookupParams {
ctx.ensureProjectContext();
const projectName = ctx.exp.slug;
@@ -17,7 +95,7 @@ export function getAppLookupParamsFromContext(ctx: Context): AppLookupParams {
throw new Error(`You do not have access to account: ${accountName}`);
}
const androidApplicationIdentifier = ctx.exp.android?.package;
const androidApplicationIdentifier = getApplicationId(ctx.projectDir, ctx.exp);
if (!androidApplicationIdentifier) {
throw new Error(
`android.package needs to be defined in your ${getProjectConfigDescription(
@@ -42,15 +120,23 @@ export async function createOrUpdateDefaultAndroidAppBuildCredentialsAsync(
!ctx.nonInteractive,
'createOrUpdateDefaultAndroidAppBuildCredentialsAsync must be run in interactive mode'
);
const existingDefaultBuildCredentials = await ctx.newAndroid.getDefaultAndroidAppBuildCredentialsAsync(
const existingDefaultBuildCredentials = await ctx.android.getDefaultAndroidAppBuildCredentialsAsync(
appLookupParams
);
if (existingDefaultBuildCredentials) {
return await ctx.newAndroid.updateAndroidAppBuildCredentialsAsync(
return await ctx.android.updateAndroidAppBuildCredentialsAsync(
existingDefaultBuildCredentials,
{ androidKeystoreId }
);
}
return await ctx.android.createAndroidAppBuildCredentialsAsync(appLookupParams, {
name: generateRandomName(),
isDefault: true,
androidKeystoreId,
});
}
export async function promptForNameAsync(): Promise<string> {
const { providedName } = await promptAsync({
type: 'text',
name: 'providedName',
@@ -58,10 +144,23 @@ export async function createOrUpdateDefaultAndroidAppBuildCredentialsAsync(
initial: generateRandomName(),
validate: (input: string) => input !== '',
});
return await ctx.newAndroid.createAndroidAppBuildCredentialsAsync(appLookupParams, {
name: providedName,
isDefault: true,
androidKeystoreId,
return providedName;
}
/**
* sort a build credentials array in descending order of preference
* prefer default credentials, then prefer names that come first lexicographically
*/
export function sortBuildCredentials(
androidAppBuildCredentialsList: AndroidAppBuildCredentialsFragment[]
): AndroidAppBuildCredentialsFragment[] {
return androidAppBuildCredentialsList.sort((buildCredentialsA, buildCredentialsB) => {
if (buildCredentialsA.isDefault) {
return -1;
} else if (buildCredentialsB.isDefault) {
return 1;
}
return buildCredentialsA.name.localeCompare(buildCredentialsB.name);
});
}
@@ -0,0 +1,30 @@
import { AndroidFcmFragment, AndroidFcmVersion } from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
import { Context } from '../../context';
export class CreateFcm {
constructor(private account: Account) {}
public async runAsync(ctx: Context): Promise<AndroidFcmFragment> {
if (ctx.nonInteractive) {
throw new Error(`FCM API Key cannot be uploaded in non-interactive mode.`);
}
const { fcmApiKey } = await promptAsync([
{
type: 'text',
name: 'fcmApiKey',
message: 'FCM API Key',
validate: (value: string) => value.length > 0 || "FCM API Key can't be empty",
},
]);
const fcmFragment = await ctx.android.createFcmAsync(
this.account,
fcmApiKey,
AndroidFcmVersion.Legacy
);
Log.succeed('Uploaded FCM API Key');
return fcmFragment;
}
}
@@ -1,11 +1,11 @@
import { AndroidKeystoreFragment } from '../../../../graphql/generated';
import Log from '../../../../log';
import { Account } from '../../../../user/Account';
import { Context } from '../../../context';
import { askForUserProvidedAsync } from '../../../utils/promptForCredentials';
import { KeystoreWithType, keystoreSchema } from '../../credentials';
import { generateRandomKeystoreAsync } from '../../utils/keystore';
import { getKeystoreWithType, validateKeystore } from '../../utils/keystoreNew';
import { AndroidKeystoreFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Account } from '../../../user/Account';
import { Context } from '../../context';
import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
import { KeystoreWithType, keystoreSchema } from '../credentials';
import { generateRandomKeystoreAsync } from '../utils/keystore';
import { getKeystoreWithType, validateKeystore } from '../utils/keystoreNew';
export class CreateKeystore {
constructor(private account: Account) {}
@@ -15,7 +15,7 @@ export class CreateKeystore {
throw new Error(`New keystore cannot be created in non-interactive mode.`);
}
const keystore = await this.provideOrGenerateAsync();
const keystoreFragment = await ctx.newAndroid.createKeystoreAsync(this.account, keystore);
const keystoreFragment = await ctx.android.createKeystoreAsync(this.account, keystore);
Log.succeed('Created keystore');
return keystoreFragment;
}
@@ -1,66 +1,82 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import { AndroidAppBuildCredentialsFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { maybeRenameExistingFileAsync } from '../../../utils/files';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { AppLookupParams, formatProjectFullName } from '../api/GraphqlClient';
import { displayAndroidKeystore } from '../utils/printCredentials';
interface DownloadKeystoreOptions {
displayCredentials?: boolean;
app: AppLookupParams;
displaySensitiveInformation?: boolean;
outputPath?: string;
}
export class DownloadKeystore implements Action {
constructor(private projectFullName: string, private options?: DownloadKeystoreOptions) {}
export class DownloadKeystore {
constructor(private options: DownloadKeystoreOptions) {}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const keystore = await ctx.android.fetchKeystoreAsync(this.projectFullName);
public async runAsync(
ctx: Context,
buildCredentials: AndroidAppBuildCredentialsFragment
): Promise<void> {
const keystore = buildCredentials.androidKeystore;
if (!keystore) {
Log.warn('There is no valid Keystore defined for this app');
Log.warn(
`There is no valid Keystore defined for build credentials: ${buildCredentials.name}`
);
return;
}
let displayCredentials = this.options?.displayCredentials;
if (displayCredentials === undefined && !ctx.nonInteractive) {
displayCredentials = await confirmAsync({
message: 'Do you want to display the Android Keystore credentials?',
let displaySensitiveInformation = this.options?.displaySensitiveInformation;
if (displaySensitiveInformation === undefined && !ctx.nonInteractive) {
displaySensitiveInformation = await confirmAsync({
message: 'Do you want to display the sensitive information of the Android Keystore?',
});
}
const keystorePath =
this.options?.outputPath ?? `${this.projectFullName.replace('/', '__')}.jks`;
const projectFullName = formatProjectFullName(this.options.app);
const keystorePath = this.options?.outputPath ?? `${projectFullName.replace('/', '__')}.jks`;
await maybeRenameExistingFileAsync(ctx.projectDir, keystorePath);
Log.log(`Saving Keystore to ${keystorePath}`);
await fs.writeFile(keystorePath, keystore.keystore, 'base64');
if (displayCredentials) {
Log.log(`Keystore credentials
Keystore password: ${chalk.bold(keystore.keystorePassword)}
Key alias: ${chalk.bold(keystore.keyAlias)}
Key password: ${chalk.bold(keystore.keyPassword)}
// non-sensitive information
displayAndroidKeystore(keystore);
Path to Keystore: ${keystorePath}
if (displaySensitiveInformation) {
Log.newLine();
Log.log(`Sensitive Keystore information:
Keystore password: ${chalk.bold(keystore.keystorePassword)}
Key alias: ${chalk.bold(keystore.keyAlias)}
Key password: ${chalk.bold(keystore.keyPassword)}
Path to Keystore: ${keystorePath}
`);
}
Log.newLine();
}
}
export class BackupKeystore implements Action {
constructor(private projectFullName: string) {}
export class BackupKeystore {
constructor(private app: AppLookupParams) {}
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...');
await manager.runActionAsync(
new DownloadKeystore(this.projectFullName, {
displayCredentials: true,
outputPath: `${this.projectFullName}.bak.jks`.replace('/', '__'),
})
);
public async runAsync(
ctx: Context,
buildCredentials: AndroidAppBuildCredentialsFragment
): Promise<void> {
if (!buildCredentials.androidKeystore) {
return;
}
const projectFullName = formatProjectFullName(this.app);
Log.warn('Backing up your old Android Keystore now...');
await new DownloadKeystore({
app: this.app,
outputPath: `${projectFullName}.bak.jks`.replace('/', '__'),
displaySensitiveInformation: true,
}).runAsync(ctx, buildCredentials);
}
}
@@ -1,22 +0,0 @@
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
export class UpdateFcmKey implements Action {
constructor(private experienceName: string) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const { fcmApiKey } = await promptAsync([
{
type: 'text',
name: 'fcmApiKey',
message: 'FCM API Key',
validate: (value: string) => value.length > 0 || "FCM API Key can't be empty",
},
]);
await ctx.android.updateFcmKeyAsync(this.experienceName, fcmApiKey);
Log.succeed('Updated successfully');
}
}
@@ -0,0 +1,39 @@
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Context } from '../../context';
import { AppLookupParams, formatProjectFullName } from '../api/GraphqlClient';
export class RemoveFcm {
constructor(private app: AppLookupParams) {}
async runAsync(ctx: Context): Promise<void> {
if (ctx.nonInteractive) {
throw new Error(
"Deleting an FCM Api Key is a destructive operation. Start the CLI without the '--non-interactive' flag to delete the credentials."
);
}
const appCredentials = await ctx.android.getAndroidAppCredentialsWithCommonFieldsAsync(
this.app
);
const fcm = appCredentials?.androidFcm;
if (!fcm) {
Log.warn(
`There is no valid FCM Api Key defined for ${formatProjectFullName(this.app)}, ${
this.app.androidApplicationIdentifier
}`
);
return;
}
const confirm = await confirmAsync({
message: 'Permanently delete the FCM Api Key from Expo servers?',
initial: false,
});
if (!confirm) {
return;
}
await ctx.android.deleteFcmAsync(fcm);
Log.succeed('FCM Api Key removed');
}
}
@@ -1,38 +1,44 @@
import chalk from 'chalk';
import { AndroidAppBuildCredentialsFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
import { BackupKeystore } from './DownloadKeystore';
export class RemoveKeystore implements Action {
constructor(private projectFullName: string) {}
export class RemoveKeystore {
constructor(private app: AppLookupParams) {}
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.');
async runAsync(
ctx: Context,
buildCredentials: AndroidAppBuildCredentialsFragment
): Promise<void> {
if (ctx.nonInteractive) {
throw new Error(
"Deleting a keystore is a destructive operation. Start the CLI without the '--non-interactive' flag to delete the credentials."
);
}
const keystore = buildCredentials.androidKeystore;
if (!keystore) {
Log.warn(
`There is no valid Keystore defined for build credentials: ${buildCredentials.name}`
);
return;
}
this.displayWarning();
if (ctx.nonInteractive) {
throw new Error(
"Deleting build credentials is a destructive operation. Start the CLI without the '--non-interactive' flag to delete the credentials."
);
}
const confirm = await confirmAsync({
message: 'Permanently delete the Android build credentials from our servers?',
message: 'Permanently delete the Android Keystore?',
initial: false,
});
if (confirm) {
await manager.runActionAsync(new BackupKeystore(this.projectFullName));
await ctx.android.removeKeystoreAsync(this.projectFullName);
Log.succeed('Keystore removed');
if (!confirm) {
return;
}
await new BackupKeystore(this.app).runAsync(ctx, buildCredentials);
await ctx.android.deleteKeystoreAsync(keystore);
Log.succeed('Keystore removed');
}
displayWarning() {
@@ -1,45 +1,136 @@
import nullthrows from 'nullthrows';
import ora from 'ora';
import {
AndroidAppBuildCredentialsFragment,
AndroidKeystoreFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { readAndroidCredentialsAsync } from '../../credentialsJson/read';
import { validateKeystoreAsync } from '../utils/keystore';
import { UpdateKeystore } from './UpdateKeystore';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { AppLookupParams } from '../api/GraphqlClient';
import {
canCopyLegacyCredentialsAsync,
createOrUpdateDefaultAndroidAppBuildCredentialsAsync,
promptUserAndCopyLegacyCredentialsAsync,
} from './BuildCredentialsUtils';
import { CreateKeystore } from './CreateKeystore';
export class SetupBuildCredentials implements Action {
constructor(private projectFullName: string) {}
interface Options {
app: AppLookupParams;
name?: string;
}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
if (await ctx.android.fetchKeystoreAsync(this.projectFullName)) {
return;
/**
* Sets up Build Credentials for Android
* @name: sets up build credentials for the specified configuration. If no name is specified, the default configuration is setup
*/
export class SetupBuildCredentials {
constructor(private options: Options) {}
async runAsync(ctx: Context): Promise<AndroidAppBuildCredentialsFragment> {
const { app, name: maybeName } = this.options;
if (!ctx.nonInteractive) {
const canCopyLegacyCredentials = await canCopyLegacyCredentialsAsync(ctx, app);
if (canCopyLegacyCredentials) {
await promptUserAndCopyLegacyCredentialsAsync(ctx, app);
}
}
const alreadySetupBuildCredentials = await this.getFullySetupBuildCredentialsAsync({
ctx,
app,
name: maybeName,
});
if (alreadySetupBuildCredentials) {
return alreadySetupBuildCredentials;
}
if (ctx.nonInteractive) {
throw new Error('Generating a new Keystore is not supported in --non-interactive mode');
}
await manager.runActionAsync(new UpdateKeystore(this.projectFullName));
}
}
export class SetupBuildCredentialsFromCredentialsJson implements Action {
constructor(
private projectFullName: string,
private options: { skipKeystoreValidation: boolean }
) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
let localCredentials;
try {
localCredentials = await readAndroidCredentialsAsync(ctx.projectDir);
} catch (error) {
Log.error(
'Reading credentials from credentials.json failed. Make sure this file is correct and all credentials are present there.'
throw new MissingCredentialsNonInteractiveError(
'Generating a new Keystore is not supported in --non-interactive mode'
);
throw error;
}
if (!this.options.skipKeystoreValidation) {
await validateKeystoreAsync(localCredentials.keystore);
const keystore = await new CreateKeystore(app.account).runAsync(ctx);
return await this.assignBuildCredentialsAsync({ ctx, app, name: maybeName, keystore });
}
async assignBuildCredentialsAsync({
ctx,
app,
name,
keystore,
}: {
ctx: Context;
app: AppLookupParams;
name?: string;
keystore: AndroidKeystoreFragment;
}): Promise<AndroidAppBuildCredentialsFragment> {
if (name) {
return await ctx.android.createOrUpdateAndroidAppBuildCredentialsByNameAsync(app, name, {
androidKeystoreId: keystore.id,
});
}
await ctx.android.updateKeystoreAsync(this.projectFullName, localCredentials.keystore);
Log.succeed('Keystore updated');
return await createOrUpdateDefaultAndroidAppBuildCredentialsAsync(ctx, app, {
androidKeystoreId: keystore.id,
});
}
async getFullySetupBuildCredentialsAsync({
ctx,
app,
name,
}: {
ctx: Context;
app: AppLookupParams;
name?: string;
}): Promise<AndroidAppBuildCredentialsFragment | null> {
if (name) {
return await this.getFullySetupBuildCredentialsByNameAsync({ ctx, app, name });
}
const defaultBuildCredentials = await ctx.android.getDefaultAndroidAppBuildCredentialsAsync(
app
);
const defaultKeystore = defaultBuildCredentials?.androidKeystore ?? null;
if (defaultKeystore) {
ora(
`Using Keystore from configuration: ${nullthrows(defaultBuildCredentials).name} (default)`
).succeed();
return defaultBuildCredentials;
}
return null;
}
async getFullySetupBuildCredentialsByNameAsync({
ctx,
app,
name,
}: {
ctx: Context;
app: AppLookupParams;
name: string;
}): Promise<AndroidAppBuildCredentialsFragment | null> {
const maybeBuildCredentials = await ctx.android.getAndroidAppBuildCredentialsByNameAsync(
app,
name
);
const keystore = maybeBuildCredentials?.androidKeystore ?? null;
if (keystore) {
const buildCredentials = nullthrows(maybeBuildCredentials);
ora(
`Using Keystore from configuration: ${buildCredentials.name}${
buildCredentials.isDefault ? ' (default)' : ''
}`
).succeed();
return buildCredentials;
}
Log.log(
`No Keystore found for configuration: ${name}${
maybeBuildCredentials?.isDefault ? ' (default)' : ''
}`
);
return null;
}
}
@@ -0,0 +1,88 @@
import {
AndroidAppBuildCredentialsFragment,
AndroidKeystoreType,
} from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Context } from '../../context';
import { readAndroidCredentialsAsync } from '../../credentialsJson/read';
import {
SelectAndroidBuildCredentials,
SelectAndroidBuildCredentialsResultType,
} from '../../manager/SelectAndroidBuildCredentials';
import { AppLookupParams } from '../api/GraphqlClient';
import { getKeystoreWithType } from '../utils/keystoreNew';
import { BackupKeystore } from './DownloadKeystore';
export class SetupBuildCredentialsFromCredentialsJson {
constructor(private app: AppLookupParams) {}
async runAsync(ctx: Context): Promise<AndroidAppBuildCredentialsFragment | null> {
if (ctx.nonInteractive) {
throw new Error(
'Setting up build credentials from credentials.json is only available in interactive mode'
);
}
let localCredentials;
try {
localCredentials = await readAndroidCredentialsAsync(ctx.projectDir);
} catch (error) {
Log.error(
'Reading credentials from credentials.json failed. Make sure this file is correct and all credentials are present there.'
);
throw error;
}
const selectBuildCredentialsResult = await new SelectAndroidBuildCredentials(this.app).runAsync(
ctx
);
if (
selectBuildCredentialsResult.resultType ===
SelectAndroidBuildCredentialsResultType.EXISTING_CREDENTIALS &&
selectBuildCredentialsResult.result.androidKeystore
) {
const buildCredentials = selectBuildCredentialsResult.result;
const confirmOverwrite = await confirmAsync({
message: `The build configuration ${buildCredentials.name} already has a keystore configured. Overwrite?`,
});
if (!confirmOverwrite) {
return null;
}
await new BackupKeystore(this.app).runAsync(ctx, buildCredentials);
}
const providedKeystoreWithType = getKeystoreWithType(localCredentials.keystore);
if (providedKeystoreWithType.type === AndroidKeystoreType.Unknown) {
const confirmKeystoreIsSketchy = await confirmAsync({
message: `The keystore you provided could not be parsed and may be corrupt. Proceed anyway?`,
});
if (!confirmKeystoreIsSketchy) {
return null;
}
}
const keystoreFragment = await ctx.android.createKeystoreAsync(
this.app.account,
providedKeystoreWithType
);
let buildCredentials: AndroidAppBuildCredentialsFragment;
if (
selectBuildCredentialsResult.resultType ===
SelectAndroidBuildCredentialsResultType.CREATE_REQUEST
) {
buildCredentials = await ctx.android.createAndroidAppBuildCredentialsAsync(this.app, {
...selectBuildCredentialsResult.result,
androidKeystoreId: keystoreFragment.id,
});
} else {
buildCredentials = await ctx.android.updateAndroidAppBuildCredentialsAsync(
selectBuildCredentialsResult.result,
{
androidKeystoreId: keystoreFragment.id,
}
);
}
Log.succeed('Keystore updated');
return buildCredentials;
}
}
@@ -1,14 +1,15 @@
import { AndroidAppBuildCredentialsFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { updateAndroidCredentialsAsync } from '../../credentialsJson/update';
export class UpdateCredentialsJson implements Action {
constructor(private projectFullName: string) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
export class UpdateCredentialsJson {
async runAsync(
ctx: Context,
buildCredentials: AndroidAppBuildCredentialsFragment
): Promise<void> {
Log.log('Updating Android credentials in credentials.json');
await updateAndroidCredentialsAsync(ctx);
await updateAndroidCredentialsAsync(ctx, buildCredentials);
Log.succeed(
'Android part of your local credentials.json is synced with values stored on EAS servers.'
);
@@ -1,46 +0,0 @@
import chalk from 'chalk';
import Log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
import { Keystore, keystoreSchema } from '../credentials';
import { generateRandomKeystoreAsync } from '../utils/keystore';
export class UpdateKeystore implements Action {
constructor(private projectFullName: string) {}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
if (await ctx.android.fetchKeystoreAsync(this.projectFullName)) {
this.displayWarning();
}
const keystore = await this.provideOrGenerateAsync();
await ctx.android.updateKeystoreAsync(this.projectFullName, keystore);
Log.succeed('Keystore updated');
}
private async provideOrGenerateAsync(): Promise<Keystore> {
const providedKeystore = await askForUserProvidedAsync(keystoreSchema);
if (providedKeystore) {
return providedKeystore;
}
return await generateRandomKeystoreAsync();
}
private displayWarning() {
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(
chalk.bold(
'Android Keystore must be identical to the one previously used to submit your app to the Google Play Store.'
)
);
Log.newLine();
}
}
@@ -0,0 +1,33 @@
import { testLegacyAndroidFcmFragment } from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { AssignFcm } from '../AssignFcm';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
describe(AssignFcm, () => {
it('assigns an fcm api key in Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: false,
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const assignFcmAction = new AssignFcm(appLookupParams);
await assignFcmAction.runAsync(ctx, testLegacyAndroidFcmFragment);
// expect app credentials to be fetched/created, then updated
expect(
ctx.android.createOrGetExistingAndroidAppCredentialsWithBuildCredentialsAsync as any
).toHaveBeenCalledTimes(1);
expect(ctx.android.updateAndroidAppCredentialsAsync as any).toHaveBeenCalledTimes(1);
});
it('works in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const assignFcmAction = new AssignFcm(appLookupParams);
// dont fail if users are running in non-interactive mode
await expect(
assignFcmAction.runAsync(ctx, testLegacyAndroidFcmFragment)
).resolves.not.toThrowError();
});
});
@@ -0,0 +1,140 @@
import { confirmAsync, promptAsync } from '../../../../prompts';
import {
getNewAndroidApiMock,
testAndroidAppCredentialsFragment,
testJksAndroidKeystoreFragment,
testLegacyAndroidAppCredentialsFragment,
testLegacyAndroidBuildCredentialsFragment,
testLegacyAndroidFcmFragment,
} from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import {
canCopyLegacyCredentialsAsync,
getAppLookupParamsFromContext,
promptUserAndCopyLegacyCredentialsAsync,
} from '../BuildCredentialsUtils';
jest.mock('../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
});
describe('BuildCredentialsUtils', () => {
describe(canCopyLegacyCredentialsAsync, () => {
it('returns true if the user has legacy credentials and no modern ones', async () => {
const ctx = createCtxMock({
nonInteractive: true,
android: {
...getNewAndroidApiMock(),
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(() => null),
getLegacyAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testLegacyAndroidAppCredentialsFragment
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const canCopyLegacyCredentials = await canCopyLegacyCredentialsAsync(ctx, appLookupParams);
expect(canCopyLegacyCredentials).toBe(true);
});
it('returns false if the user has modern credentials', async () => {
const ctx = createCtxMock({
nonInteractive: true,
android: {
...getNewAndroidApiMock(),
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testAndroidAppCredentialsFragment
),
getLegacyAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testLegacyAndroidAppCredentialsFragment
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const canCopyLegacyCredentials = await canCopyLegacyCredentialsAsync(ctx, appLookupParams);
expect(canCopyLegacyCredentials).toBe(false);
});
it('returns false if the user has no legacy credentials', async () => {
const ctx = createCtxMock({
nonInteractive: true,
android: {
...getNewAndroidApiMock(),
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(() => null),
getLegacyAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(() => null),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const canCopyLegacyCredentials = await canCopyLegacyCredentialsAsync(ctx, appLookupParams);
expect(canCopyLegacyCredentials).toBe(false);
});
it('works in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
android: {
...getNewAndroidApiMock(),
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(() => null),
getLegacyAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testLegacyAndroidAppCredentialsFragment
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const canCopyLegacyCredentials = await canCopyLegacyCredentialsAsync(ctx, appLookupParams);
expect(canCopyLegacyCredentials).toBe(true);
});
});
describe(promptUserAndCopyLegacyCredentialsAsync, () => {
it('copies all legacy credentials to EAS if the user is eligible', async () => {
(promptAsync as jest.Mock).mockImplementation(() => ({ providedName: 'test-provided-name' }));
const ctx = createCtxMock({
nonInteractive: false,
android: {
...getNewAndroidApiMock(),
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(() => null),
getLegacyAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testLegacyAndroidAppCredentialsFragment
),
getLegacyAndroidAppBuildCredentialsAsync: jest.fn(
() => testLegacyAndroidBuildCredentialsFragment
),
createKeystoreAsync: jest.fn(() => testJksAndroidKeystoreFragment),
createFcmAsync: jest.fn(() => testLegacyAndroidFcmFragment),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
await promptUserAndCopyLegacyCredentialsAsync(ctx, appLookupParams);
expect(
ctx.android.createOrGetExistingAndroidAppCredentialsWithBuildCredentialsAsync as any
).toHaveBeenCalledTimes(1);
expect(ctx.android.createFcmAsync as any).toHaveBeenCalledTimes(1);
expect(ctx.android.updateAndroidAppCredentialsAsync as any).toHaveBeenCalledTimes(1);
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
expect(ctx.android.createAndroidAppBuildCredentialsAsync as any).toHaveBeenCalledTimes(1);
});
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
android: {
...getNewAndroidApiMock(),
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(() => null),
getLegacyAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testLegacyAndroidAppCredentialsFragment
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
await expect(
promptUserAndCopyLegacyCredentialsAsync(ctx, appLookupParams)
).rejects.toThrowError();
});
});
});
@@ -0,0 +1,27 @@
import { promptAsync } from '../../../../prompts';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { CreateFcm } from '../CreateFcm';
jest.mock('../../../../prompts');
(promptAsync as jest.Mock).mockImplementation(() => ({ fcmApiKey: 'blah' }));
describe(CreateFcm, () => {
it('creates an fcm api key in Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: false });
const appLookupParams = getAppLookupParamsFromContext(ctx);
const createFcmAction = new CreateFcm(appLookupParams.account);
await createFcmAction.runAsync(ctx);
// expect fcm api key to be created on expo servers
expect(ctx.android.createFcmAsync as any).toHaveBeenCalledTimes(1);
});
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
const appLookupParams = getAppLookupParamsFromContext(ctx);
const createFcmAction = new CreateFcm(appLookupParams.account);
// fail if users are running in non-interactive mode
await expect(createFcmAction.runAsync(ctx)).rejects.toThrowError();
});
});
@@ -1,9 +1,9 @@
import { confirmAsync } from '../../../../../prompts';
import { createCtxMock } from '../../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContext } from '../../BuildCredentialsUtils';
import { confirmAsync } from '../../../../prompts';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { CreateKeystore } from '../CreateKeystore';
jest.mock('../../../../../prompts');
jest.mock('../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
describe('CreateKeystore', () => {
@@ -14,7 +14,7 @@ describe('CreateKeystore', () => {
await createKeystoreAction.runAsync(ctx);
// expect keystore to be created on expo servers
expect(ctx.newAndroid.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
});
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
@@ -0,0 +1,43 @@
import fs from 'fs-extra';
import { testAndroidBuildCredentialsFragment } from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { DownloadKeystore } from '../DownloadKeystore';
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
});
describe(DownloadKeystore, () => {
it('downloads a keystore', async () => {
const fsWriteFileSpy = jest.spyOn(fs, 'writeFile');
const ctx = createCtxMock({
nonInteractive: false,
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const downloadKeystoreAction = new DownloadKeystore({ app: appLookupParams });
await downloadKeystoreAction.runAsync(ctx, testAndroidBuildCredentialsFragment);
expect(fsWriteFileSpy as any).toHaveBeenCalledTimes(1);
expect(fsWriteFileSpy as any).toHaveBeenCalledWith(
`@${appLookupParams.account.name}__${appLookupParams.projectName}.jks`,
testAndroidBuildCredentialsFragment.androidKeystore?.keystore,
'base64'
);
fsWriteFileSpy.mockRestore();
});
it('works in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
const appLookupParams = getAppLookupParamsFromContext(ctx);
const downloadKeystoreAction = new DownloadKeystore({ app: appLookupParams });
await expect(
downloadKeystoreAction.runAsync(ctx, testAndroidBuildCredentialsFragment)
).resolves.not.toThrowError();
});
});
@@ -0,0 +1,45 @@
import { confirmAsync } from '../../../../prompts';
import {
getNewAndroidApiMock,
testAndroidAppCredentialsFragment,
} from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { RemoveFcm } from '../RemoveFcm';
jest.mock('../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
});
describe(RemoveFcm, () => {
it('removes an FCM Api Key', async () => {
const ctx = createCtxMock({
nonInteractive: false,
android: {
...getNewAndroidApiMock(),
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testAndroidAppCredentialsFragment
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const removeFcmApiKeyAction = new RemoveFcm(appLookupParams);
await removeFcmApiKeyAction.runAsync(ctx);
expect(ctx.android.deleteFcmAsync as any).toHaveBeenCalledTimes(1);
});
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
const appLookupParams = getAppLookupParamsFromContext(ctx);
const removeFcmApiKeyAction = new RemoveFcm(appLookupParams);
await expect(removeFcmApiKeyAction.runAsync(ctx)).rejects.toThrowError();
});
});
@@ -1,122 +1,38 @@
import prompts from 'prompts';
import { Action } from '../../../CredentialsManager';
import { testKeystore } from '../../../__tests__/fixtures-android';
import { testExperienceName } from '../../../__tests__/fixtures-constants';
import { createCtxMock, createManagerMock } from '../../../__tests__/fixtures-context';
import { BackupKeystore } from '../DownloadKeystore';
import { confirmAsync } from '../../../../prompts';
import { testAndroidBuildCredentialsFragment } from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { RemoveKeystore } from '../RemoveKeystore';
jest.mock('prompts');
jest.mock('../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
const originalWarn = console.warn;
const originalLog = console.log;
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.warn = jest.fn();
console.log = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.warn = originalWarn;
console.log = originalLog;
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
});
beforeEach(() => {
(prompts as any).mockReset();
(prompts as any).mockImplementation(() => {
throw new Error('Should not be called');
});
});
describe('run RemoveKeystore when keystore does exist', () => {
it('should display warning prompt and abort', async () => {
describe(RemoveKeystore, () => {
it('removes a keystore', async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
},
nonInteractive: false,
});
const manager = createManagerMock();
(prompts as any).mockImplementationOnce(() => ({ value: false })); // prompt with warning message, false means abort
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).toHaveBeenCalledTimes(1);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
const appLookupParams = getAppLookupParamsFromContext(ctx);
const removeKeystoreAction = new RemoveKeystore(appLookupParams);
await removeKeystoreAction.runAsync(ctx, testAndroidBuildCredentialsFragment);
expect(ctx.android.deleteKeystoreAsync as any).toHaveBeenCalledTimes(1);
});
it('should display warning prompt and continue', async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
},
});
const manager = createManagerMock();
(manager.runActionAsync as any).mockImplementationOnce((action: Action) => {
expect(action).toBeInstanceOf(BackupKeystore);
});
(prompts as any).mockImplementationOnce(() => ({ value: true })); // prompt with warning message, true means continue
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).toHaveBeenCalledTimes(1);
expect(prompts).toHaveBeenCalledTimes(1);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.removeKeystoreAsync).toHaveBeenCalledTimes(1);
});
it('should not display a prompt in non-interactive mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
},
});
const manager = createManagerMock();
try {
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
throw new Error('make sure expect in catch is called');
} catch (e) {
expect(e.message).toMatch('Deleting build credentials is a destructive operation');
}
expect(prompts).not.toHaveBeenCalled();
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
});
});
describe('run RemoveKeystore when keystore does not exist', () => {
it("shouldn't display warning prompt and finish", async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn().mockImplementationOnce(() => null),
},
});
const manager = createManagerMock();
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).not.toHaveBeenCalled();
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
});
it("shouldn't display warning prompt and finish in non-interactive mode", async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn().mockImplementationOnce(() => null),
},
});
const manager = createManagerMock();
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).not.toHaveBeenCalled();
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
const appLookupParams = getAppLookupParamsFromContext(ctx);
const removeKeystoreAction = new RemoveKeystore(appLookupParams);
await expect(
removeKeystoreAction.runAsync(ctx, testAndroidBuildCredentialsFragment)
).rejects.toThrowError();
});
});
@@ -1,100 +1,74 @@
import prompts from 'prompts';
import { Action } from '../../../CredentialsManager';
import { testKeystore } from '../../../__tests__/fixtures-android';
import { testExperienceName } from '../../../__tests__/fixtures-constants';
import { createCtxMock, createManagerMock } from '../../../__tests__/fixtures-context';
import { confirmAsync, promptAsync } from '../../../../prompts';
import {
getNewAndroidApiMock,
testAndroidBuildCredentialsFragment,
testJksAndroidKeystoreFragment,
} from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { MissingCredentialsNonInteractiveError } from '../../../errors';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { SetupBuildCredentials } from '../SetupBuildCredentials';
import { UpdateKeystore } from '../UpdateKeystore';
jest.mock('prompts');
jest.mock('../../utils/keystore');
jest.mock('../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
const originalWarn = console.warn;
const originalLog = console.log;
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
beforeAll(() => {
// console.warn = jest.fn();
// console.log = jest.fn();
console.log = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.warn = originalWarn;
console.log = originalLog;
});
beforeEach(() => {
(prompts as any).mockReset();
(prompts as any).mockImplementation(() => {
throw new Error('Should not be called');
});
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
});
describe('run SetupBuildCredentials when www has valid credentials', () => {
it('should fetch credentials and exit', async () => {
describe('SetupBuildCredentials', () => {
it('skips setup when there are prior credentials', async () => {
const ctx = createCtxMock({
nonInteractive: false,
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
...getNewAndroidApiMock(),
getDefaultAndroidAppBuildCredentialsAsync: jest.fn(
() => testAndroidBuildCredentialsFragment
),
},
});
const manager = createManagerMock();
const appLookupParams = getAppLookupParamsFromContext(ctx);
const setupBuildCredentialsAction = new SetupBuildCredentials({ app: appLookupParams });
await setupBuildCredentialsAction.runAsync(ctx);
await new SetupBuildCredentials(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).not.toHaveBeenCalled();
// expect keystore not to be created
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledTimes(0);
});
it('sets up credentials when there are no prior credentials', async () => {
(promptAsync as jest.Mock).mockImplementation(() => ({ providedName: 'test-provided-name' }));
const ctx = createCtxMock({
nonInteractive: false,
android: {
...getNewAndroidApiMock(),
createKeystoreAsync: jest.fn(() => testJksAndroidKeystoreFragment),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const setupBuildCredentialsAction = new SetupBuildCredentials({ app: appLookupParams });
await setupBuildCredentialsAction.runAsync(ctx);
it('should fetch credentials and exit in non-interactive mode', async () => {
// expect keystore to be created
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
});
it('errors in Non-Interactive Mode', async () => {
(promptAsync as jest.Mock).mockImplementation(() => ({ providedName: 'test-provided-name' }));
const ctx = createCtxMock({
nonInteractive: true,
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
...getNewAndroidApiMock(),
},
});
const manager = createManagerMock();
await new SetupBuildCredentials(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).not.toHaveBeenCalled();
});
});
describe('run SetupBuildCredentials when www has no credentials', () => {
it('should try to fetch and launch UpdateKeystore', async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn(() => null),
},
});
const manager = createManagerMock();
(manager.runActionAsync as any).mockImplementationOnce((action: Action) => {
expect(action).toBeInstanceOf(UpdateKeystore);
});
await new SetupBuildCredentials(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(0); // will be called in UpdateKeystore
});
it('should fail if credentials are missing in non-interactive mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
android: {
fetchKeystore: jest.fn(() => null),
},
});
const manager = createManagerMock();
try {
await new SetupBuildCredentials(testExperienceName).runAsync(manager, ctx);
throw new Error('SetupBuildCredentials.runAsync should throw an error');
} catch (error) {
expect(error.message).toMatch('Generating a new Keystore is not supported');
}
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
const appLookupParams = getAppLookupParamsFromContext(ctx);
const setupBuildCredentialsAction = new SetupBuildCredentials({ app: appLookupParams });
await expect(setupBuildCredentialsAction.runAsync(ctx)).rejects.toThrowError(
MissingCredentialsNonInteractiveError
);
});
});
@@ -0,0 +1,149 @@
import { vol } from 'memfs';
import { AndroidKeystoreType } from '../../../../graphql/generated';
import { confirmAsync } from '../../../../prompts';
import {
getNewAndroidApiMock,
testAndroidBuildCredentialsFragment,
testJksAndroidKeystoreFragment,
} from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import {
SelectAndroidBuildCredentials,
SelectAndroidBuildCredentialsResultType,
} from '../../../manager/SelectAndroidBuildCredentials';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { SetupBuildCredentialsFromCredentialsJson } from '../SetupBuildCredentialsFromCredentialsJson';
jest.mock('fs');
jest.mock('../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
jest.mock('../../../manager/SelectAndroidBuildCredentials');
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
});
beforeEach(() => {
vol.reset();
});
describe(SetupBuildCredentialsFromCredentialsJson, () => {
it('sets up a new build configuration from credentials.json upon user request', async () => {
(SelectAndroidBuildCredentials as any).mockImplementation(() => {
return {
runAsync: () => {
return {
resultType: SelectAndroidBuildCredentialsResultType.CREATE_REQUEST,
result: { isDefault: true, name: 'test configuration' },
};
},
};
});
const ctx = createCtxMock({
nonInteractive: false,
android: {
...getNewAndroidApiMock(),
createKeystoreAsync: jest.fn(() => testJksAndroidKeystoreFragment),
},
});
vol.fromJSON({
'./credentials.json': JSON.stringify({
android: {
keystore: {
keystorePath: 'keystore.jks',
keystorePassword: testJksAndroidKeystoreFragment.keystorePassword,
keyAlias: testJksAndroidKeystoreFragment.keyAlias,
keyPassword: testJksAndroidKeystoreFragment.keyPassword,
},
},
}),
'keystore.jks': 'some-binary-content',
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const setupBuildCredentialsAction = new SetupBuildCredentialsFromCredentialsJson(
appLookupParams
);
await setupBuildCredentialsAction.runAsync(ctx);
// expect keystore to be created with credentials.json content
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
expect(ctx.android.createKeystoreAsync as any).toBeCalledWith(appLookupParams.account, {
keystorePassword: testJksAndroidKeystoreFragment.keystorePassword,
keyAlias: testJksAndroidKeystoreFragment.keyAlias,
keyPassword: testJksAndroidKeystoreFragment.keyPassword,
keystore: Buffer.from('some-binary-content').toString('base64'),
type: AndroidKeystoreType.Unknown,
});
// expect new build credentials to be created
expect(ctx.android.createAndroidAppBuildCredentialsAsync as any).toHaveBeenCalledTimes(1);
});
it('uses an existing build configuration upon user request', async () => {
(SelectAndroidBuildCredentials as any).mockImplementation(() => {
return {
runAsync: () => {
return {
resultType: SelectAndroidBuildCredentialsResultType.EXISTING_CREDENTIALS,
result: testAndroidBuildCredentialsFragment,
};
},
};
});
const ctx = createCtxMock({
nonInteractive: false,
android: {
...getNewAndroidApiMock(),
createKeystoreAsync: jest.fn(() => testJksAndroidKeystoreFragment),
},
});
vol.fromJSON({
'./credentials.json': JSON.stringify({
android: {
keystore: {
keystorePath: 'keystore.jks',
keystorePassword: testJksAndroidKeystoreFragment.keystorePassword,
keyAlias: testJksAndroidKeystoreFragment.keyAlias,
keyPassword: testJksAndroidKeystoreFragment.keyPassword,
},
},
}),
'keystore.jks': 'some-binary-content',
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const setupBuildCredentialsAction = new SetupBuildCredentialsFromCredentialsJson(
appLookupParams
);
await setupBuildCredentialsAction.runAsync(ctx);
// expect keystore to be created with credentials.json content
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
expect(ctx.android.createKeystoreAsync as any).toBeCalledWith(appLookupParams.account, {
keystorePassword: testJksAndroidKeystoreFragment.keystorePassword,
keyAlias: testJksAndroidKeystoreFragment.keyAlias,
keyPassword: testJksAndroidKeystoreFragment.keyPassword,
keystore: Buffer.from('some-binary-content').toString('base64'),
type: AndroidKeystoreType.Unknown,
});
// expect existing build credentials to be updated
expect(ctx.android.updateAndroidAppBuildCredentialsAsync as any).toHaveBeenCalledTimes(1);
});
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const setupBuildCredentialsAction = new SetupBuildCredentialsFromCredentialsJson(
appLookupParams
);
await expect(setupBuildCredentialsAction.runAsync(ctx)).rejects.toThrowError();
});
});
@@ -1,118 +0,0 @@
import fs from 'fs-extra';
import { vol } from 'memfs';
import prompts from 'prompts';
import { testKeystore } from '../../../__tests__/fixtures-android';
import { testExperienceName } from '../../../__tests__/fixtures-constants';
import { createCtxMock, createManagerMock } from '../../../__tests__/fixtures-context';
import { UpdateKeystore } from '../UpdateKeystore';
jest.mock('fs');
jest.mock('prompts');
jest.mock('../../utils/keystore');
const originalWarn = console.warn;
const originalLog = console.log;
beforeAll(() => {
console.warn = jest.fn();
console.log = jest.fn();
});
afterAll(() => {
console.warn = originalWarn;
console.log = originalLog;
});
beforeEach(() => {
vol.reset();
(prompts as any).mockReset();
(prompts as any).mockImplementation(() => {
throw new Error('Should not be called');
});
});
describe('run UpdateKeystore when keystore exist on www', () => {
it('should fetch old credentials and generate new ones', async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
},
});
const manager = createManagerMock();
(prompts as any).mockImplementationOnce(() => ({ value: true })); // promptForCredentials: let expo handle
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).toHaveBeenCalledTimes(1);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
});
it('should fetch old credentials and ask users to provide new ones', async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
},
});
const manager = createManagerMock();
await fs.writeFile('/keystore.jks', testKeystore.keystore, 'base64');
(prompts as any)
.mockImplementationOnce(() => ({ value: false })) // promptForCredentials: user specified
.mockImplementationOnce(() => ({ input: '/keystore.jks' })) // keystore
.mockImplementationOnce(() => ({ input: 'test' })) // keystore password
.mockImplementationOnce(() => ({ input: 'test' })) // key alias
.mockImplementationOnce(() => ({ input: 'test' })); // key password
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).toHaveBeenCalledTimes(5);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
});
});
describe('run UpdateKeystore when keystore does not exist on www', () => {
it('should fail to fetch credentials and generate new ones', async () => {
const ctx = createCtxMock({
android: {
fetchKeystore: jest.fn(() => null),
},
});
const manager = createManagerMock();
(prompts as any).mockImplementationOnce(() => ({ value: true })); // Let expo handle
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).toHaveBeenCalledTimes(1);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
});
it('should fail to fetch credentials and ask users to provide new ones', async () => {
const ctx = createCtxMock({
android: {
fetchKeystore: jest.fn(() => null),
},
});
const manager = createManagerMock();
await fs.writeFile('/keystore.jks', testKeystore.keystore, 'base64');
(prompts as any)
.mockImplementationOnce(() => ({ value: false })) // promptForCredentials: user specified
.mockImplementationOnce(() => ({ input: '/keystore.jks' })) // keystore
.mockImplementationOnce(() => ({ input: 'test' })) // keystore password
.mockImplementationOnce(() => ({ input: 'test' })) // key alias
.mockImplementationOnce(() => ({ input: 'test' })); // key password
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
expect(manager.runActionAsync).not.toHaveBeenCalled();
expect(prompts).toHaveBeenCalledTimes(5);
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
});
});
@@ -1,133 +0,0 @@
import nullthrows from 'nullthrows';
import {
AndroidAppBuildCredentialsFragment,
AndroidKeystoreFragment,
} from '../../../../graphql/generated';
import Log from '../../../../log';
import { Context } from '../../../context';
import { MissingCredentialsNonInteractiveError } from '../../../errors';
import { AppLookupParams } from '../../api/GraphqlClient';
import { createOrUpdateDefaultAndroidAppBuildCredentialsAsync } from '../BuildCredentialsUtils';
import { CreateKeystore } from './CreateKeystore';
interface Options {
app: AppLookupParams;
name?: string;
}
/**
* Sets up Build Credentials for Android
* @name: sets up build credentials for the specified configuration. If no name is specified, the default configuration is setup
*/
export class SetupBuildCredentials {
constructor(private options: Options) {}
async runAsync(ctx: Context): Promise<AndroidAppBuildCredentialsFragment> {
const { app, name: maybeName } = this.options;
const alreadySetupBuildCredentials = await this.getFullySetupBuildCredentialsAsync({
ctx,
app,
name: maybeName,
});
if (alreadySetupBuildCredentials) {
return alreadySetupBuildCredentials;
}
if (ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError(
'Generating a new Keystore is not supported in --non-interactive mode'
);
}
const keystore = await new CreateKeystore(app.account).runAsync(ctx);
return await this.assignBuildCredentialsAsync({ ctx, app, name: maybeName, keystore });
}
async assignBuildCredentialsAsync({
ctx,
app,
name,
keystore,
}: {
ctx: Context;
app: AppLookupParams;
name?: string;
keystore: AndroidKeystoreFragment;
}): Promise<AndroidAppBuildCredentialsFragment> {
if (name) {
return await ctx.newAndroid.createOrUpdateAndroidAppBuildCredentialsByNameAsync(app, name, {
androidKeystoreId: keystore.id,
});
}
return await createOrUpdateDefaultAndroidAppBuildCredentialsAsync(ctx, app, {
androidKeystoreId: keystore.id,
});
}
async getFullySetupBuildCredentialsAsync({
ctx,
app,
name,
}: {
ctx: Context;
app: AppLookupParams;
name?: string;
}): Promise<AndroidAppBuildCredentialsFragment | null> {
if (name) {
return await this.getFullySetupBuildCredentialsByNameAsync({ ctx, app, name });
}
const defaultBuildCredentials = await ctx.newAndroid.getDefaultAndroidAppBuildCredentialsAsync(
app
);
const defaultKeystore = defaultBuildCredentials?.androidKeystore ?? null;
if (defaultKeystore) {
Log.log(
`Using Keystore from configuration: ${nullthrows(defaultBuildCredentials).name} (default)`
);
return defaultBuildCredentials;
}
// fall back to legacy credentials if we cant find a default keystore
const legacyBuildCredentials = await ctx.newAndroid.getLegacyAndroidAppBuildCredentialsAsync(
app
);
const legacyKeystore = legacyBuildCredentials?.androidKeystore ?? null;
if (legacyKeystore) {
Log.log('Using Keystore ported from Expo Classic (expo-cli)');
return legacyBuildCredentials;
}
return null;
}
async getFullySetupBuildCredentialsByNameAsync({
ctx,
app,
name,
}: {
ctx: Context;
app: AppLookupParams;
name: string;
}): Promise<AndroidAppBuildCredentialsFragment | null> {
const maybeBuildCredentials = await ctx.newAndroid.getAndroidAppBuildCredentialsByNameAsync(
app,
name
);
const keystore = maybeBuildCredentials?.androidKeystore ?? null;
if (keystore) {
const buildCredentials = nullthrows(maybeBuildCredentials);
Log.log(
`Using Keystore from configuration: ${buildCredentials.name}${
buildCredentials.isDefault ? ' (default)' : ''
}`
);
return buildCredentials;
}
Log.log(
`No Keystore found for configuration: ${name}${
maybeBuildCredentials?.isDefault ? ' (default)' : ''
}`
);
return null;
}
}
@@ -1,92 +0,0 @@
import { confirmAsync, promptAsync } from '../../../../../prompts';
import {
getNewAndroidApiMockWithoutCredentials,
testAndroidBuildCredentialsFragment,
testJksAndroidKeystoreFragment,
testLegacyAndroidBuildCredentialsFragment,
} from '../../../../__tests__/fixtures-android-new';
import { createCtxMock } from '../../../../__tests__/fixtures-context';
import { MissingCredentialsNonInteractiveError } from '../../../../errors';
import { getAppLookupParamsFromContext } from '../../BuildCredentialsUtils';
import { SetupBuildCredentials } from '../SetupBuildCredentials';
jest.mock('../../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
});
describe('SetupBuildCredentials', () => {
it('skips setup when there are prior legacy credentials', async () => {
const ctx = createCtxMock({
nonInteractive: false,
newAndroid: {
...getNewAndroidApiMockWithoutCredentials(),
getLegacyAndroidAppBuildCredentialsAsync: jest.fn(
() => testLegacyAndroidBuildCredentialsFragment
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const setupBuildCredentialsAction = new SetupBuildCredentials({ app: appLookupParams });
await setupBuildCredentialsAction.runAsync(ctx);
// expect keystore not to be created
expect(ctx.newAndroid.createKeystoreAsync as any).toHaveBeenCalledTimes(0);
});
it('skips setup when there are prior credentials', async () => {
const ctx = createCtxMock({
nonInteractive: false,
newAndroid: {
...getNewAndroidApiMockWithoutCredentials(),
getDefaultAndroidAppBuildCredentialsAsync: jest.fn(
() => testAndroidBuildCredentialsFragment
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const setupBuildCredentialsAction = new SetupBuildCredentials({ app: appLookupParams });
await setupBuildCredentialsAction.runAsync(ctx);
// expect keystore not to be created
expect(ctx.newAndroid.createKeystoreAsync as any).toHaveBeenCalledTimes(0);
});
it('sets up credentials when there are no prior credentials', async () => {
(promptAsync as jest.Mock).mockImplementation(() => ({ providedName: 'test-provided-name' }));
const ctx = createCtxMock({
nonInteractive: false,
newAndroid: {
...getNewAndroidApiMockWithoutCredentials(),
createKeystoreAsync: jest.fn(() => testJksAndroidKeystoreFragment),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const setupBuildCredentialsAction = new SetupBuildCredentials({ app: appLookupParams });
await setupBuildCredentialsAction.runAsync(ctx);
// expect keystore to be created
expect(ctx.newAndroid.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
});
it('errors in Non-Interactive Mode', async () => {
(promptAsync as jest.Mock).mockImplementation(() => ({ providedName: 'test-provided-name' }));
const ctx = createCtxMock({
nonInteractive: true,
newAndroid: {
...getNewAndroidApiMockWithoutCredentials(),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const setupBuildCredentialsAction = new SetupBuildCredentials({ app: appLookupParams });
await expect(setupBuildCredentialsAction.runAsync(ctx)).rejects.toThrowError(
MissingCredentialsNonInteractiveError
);
});
});
@@ -1,80 +0,0 @@
import keyBy from 'lodash/keyBy';
import { AndroidCredentials, FcmCredentials, Keystore } from '../credentials';
import ApiClient from './ClientWrapper';
export default class AndroidApi {
private client = new ApiClient();
private shouldRefetchAll: boolean = true;
private credentials: { [key: string]: AndroidCredentials } = {};
public async fetchAllAsync(): Promise<{ [key: string]: AndroidCredentials }> {
if (this.shouldRefetchAll) {
this.credentials = keyBy(await this.client.getAllCredentialsApi(), 'experienceName');
this.shouldRefetchAll = false;
}
return this.credentials;
}
public async fetchKeystoreAsync(experienceName: string): Promise<Keystore | null> {
await this.ensureCredentialsFetchedAsync(experienceName);
return this.credentials[experienceName]?.keystore || null;
}
public async fetchCredentialsAsync(experienceName: string): Promise<AndroidCredentials> {
await this.ensureCredentialsFetchedAsync(experienceName);
return this.credentials[experienceName];
}
public async updateKeystoreAsync(experienceName: string, keystore: Keystore): Promise<void> {
await this.ensureCredentialsFetchedAsync(experienceName);
await this.client.updateKeystoreApi(experienceName, keystore);
this.credentials[experienceName] = {
experienceName,
keystore,
pushCredentials: this.credentials[experienceName]?.pushCredentials,
};
}
public async fetchFcmKeyAsync(experienceName: string): Promise<FcmCredentials | null> {
await this.ensureCredentialsFetchedAsync(experienceName);
return this.credentials?.[experienceName]?.pushCredentials;
}
public async updateFcmKeyAsync(experienceName: string, fcmApiKey: string): Promise<void> {
await this.ensureCredentialsFetchedAsync(experienceName);
await this.client.updateFcmKeyApi(experienceName, fcmApiKey);
this.credentials[experienceName] = {
experienceName,
keystore: this.credentials[experienceName]?.keystore,
pushCredentials: { fcmApiKey },
};
}
public async removeFcmKeyAsync(experienceName: string): Promise<void> {
await this.ensureCredentialsFetchedAsync(experienceName);
await this.client.removeFcmKeyApi(experienceName);
if (this.credentials[experienceName]) {
this.credentials[experienceName].pushCredentials = null;
}
}
public async removeKeystoreAsync(experienceName: string): Promise<void> {
await this.ensureCredentialsFetchedAsync(experienceName);
await this.client.removeKeystoreApi(experienceName);
if (this.credentials[experienceName]) {
this.credentials[experienceName].keystore = null;
}
}
private async ensureCredentialsFetchedAsync(experienceName: string): Promise<void> {
if (!this.credentials[experienceName]) {
const response = await this.client.getAllCredentialsForAppApi(experienceName);
this.credentials[experienceName] = {
experienceName,
keystore: response?.keystore,
pushCredentials: response?.pushCredentials,
};
}
}
}
@@ -1,31 +0,0 @@
import { apiClient } from '../../../api';
import { AndroidCredentials, Keystore } from '../credentials';
type AllCredentialsApiResponse = AndroidCredentials[];
// This class should not be used directly, use only as part of cached api client from ./Client.ts
// or mock it in tests (it's easier to mock this class than got directly)
export default class ClientWrapper {
public async getAllCredentialsApi(): Promise<AllCredentialsApiResponse> {
return ((await apiClient.get('credentials/android').json()) as any).data?.credentials || [];
}
public async getAllCredentialsForAppApi(projectFullName: string): Promise<AndroidCredentials> {
return ((await apiClient.get(`credentials/android/${projectFullName}`).json()) as any)?.data;
}
public async updateKeystoreApi(projectFullName: string, keystore: Keystore): Promise<void> {
await apiClient.put(`credentials/android/keystore/${projectFullName}`, {
json: {
keystore,
},
});
}
public async updateFcmKeyApi(projectFullName: string, fcmApiKey: string): Promise<void> {
await apiClient.put(`credentials/android/push/${projectFullName}`, { json: { fcmApiKey } });
}
public async removeKeystoreApi(projectFullName: string): Promise<void> {
await apiClient.delete(`credentials/android/keystore/${projectFullName}`);
}
public async removeFcmKeyApi(projectFullName: string): Promise<void> {
await apiClient.delete(`credentials/android/push/${projectFullName}`);
}
}
@@ -1,5 +1,7 @@
import {
AndroidAppBuildCredentialsFragment,
AndroidFcmFragment,
AndroidFcmVersion,
AndroidKeystoreFragment,
AppFragment,
CommonAndroidAppCredentialsFragment,
@@ -9,6 +11,7 @@ import { AppQuery } from '../../ios/api/graphql/queries/AppQuery';
import { KeystoreWithType } from '../credentials';
import { AndroidAppBuildCredentialsMutation } from './graphql/mutations/AndroidAppBuildCredentialsMutation';
import { AndroidAppCredentialsMutation } from './graphql/mutations/AndroidAppCredentialsMutation';
import { AndroidFcmMutation } from './graphql/mutations/AndroidFcmMutation';
import { AndroidKeystoreMutation } from './graphql/mutations/AndroidKeystoreMutation';
import { AndroidAppCredentialsQuery } from './graphql/queries/AndroidAppCredentialsQuery';
@@ -73,15 +76,24 @@ export async function createOrGetExistingAndroidAppCredentialsWithBuildCredentia
} else {
const app = await getAppAsync(appLookupParams);
return await AndroidAppCredentialsMutation.createAndroidAppCredentialsAsync(
{
/* fcmKey not supported yet */
},
{},
app.id,
appLookupParams.androidApplicationIdentifier
);
}
}
export async function updateAndroidAppCredentialsAsync(
appCredentials: CommonAndroidAppCredentialsFragment,
{
androidFcmId,
}: {
androidFcmId: string;
}
): Promise<CommonAndroidAppCredentialsFragment> {
return await AndroidAppCredentialsMutation.setFcmKeyAsync(appCredentials.id, androidFcmId);
}
export async function updateAndroidAppBuildCredentialsAsync(
buildCredentials: AndroidAppBuildCredentialsFragment,
{
@@ -193,10 +205,26 @@ export async function createKeystoreAsync(
);
}
export async function deleteKeystoreAsync(keystore: AndroidKeystoreFragment): Promise<void> {
return await AndroidKeystoreMutation.deleteAndroidKeystore(keystore.id);
}
export async function createFcmAsync(
account: Account,
fcmApiKey: string,
version: AndroidFcmVersion
): Promise<AndroidFcmFragment> {
return await AndroidFcmMutation.createAndroidFcm({ credential: fcmApiKey, version }, account.id);
}
export async function deleteFcmAsync(fcm: AndroidFcmFragment): Promise<void> {
return await AndroidFcmMutation.deleteAndroidFcm(fcm.id);
}
async function getAppAsync(appLookupParams: AppLookupParams): Promise<AppFragment> {
const projectFullName = formatProjectFullName(appLookupParams);
return await AppQuery.byFullNameAsync(projectFullName);
}
const formatProjectFullName = ({ account, projectName }: AppLookupParams): string =>
export const formatProjectFullName = ({ account, projectName }: AppLookupParams): string =>
`@${account.name}/${projectName}`;
@@ -5,18 +5,19 @@ import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AndroidAppBuildCredentialsFragment,
AndroidAppBuildCredentialsInput,
CreateAndroidAppBuildCredentialsMutation,
SetKeystoreMutation,
} from '../../../../../graphql/generated';
import { AndroidAppBuildCredentialsFragmentNode } from '../../../../../graphql/types/credentials/AndroidAppBuildCredentials';
export type AndroidAppBuildCredentialsMetadataInput = Omit<
AndroidAppBuildCredentialsInput,
'keystoreId'
>;
const AndroidAppBuildCredentialsMutation = {
async createAndroidAppBuildCredentialsAsync(
androidAppBuildCredentialsInput: {
isDefault: boolean;
name: string;
keystoreId: string;
},
androidAppBuildCredentialsInput: AndroidAppBuildCredentialsInput,
androidAppCredentialsId: string
): Promise<AndroidAppBuildCredentialsFragment> {
const data = await withErrorHandlingAsync(
@@ -6,6 +6,7 @@ import { graphqlClient, withErrorHandlingAsync } from '../../../../../graphql/cl
import {
CommonAndroidAppCredentialsFragment,
CreateAndroidAppCredentialsMutation,
SetFcmMutation,
} from '../../../../../graphql/generated';
import { CommonAndroidAppCredentialsFragmentNode } from '../../../../../graphql/types/credentials/AndroidAppCredentials';
@@ -53,6 +54,34 @@ const AndroidAppCredentialsMutation = {
);
return data.androidAppCredentials.createAndroidAppCredentials;
},
async setFcmKeyAsync(
androidAppCredentialsId: string,
fcmId: string
): Promise<CommonAndroidAppCredentialsFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<SetFcmMutation>(
gql`
mutation SetFcmMutation($androidAppCredentialsId: ID!, $fcmId: ID!) {
androidAppCredentials {
setFcm(id: $androidAppCredentialsId, fcmId: $fcmId) {
id
...CommonAndroidAppCredentialsFragment
}
}
}
${print(CommonAndroidAppCredentialsFragmentNode)}
`,
{
androidAppCredentialsId,
fcmId,
}
)
.toPromise()
);
assert(data.androidAppCredentials.setFcm, 'GraphQL: `setFcm` not defined in server response');
return data.androidAppCredentials.setFcm;
},
};
export { AndroidAppCredentialsMutation };
@@ -0,0 +1,71 @@
import assert from 'assert';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AndroidFcmFragment,
AndroidFcmInput,
CreateAndroidFcmMutation,
DeleteAndroidFcmMutation,
} from '../../../../../graphql/generated';
import { AndroidFcmFragmentNode } from '../../../../../graphql/types/credentials/AndroidFcm';
const AndroidFcmMutation = {
async createAndroidFcm(
androidFcmInput: AndroidFcmInput,
accountId: string
): Promise<AndroidFcmFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateAndroidFcmMutation>(
gql`
mutation CreateAndroidFcmMutation($androidFcmInput: AndroidFcmInput!, $accountId: ID!) {
androidFcm {
createAndroidFcm(androidFcmInput: $androidFcmInput, accountId: $accountId) {
id
...AndroidFcmFragment
}
}
}
${print(AndroidFcmFragmentNode)}
`,
{
androidFcmInput,
accountId,
}
)
.toPromise()
);
assert(
data.androidFcm.createAndroidFcm,
'GraphQL: `createAndroidFcm` not defined in server response'
);
return data.androidFcm.createAndroidFcm;
},
async deleteAndroidFcm(androidFcmId: string): Promise<void> {
await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteAndroidFcmMutation>(
gql`
mutation DeleteAndroidFcmMutation($androidFcmId: ID!) {
androidFcm {
deleteAndroidFcm(id: $androidFcmId) {
id
}
}
}
`,
{
androidFcmId,
},
{
additionalTypenames: ['AndroidFcm', 'AndroidAppCredentials'],
}
)
.toPromise()
);
},
};
export { AndroidFcmMutation };
@@ -5,20 +5,15 @@ import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AndroidKeystoreFragment,
AndroidKeystoreType,
AndroidKeystoreInput,
CreateAndroidKeystoreMutation,
DeleteAndroidKeystoreMutation,
} from '../../../../../graphql/generated';
import { AndroidKeystoreFragmentNode } from '../../../../../graphql/types/credentials/AndroidKeystore';
const AndroidKeystoreMutation = {
async createAndroidKeystore(
androidKeystoreInput: {
base64EncodedKeystore: string;
keystorePassword: string;
keyAlias: string;
keyPassword?: string;
type: AndroidKeystoreType;
},
androidKeystoreInput: AndroidKeystoreInput,
accountId: string
): Promise<AndroidKeystoreFragment> {
const data = await withErrorHandlingAsync(
@@ -54,6 +49,29 @@ const AndroidKeystoreMutation = {
);
return data.androidKeystore.createAndroidKeystore;
},
async deleteAndroidKeystore(androidKeystoreId: string): Promise<void> {
await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteAndroidKeystoreMutation>(
gql`
mutation DeleteAndroidKeystoreMutation($androidKeystoreId: ID!) {
androidKeystore {
deleteAndroidKeystore(id: $androidKeystoreId) {
id
}
}
}
`,
{
androidKeystoreId,
},
{
additionalTypenames: ['AndroidKeystore', 'AndroidAppBuildCredentials'],
}
)
.toPromise()
);
},
};
export { AndroidKeystoreMutation };
@@ -52,7 +52,12 @@ const AndroidAppCredentialsQuery = {
legacyOnly,
},
{
additionalTypenames: ['AndroidAppCredentials', 'AndroidAppBuildCredentials', 'App'],
additionalTypenames: [
'AndroidAppCredentials',
'AndroidAppBuildCredentials',
'App',
'AndroidFcm',
],
}
)
.toPromise()
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`print credentials prints the AndroidAppCredentials fragment 1`] = `"Android Credentials Project: testApp Application Identifier: test.com.app Push Notifications (FCM): Key: abcd...efgh Updated 0 second ago Configuration: legacy (Default) Keystore: Type: JKS Key Alias: QHdrb3p5cmEvY3JlZGVudGlhbHMtdGVzdA== MD5 Fingerprint: TE:ST:-M:D5 SHA1 Fingerprint: TE:ST:-S:HA:1 SHA256 Fingerprint: TE:ST:-S:HA:25:6 Updated 0 second ago"`;
@@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`print credentials prints the AndroidAppCredentials fragment 1`] = `"Android Credentials Project: testApp Application Identifier: test.com.app EAS Credentials: These will take precedence over Expo Classic credentials Configuration: legacy (Default) Keystore: Type: JKS Key Alias: QHdrb3p5cmEvY3JlZGVudGlhbHMtdGVzdA== MD5 Fingerprint: TE:ST:-M:D5 SHA1 Fingerprint: TE:ST:-S:HA:1 SHA256 Fingerprint: TE:ST:-S:HA:25:6 Updated 0 second ago Credentials ported from Expo Classic (expo-cli): Keystore: Type: JKS Key Alias: QHdrb3p5cmEvY3JlZGVudGlhbHMtdGVzdA== MD5 Fingerprint: TE:ST:-M:D5 SHA1 Fingerprint: TE:ST:-S:HA:1 SHA256 Fingerprint: TE:ST:-S:HA:25:6 Updated 0 second ago"`;
@@ -1,13 +1,10 @@
import mockdate from 'mockdate';
import Log from '../../../../log';
import {
testAndroidAppCredentialsFragment,
testLegacyAndroidAppCredentialsFragment,
} from '../../../__tests__/fixtures-android-new';
import { testAndroidAppCredentialsFragment } from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContext } from '../../actions/BuildCredentialsUtils';
import { displayAndroidAppCredentials } from '../printCredentialsBeta';
import { displayAndroidAppCredentials } from '../printCredentials';
jest.mock('../../../../log');
jest.mock('chalk', () => ({ bold: jest.fn(log => log) }));
@@ -20,7 +17,6 @@ describe('print credentials', () => {
const appLookupParams = getAppLookupParamsFromContext(ctx);
displayAndroidAppCredentials({
appLookupParams,
legacyAppCredentials: testLegacyAndroidAppCredentialsFragment,
appCredentials: testAndroidAppCredentialsFragment,
});
const loggedSoFar = (Log.log as jest.Mock).mock.calls.reduce(
@@ -1,34 +1,128 @@
import chalk from 'chalk';
import {
AndroidAppBuildCredentialsFragment,
AndroidFcmVersion,
AndroidKeystoreFragment,
CommonAndroidAppCredentialsFragment,
FcmSnippetLegacy,
FcmSnippetV1,
} from '../../../graphql/generated';
import Log from '../../../log';
import { AndroidCredentials } from '../credentials';
import { keytoolCommandExistsAsync, logKeystoreHashesAsync } from '../utils/keystore';
import { fromNow } from '../../../utils/date';
import { sortBuildCredentials } from '../actions/BuildCredentialsUtils';
import { AppLookupParams } from '../api/GraphqlClient';
export async function printAndroidCredentials(credentialsList: AndroidCredentials[]) {
Log.log(chalk.bold('Available Android credentials'));
export function displayEmptyAndroidCredentials(appLookupParams: AppLookupParams): void {
const { projectName, androidApplicationIdentifier } = appLookupParams;
Log.log(chalk.bold(`Android Credentials`));
Log.log(` Project: ${projectName}`);
Log.log(` Application Identifier: ${androidApplicationIdentifier}`);
Log.log(` No credentials set up yet!`);
}
function displayAndroidFcmCredentials(appCredentials: CommonAndroidAppCredentialsFragment): void {
const maybeFcm = appCredentials.androidFcm;
Log.log(` Push Notifications (FCM):`);
if (!maybeFcm) {
Log.log(` None assigned yet`);
Log.newLine();
return;
}
const { snippet, version, updatedAt } = maybeFcm;
if (version === AndroidFcmVersion.Legacy) {
const { firstFourCharacters, lastFourCharacters } = snippet as FcmSnippetLegacy;
Log.log(` Key: ${firstFourCharacters}...${lastFourCharacters}`);
} else if (version === AndroidFcmVersion.V1) {
const { projectId, serviceAccountEmail, clientId, keyId } = snippet as FcmSnippetV1;
Log.log(` Project Id: ${projectId}`);
Log.log(` Service Account: ${serviceAccountEmail}`);
Log.log(` Client Id: ${clientId}`);
Log.log(` Key Id: ${keyId}`);
}
Log.log(` Updated ${fromNow(new Date(updatedAt))} ago`);
Log.newLine();
for (const credentials of credentialsList) {
await printAndroidAppCredentials(credentials);
}
function displayEASAndroidAppCredentials(
appCredentials: CommonAndroidAppCredentialsFragment
): void {
displayAndroidFcmCredentials(appCredentials);
const sortedBuildCredentialsList = sortBuildCredentials(
appCredentials.androidAppBuildCredentialsList
);
for (const buildCredentials of sortedBuildCredentialsList) {
displayAndroidBuildCredentials(buildCredentials);
}
}
export async function printAndroidAppCredentials(credentials: AndroidCredentials) {
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);
}
function formatFingerprint(fingerprint: string | null): string {
if (!fingerprint) {
return 'unavailable';
}
const uppercaseFingerprint = fingerprint.toUpperCase();
const bytes = [];
for (let i = 0; i < uppercaseFingerprint.length; i++) {
const halfByte = uppercaseFingerprint.charAt(i);
if (i % 2 === 0) {
bytes.push(halfByte); // first half of the byte
} else {
Log.warn(' keytool is required to calulate Keystore hashes');
bytes[bytes.length - 1] += halfByte; // second half of the byte
}
}
return bytes.join(':');
}
function displayAndroidBuildCredentials(
buildCredentials: AndroidAppBuildCredentialsFragment,
skipConfigurationDisplay?: boolean
): void {
if (!skipConfigurationDisplay) {
const { isDefault, name } = buildCredentials;
Log.log(chalk.bold(` Configuration: ${name}${isDefault ? ' (Default)' : ''}`));
}
const maybeKeystore = buildCredentials.androidKeystore;
Log.log(` Keystore:`);
if (maybeKeystore) {
displayAndroidKeystore(maybeKeystore);
} else {
Log.log(' -----------------------');
Log.log(` None assigned yet`);
}
Log.newLine();
}
export function displayAndroidKeystore(keystore: AndroidKeystoreFragment) {
const {
keyAlias,
type,
md5CertificateFingerprint,
sha1CertificateFingerprint,
sha256CertificateFingerprint,
updatedAt,
} = keystore;
Log.log(` Type: ${type}`);
Log.log(` Key Alias: ${keyAlias}`);
Log.log(` MD5 Fingerprint: ${formatFingerprint(md5CertificateFingerprint ?? null)}`);
Log.log(` SHA1 Fingerprint: ${formatFingerprint(sha1CertificateFingerprint ?? null)}`);
Log.log(` SHA256 Fingerprint: ${formatFingerprint(sha256CertificateFingerprint ?? null)}`);
Log.log(` Updated ${fromNow(new Date(updatedAt))} ago`);
}
export function displayAndroidAppCredentials({
appLookupParams,
appCredentials,
}: {
appLookupParams: AppLookupParams;
appCredentials: CommonAndroidAppCredentialsFragment | null;
}): void {
const { projectName, androidApplicationIdentifier } = appLookupParams;
Log.log(chalk.bold(`Android Credentials`));
Log.log(` Project: ${projectName}`);
Log.log(` Application Identifier: ${androidApplicationIdentifier}`);
Log.newLine();
if (appCredentials) {
displayEASAndroidAppCredentials(appCredentials);
}
Log.log(chalk.bold(' Push Notifications credentials'));
Log.log(' FCM API Key: ', credentials.pushCredentials?.fcmApiKey ?? '---------------------');
Log.log('\n');
}
@@ -1,143 +0,0 @@
import chalk from 'chalk';
import {
AndroidAppBuildCredentialsFragment,
CommonAndroidAppCredentialsFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { fromNow } from '../../../utils/date';
import { AppLookupParams } from '../api/GraphqlClient';
export function displayEmptyAndroidCredentials(appLookupParams: AppLookupParams): void {
const { projectName, androidApplicationIdentifier } = appLookupParams;
Log.log(chalk.bold(`Android Credentials`));
Log.log(` Project: ${projectName}`);
Log.log(` Application Identifier: ${androidApplicationIdentifier}`);
Log.log(` No credentials set up yet!`);
}
/**
* sort a build credentials array in descending order of preference
* prefer default credentials, then prefer names that come first lexicographically
*/
function sortBuildCredentials(
androidAppBuildCredentialsList: AndroidAppBuildCredentialsFragment[]
): AndroidAppBuildCredentialsFragment[] {
return androidAppBuildCredentialsList.sort((buildCredentialsA, buildCredentialsB) => {
if (buildCredentialsA.isDefault) {
return -1;
} else if (buildCredentialsB.isDefault) {
return 1;
}
return buildCredentialsA.name.localeCompare(buildCredentialsB.name);
});
}
function displayLegacyAndroidAppCredentials(
legacyAppCredentials: CommonAndroidAppCredentialsFragment,
appCredentials: CommonAndroidAppCredentialsFragment | null
): void {
if (appCredentials) {
// differentiate between old and new if there's both
Log.log(chalk.bold(` Credentials ported from Expo Classic (expo-cli):`));
}
// There can only be one set of build credentials in legacy app credentials
const [legacyBuildCredentials] = legacyAppCredentials.androidAppBuildCredentialsList;
if (legacyBuildCredentials) {
displayAndroidBuildCredentials(
legacyBuildCredentials,
/* only one set to show, skip config display */ true
);
}
}
function displayEASAndroidAppCredentials(
legacyAppCredentials: CommonAndroidAppCredentialsFragment | null,
appCredentials: CommonAndroidAppCredentialsFragment
): void {
if (legacyAppCredentials) {
// differentiate between old and new if there's both
Log.log(chalk.bold(` EAS Credentials:`));
Log.log(` These will take precedence over Expo Classic credentials`);
}
const sortedBuildCredentialsList = sortBuildCredentials(
appCredentials.androidAppBuildCredentialsList
);
for (const buildCredentials of sortedBuildCredentialsList) {
displayAndroidBuildCredentials(buildCredentials);
}
}
function formatFingerprint(fingerprint: string | null): string {
if (!fingerprint) {
return 'unavailable';
}
const uppercaseFingerprint = fingerprint.toUpperCase();
const bytes = [];
for (let i = 0; i < uppercaseFingerprint.length; i++) {
const halfByte = uppercaseFingerprint.charAt(i);
if (i % 2 === 0) {
bytes.push(halfByte); // first half of the byte
} else {
bytes[bytes.length - 1] += halfByte; // second half of the byte
}
}
return bytes.join(':');
}
function displayAndroidBuildCredentials(
buildCredentials: AndroidAppBuildCredentialsFragment,
skipConfigurationDisplay?: boolean
): void {
if (!skipConfigurationDisplay) {
const { isDefault, name } = buildCredentials;
Log.log(chalk.bold(` Configuration: ${name}${isDefault ? ' (Default)' : ''}`));
Log.newLine();
}
const maybeKeystore = buildCredentials.androidKeystore;
Log.log(` Keystore:`);
if (maybeKeystore) {
const {
keyAlias,
type,
md5CertificateFingerprint,
sha1CertificateFingerprint,
sha256CertificateFingerprint,
updatedAt,
} = maybeKeystore;
Log.log(` Type: ${type}`);
Log.log(` Key Alias: ${keyAlias}`);
Log.log(` MD5 Fingerprint: ${formatFingerprint(md5CertificateFingerprint ?? null)}`);
Log.log(` SHA1 Fingerprint: ${formatFingerprint(sha1CertificateFingerprint ?? null)}`);
Log.log(` SHA256 Fingerprint: ${formatFingerprint(sha256CertificateFingerprint ?? null)}`);
Log.log(` Updated ${fromNow(new Date(updatedAt))} ago`);
} else {
Log.log(` None assigned yet`);
}
Log.newLine();
}
export function displayAndroidAppCredentials({
appLookupParams,
legacyAppCredentials,
appCredentials,
}: {
appLookupParams: AppLookupParams;
legacyAppCredentials: CommonAndroidAppCredentialsFragment | null;
appCredentials: CommonAndroidAppCredentialsFragment | null;
}): void {
const { projectName, androidApplicationIdentifier } = appLookupParams;
Log.log(chalk.bold(`Android Credentials`));
Log.log(` Project: ${projectName}`);
Log.log(` Application Identifier: ${androidApplicationIdentifier}`);
Log.newLine();
if (appCredentials) {
displayEASAndroidAppCredentials(legacyAppCredentials, appCredentials);
}
if (legacyAppCredentials) {
displayLegacyAndroidAppCredentials(legacyAppCredentials, appCredentials);
}
}
+6 -5
View File
@@ -7,11 +7,14 @@ import { getProjectAccountName } from '../project/projectUtils';
import { confirmAsync } from '../prompts';
import { Actor, getActorDisplayName } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import AndroidApi from './android/api/Client';
import * as AndroidGraphqlClient from './android/api/GraphqlClient';
import * as IosGraphqlClient from './ios/api/GraphqlClient';
import AppStoreApi from './ios/appstore/AppStoreApi';
export interface Action<T = void> {
runAsync(ctx: Context): Promise<T>;
}
interface AppleCtxOptions {
appleId?: string;
appleIdPassword?: string;
@@ -26,8 +29,7 @@ export interface Context {
readonly projectDir: string;
readonly user: Actor;
readonly nonInteractive: boolean;
readonly android: AndroidApi;
readonly newAndroid: typeof AndroidGraphqlClient;
readonly android: typeof AndroidGraphqlClient;
readonly ios: typeof IosGraphqlClient;
readonly appStore: AppStoreApi;
readonly hasProjectContext: boolean;
@@ -55,8 +57,7 @@ export async function createCredentialsContextAsync(
}
class CredentialsContext implements Context {
public readonly android = new AndroidApi();
public readonly newAndroid = AndroidGraphqlClient;
public readonly android = AndroidGraphqlClient;
public readonly ios = IosGraphqlClient;
public readonly appStore: AppStoreApi;
public readonly nonInteractive: boolean;
@@ -1,5 +1,7 @@
import { vol } from 'memfs';
import { findApplicationTarget } from '../../../project/ios/target';
import { testTargets } from '../../__tests__/fixtures-ios';
import * as credentialsJsonReader from '../read';
jest.mock('fs');
@@ -37,7 +39,7 @@ describe('credentialsJson', () => {
it('should throw error when credentials.json is missing', async () => {
const promise = credentialsJsonReader.readAndroidCredentialsAsync('.');
await expect(promise).rejects.toThrow(
'credentials.json must exist in the project root directory and contain a valid JSON'
'credentials.json does not exist in the project root directory'
);
});
@@ -47,9 +49,7 @@ describe('credentialsJson', () => {
'keystore.jks': 'somebinarydata',
});
const promise = credentialsJsonReader.readAndroidCredentialsAsync('.');
await expect(promise).rejects.toThrow(
'Android credentials are missing from credentials.json'
);
await expect(promise).rejects.toThrow('Android credentials are missing in credentials.json');
});
it('should throw error when one of the required fields is missing', async () => {
vol.fromJSON({
@@ -69,7 +69,7 @@ describe('credentialsJson', () => {
'credentials.json is not valid [ValidationError: "android.keystore.keystorePath" is required]'
);
});
it('should throw error when file specified in cedentials.json is missing', async () => {
it('should throw error when file specified in credentials.json is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
android: {
@@ -105,19 +105,25 @@ describe('credentialsJson', () => {
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const result = await credentialsJsonReader.readIosCredentialsAsync('.');
const applicationTarget = findApplicationTarget(testTargets);
const result = await credentialsJsonReader.readIosCredentialsAsync('.', applicationTarget);
expect(result).toEqual({
provisioningProfile: 'c29tZWJpbmFyeWNvbnRlbnQ=',
distributionCertificate: {
certP12: 'c29tZWJpbmFyeWNvbnRlbnQy',
certPassword: 'certPass',
[applicationTarget.targetName]: {
provisioningProfile: 'c29tZWJpbmFyeWNvbnRlbnQ=',
distributionCertificate: {
certificateP12: 'c29tZWJpbmFyeWNvbnRlbnQy',
certificatePassword: 'certPass',
},
},
});
});
it('should throw error when credentials.json is missing', async () => {
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
const promise = credentialsJsonReader.readIosCredentialsAsync(
'.',
findApplicationTarget(testTargets)
);
await expect(promise).rejects.toThrow(
'credentials.json must exist in the project root directory and contain a valid JSON'
'credentials.json does not exist in the project root directory'
);
});
it('should throw error if ios field is missing', async () => {
@@ -126,8 +132,11 @@ describe('credentialsJson', () => {
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow('iOS credentials are missing from credentials.json');
const promise = credentialsJsonReader.readIosCredentialsAsync(
'.',
findApplicationTarget(testTargets)
);
await expect(promise).rejects.toThrow('iOS credentials are missing in credentials.json');
});
it('should throw error if some field is missing', async () => {
vol.fromJSON({
@@ -142,7 +151,10 @@ describe('credentialsJson', () => {
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
const promise = credentialsJsonReader.readIosCredentialsAsync(
'.',
findApplicationTarget(testTargets)
);
await expect(promise).rejects.toThrow(
'credentials.json is not valid [ValidationError: "ios" does not match any of the allowed types]'
);
@@ -160,7 +172,10 @@ describe('credentialsJson', () => {
}),
'./pprofile': 'somebinarycontent',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
const promise = credentialsJsonReader.readIosCredentialsAsync(
'.',
findApplicationTarget(testTargets)
);
await expect(promise).rejects.toThrow("ENOENT: no such file or directory, open 'cert.p12'");
});
it('should throw error if provisioningProfile file is missing', async () => {
@@ -176,7 +191,10 @@ describe('credentialsJson', () => {
}),
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
const promise = credentialsJsonReader.readIosCredentialsAsync(
'.',
findApplicationTarget(testTargets)
);
await expect(promise).rejects.toThrow("ENOENT: no such file or directory, open 'pprofile'");
});
});
@@ -206,49 +224,27 @@ describe('credentialsJson', () => {
'./dist-cert-1.p12': 'cert-1-somebinarycontent',
'./dist-cert-2.p12': 'cert-2-somebinarycontent',
});
const result = await credentialsJsonReader.readIosCredentialsAsync('.');
const result = await credentialsJsonReader.readIosCredentialsAsync(
'.',
findApplicationTarget(testTargets)
);
expect(result).toEqual({
target1: {
provisioningProfile: 'cHByb2ZpbGUtMS1zb21lYmluYXJ5Y29udGVudA==',
distributionCertificate: {
certP12: 'Y2VydC0xLXNvbWViaW5hcnljb250ZW50',
certPassword: 'cert-pass-1',
certificateP12: 'Y2VydC0xLXNvbWViaW5hcnljb250ZW50',
certificatePassword: 'cert-pass-1',
},
},
target2: {
provisioningProfile: 'cHByb2ZpbGUtMi1zb21lYmluYXJ5Y29udGVudA==',
distributionCertificate: {
certP12: 'Y2VydC0yLXNvbWViaW5hcnljb250ZW50',
certPassword: 'cert-pass-2',
certificateP12: 'Y2VydC0yLXNvbWViaW5hcnljb250ZW50',
certificatePassword: 'cert-pass-2',
},
},
});
});
});
});
describe('readEnvironmentSecretsAsync', () => {
it('should read environmentSecrets field correctly', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
experimental: {
npmToken: 'VALUE',
},
}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const result = await credentialsJsonReader.readEnvironmentSecretsAsync('.');
expect(result).toEqual({
NPM_TOKEN: 'VALUE',
});
});
});
});
@@ -4,19 +4,23 @@ import prompts from 'prompts';
import { asMock } from '../../../__tests__/utils';
import { IosDistributionType } from '../../../graphql/generated';
import { testKeystore } from '../../__tests__/fixtures-android';
import {
testKeystore,
testLegacyAndroidBuildCredentialsFragment,
} from '../../__tests__/fixtures-android';
import { createCtxMock } from '../../__tests__/fixtures-context';
import {
getNewIosApiMockWithoutCredentials,
testAllCredentialsForApp,
getNewIosApiMock,
testCommonIosAppCredentialsFragment,
testDistCertFragmentNoDependencies,
testProvisioningProfileFragment,
} from '../../__tests__/fixtures-ios';
import { getAppLookupParamsFromContext } from '../../ios/actions/BuildCredentialsUtils';
import { getAppFromContext } from '../../ios/actions/BuildCredentialsUtils';
import { Target } from '../../ios/types';
import { updateAndroidCredentialsAsync, updateIosCredentialsAsync } from '../update';
jest.mock('fs');
jest.mock('prompts');
jest.mock('../../../project/ios/bundleIdentifier');
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
@@ -45,11 +49,7 @@ beforeEach(() => {
describe('update credentials.json', () => {
describe(updateAndroidCredentialsAsync, () => {
it('should update keystore in credentials.json if www returns valid credentials', async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
},
});
const ctx = createCtxMock();
vol.fromJSON({
'./credentials.json': JSON.stringify({
android: {
@@ -63,7 +63,7 @@ describe('update credentials.json', () => {
}),
'keystore.jks': 'somebinarydata',
});
await updateAndroidCredentialsAsync(ctx);
await updateAndroidCredentialsAsync(ctx, testLegacyAndroidBuildCredentialsFragment);
const keystore = await fs.readFile('./keystore.jks', 'base64');
const credJson = await fs.readJson('./credentials.json');
expect(keystore).toEqual(testKeystore.keystore);
@@ -71,30 +71,28 @@ describe('update credentials.json', () => {
android: {
keystore: {
keystorePath: 'keystore.jks',
keystorePassword: testKeystore.keystorePassword,
keyAlias: testKeystore.keyAlias,
keyPassword: testKeystore.keyPassword,
keystorePassword:
testLegacyAndroidBuildCredentialsFragment.androidKeystore?.keystorePassword,
keyAlias: testLegacyAndroidBuildCredentialsFragment.androidKeystore?.keyAlias,
keyPassword: testLegacyAndroidBuildCredentialsFragment.androidKeystore?.keyPassword,
},
},
});
});
it('should create credentials.json and keystore if credentials.json does not exist', async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
},
});
await updateAndroidCredentialsAsync(ctx);
const keystore = await fs.readFile('./android/keystores/keystore.jks', 'base64');
const ctx = createCtxMock();
await updateAndroidCredentialsAsync(ctx, testLegacyAndroidBuildCredentialsFragment);
const keystore = await fs.readFile('./credentials/android/keystore.jks', 'base64');
const credJson = await fs.readJson('./credentials.json');
expect(keystore).toEqual(testKeystore.keystore);
expect(credJson).toEqual({
android: {
keystore: {
keystorePath: 'android/keystores/keystore.jks',
keystorePassword: testKeystore.keystorePassword,
keyAlias: testKeystore.keyAlias,
keyPassword: testKeystore.keyPassword,
keystorePath: 'credentials/android/keystore.jks',
keystorePassword:
testLegacyAndroidBuildCredentialsFragment.androidKeystore?.keystorePassword,
keyAlias: testLegacyAndroidBuildCredentialsFragment.androidKeystore?.keyAlias,
keyPassword: testLegacyAndroidBuildCredentialsFragment.androidKeystore?.keyPassword,
},
},
});
@@ -116,7 +114,9 @@ describe('update credentials.json', () => {
'keystore.jks': 'somebinarydata',
});
try {
await updateAndroidCredentialsAsync(ctx);
const buildCredentialsNoKeystore = { ...testLegacyAndroidBuildCredentialsFragment };
delete buildCredentialsNoKeystore.androidKeystore;
await updateAndroidCredentialsAsync(ctx, buildCredentialsNoKeystore);
throw new Error('updateAndroidCredentialsAsync should throw an error');
} catch (e) {
expect(e.message).toMatch(
@@ -129,35 +129,28 @@ describe('update credentials.json', () => {
expect(newCredJson).toEqual(credJson);
});
it('should update keystore and credentials.json if android part of credentials.json is not valid', async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
},
});
const ctx = createCtxMock();
vol.fromJSON({
'./credentials.json': JSON.stringify({ android: { test: '123' } }),
});
await updateAndroidCredentialsAsync(ctx);
const keystore = await fs.readFile('./android/keystores/keystore.jks', 'base64');
await updateAndroidCredentialsAsync(ctx, testLegacyAndroidBuildCredentialsFragment);
const keystore = await fs.readFile('./credentials/android/keystore.jks', 'base64');
const credJson = await fs.readJson('./credentials.json');
expect(keystore).toEqual(testKeystore.keystore);
expect(credJson).toEqual({
android: {
keystore: {
keystorePath: 'android/keystores/keystore.jks',
keystorePassword: testKeystore.keystorePassword,
keyAlias: testKeystore.keyAlias,
keyPassword: testKeystore.keyPassword,
keystorePath: 'credentials/android/keystore.jks',
keystorePassword:
testLegacyAndroidBuildCredentialsFragment.androidKeystore?.keystorePassword,
keyAlias: testLegacyAndroidBuildCredentialsFragment.androidKeystore?.keyAlias,
keyPassword: testLegacyAndroidBuildCredentialsFragment.androidKeystore?.keyPassword,
},
},
});
});
it('should update keystore and credentials.json if ios part of credentials.json is not valid', async () => {
const ctx = createCtxMock({
android: {
fetchKeystoreAsync: jest.fn(() => testKeystore),
},
});
const ctx = createCtxMock();
const credJson = {
android: {
keystore: {
@@ -175,7 +168,7 @@ describe('update credentials.json', () => {
'./credentials.json': JSON.stringify(credJson),
'keystore.jks': 'somebinarydata',
});
await updateAndroidCredentialsAsync(ctx);
await updateAndroidCredentialsAsync(ctx, testLegacyAndroidBuildCredentialsFragment);
const keystore = await fs.readFile('./keystore.jks', 'base64');
const newCredJson = await fs.readJson('./credentials.json');
expect(keystore).toEqual(testKeystore.keystore);
@@ -195,10 +188,17 @@ describe('update credentials.json', () => {
});
});
describe(updateIosCredentialsAsync, () => {
const targets: Target[] = [
{
targetName: 'testapp',
bundleIdentifier: 'com.bundle.id',
},
];
it('should update ios credentials in credentials.json if www returns valid credentials', async () => {
const ctx = createCtxMock({
ios: {
...getNewIosApiMockWithoutCredentials(),
...getNewIosApiMock(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
@@ -217,8 +217,9 @@ describe('update credentials.json', () => {
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
await updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore);
const app = getAppFromContext(ctx);
await updateIosCredentialsAsync(ctx, app, targets, IosDistributionType.AppStore);
const certP12 = await fs.readFile('./cert.p12', 'base64');
const pprofile = await fs.readFile('./pprofile', 'base64');
const credJson = await fs.readJson('./credentials.json');
@@ -235,7 +236,7 @@ describe('update credentials.json', () => {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: testAllCredentialsForApp.distCredentials.certPassword,
password: testDistCertFragmentNoDependencies.certificatePassword,
},
},
});
@@ -243,25 +244,27 @@ describe('update credentials.json', () => {
it('should create credentials.json provisioning profile and distribution certificate if credentials.json does not exist', async () => {
const ctx = createCtxMock({
ios: {
...getNewIosApiMockWithoutCredentials(),
...getNewIosApiMock(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
await updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore);
const certP12 = await fs.readFile('./ios/certs/dist-cert.p12', 'base64');
const pprofile = await fs.readFile('./ios/certs/profile.mobileprovision', 'base64');
const app = getAppFromContext(ctx);
await updateIosCredentialsAsync(ctx, app, targets, IosDistributionType.AppStore);
const certP12 = await fs.readFile('./credentials/ios/dist-cert.p12', 'base64');
const pprofile = await fs.readFile('./credentials/ios/profile.mobileprovision', 'base64');
const credJson = await fs.readJson('./credentials.json');
expect(certP12).toEqual(testAllCredentialsForApp.distCredentials.certP12);
expect(pprofile).toEqual(testAllCredentialsForApp.credentials.provisioningProfile);
expect(certP12).toEqual(testDistCertFragmentNoDependencies.certificateP12);
expect(pprofile).toEqual(testProvisioningProfileFragment.provisioningProfile);
expect(credJson).toEqual({
ios: {
provisioningProfilePath: 'ios/certs/profile.mobileprovision',
provisioningProfilePath: `credentials/ios/profile.mobileprovision`,
distributionCertificate: {
path: 'ios/certs/dist-cert.p12',
password: testAllCredentialsForApp.distCredentials.certPassword,
path: `credentials/ios/dist-cert.p12`,
password: testDistCertFragmentNoDependencies.certificatePassword,
},
},
});
@@ -269,7 +272,7 @@ describe('update credentials.json', () => {
it('should not do anything if no credentials are returned from www', async () => {
const ctx = createCtxMock({
ios: {
...getNewIosApiMockWithoutCredentials(),
...getNewIosApiMock(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(() => null),
},
});
@@ -287,14 +290,12 @@ describe('update credentials.json', () => {
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const app = getAppFromContext(ctx);
try {
const appLookupParams = getAppLookupParamsFromContext(ctx);
await updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore);
await updateIosCredentialsAsync(ctx, app, targets, IosDistributionType.AppStore);
throw new Error('updateIosCredentialsAsync should throw na error');
} catch (e) {
expect(e.message).toMatch(
'There are no credentials configured for the APP_STORE distribution of this project on EAS servers'
);
expect(e.message).toMatch('There are no credentials configured');
}
const certP12 = await fs.readFile('./cert.p12', 'base64');
const pprofile = await fs.readFile('./pprofile', 'base64');
@@ -303,116 +304,5 @@ describe('update credentials.json', () => {
expect(pprofile).toEqual('c29tZWJpbmFyeWNvbnRlbnQ='); // base64 "somebinarycontent"
expect(newCredJson).toEqual(credJson);
});
it('should display confirm prompt if some credentials are missing in www (confirm: true)', async () => {
// create deep clone the quick and dirty way
const testIosAppCredentialsNoDistCert = JSON.parse(
JSON.stringify(testCommonIosAppCredentialsFragment)
);
testIosAppCredentialsNoDistCert.iosAppBuildCredentialsList[0].distributionCertificate = null;
const ctx = createCtxMock({
ios: {
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(() => testIosAppCredentialsNoDistCert),
},
});
(prompts as any)
.mockImplementationOnce(() => ({ value: true })) // Continue with partial credentials
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
await updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore);
const certP12Exists = await fs.pathExists('./cert.p12');
const pprofile = await fs.readFile('./pprofile', 'base64');
const credJson = await fs.readJson('./credentials.json');
expect(certP12Exists).toEqual(false);
expect(pprofile).toEqual(testAllCredentialsForApp.credentials.provisioningProfile);
expect(credJson).toEqual({
ios: {
provisioningProfilePath: 'pprofile',
},
});
});
it('should throw an error if credentials.json contains credentials for multi-target projects', async () => {
const ctx = createCtxMock({
ios: {
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
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 appLookupParams = getAppLookupParamsFromContext(ctx);
await expect(() =>
updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore)
).rejects.toThrow(/Updating credentials.json failed/);
});
it(`should throw an error if there is a typo in credentials.json (because we can't tell if this is a target name or not)`, async () => {
const ctx = createCtxMock({
ios: {
...getNewIosApiMockWithoutCredentials(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
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',
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
await expect(() =>
updateIosCredentialsAsync(ctx, appLookupParams, IosDistributionType.AppStore)
).rejects.toThrow(/Updating credentials.json failed/);
});
});
});
@@ -1,79 +1,22 @@
import Joi from '@hapi/joi';
import fs from 'fs-extra';
import path from 'path';
import { Keystore } from '../android/credentials';
interface CredentialsJsonIosCredentials {
provisioningProfilePath: string;
distributionCertificate: {
path: string;
password: string;
};
}
export interface CredentialsJson {
android?: {
keystore: {
keystorePath: string;
keystorePassword: string;
keyAlias: string;
keyPassword?: 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().allow('').required(),
}).required(),
});
const CredentialsJsonSchema = Joi.object({
android: Joi.object({
keystore: Joi.object({
keystorePath: Joi.string().required(),
keystorePassword: Joi.string().allow('').required(),
keyAlias: Joi.string().required(),
keyPassword: Joi.string().allow(''),
}).required(),
}),
ios: [
IosTargetCredentials,
Joi.object().pattern(Joi.string().required(), IosTargetCredentials.required()),
],
experimental: Joi.object({
npmToken: Joi.string(),
}),
});
interface AndroidCredentials {
keystore: Keystore;
}
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'));
}
import { Target } from '../ios/types';
import {
AndroidCredentials,
CredentialsJson,
CredentialsJsonIosCredentials,
CredentialsJsonIosTargetCredentials,
CredentialsJsonSchema,
IosCredentials,
IosTargetCredentials,
} from './types';
import { getCredentialsJsonPath } from './utils';
export async function readAndroidCredentialsAsync(projectDir: string): Promise<AndroidCredentials> {
const credentialsJson = await readAsync(projectDir);
if (!credentialsJson.android) {
throw new Error('Android credentials are missing from credentials.json');
throw new Error('Android credentials are missing in credentials.json');
}
const keystoreInfo = credentialsJson.android.keystore;
return {
@@ -86,40 +29,45 @@ export async function readAndroidCredentialsAsync(projectDir: string): Promise<A
};
}
export async function readIosCredentialsAsync(projectDir: string): Promise<IosCredentials> {
export async function readIosCredentialsAsync(
projectDir: string,
applicationTarget: Target
): Promise<IosCredentials> {
const credentialsJson = await readAsync(projectDir);
if (!credentialsJson.ios) {
throw new Error('iOS credentials are missing from credentials.json');
throw new Error('iOS credentials are missing in credentials.json');
}
if (!isInternalCredentialsMap(credentialsJson.ios)) {
return await readCredentialsForTargetAsync(projectDir, credentialsJson.ios);
} else {
if (isCredentialsMap(credentialsJson.ios)) {
const targets = Object.keys(credentialsJson.ios);
const targetCredentialsMap: IosTargetCredentialsMap = {};
const iosCredentials: IosCredentials = {};
for (const target of targets) {
targetCredentialsMap[target] = await readCredentialsForTargetAsync(
iosCredentials[target] = await readCredentialsForTargetAsync(
projectDir,
credentialsJson.ios[target]
);
}
return targetCredentialsMap;
return iosCredentials;
} else {
const applicationTargetCredentials = await readCredentialsForTargetAsync(
projectDir,
credentialsJson.ios
);
return {
[applicationTarget.targetName]: applicationTargetCredentials,
};
}
}
function isInternalCredentialsMap(
ios: CredentialsJsonIosCredentials | Record<string, CredentialsJsonIosCredentials>
): ios is Record<string, CredentialsJsonIosCredentials> {
function isCredentialsMap(
ios: CredentialsJsonIosTargetCredentials | CredentialsJsonIosCredentials
): ios is 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
targetCredentials: CredentialsJsonIosTargetCredentials
): Promise<IosTargetCredentials> {
return {
provisioningProfile: await fs.readFile(
@@ -127,26 +75,15 @@ async function readCredentialsForTargetAsync(
'base64'
),
distributionCertificate: {
certP12: await fs.readFile(
certificateP12: await fs.readFile(
getAbsolutePath(projectDir, targetCredentials.distributionCertificate.path),
'base64'
),
certPassword: targetCredentials.distributionCertificate.password,
certificatePassword: targetCredentials.distributionCertificate.password,
},
};
}
export async function readEnvironmentSecretsAsync(
projectDir: string
): Promise<Record<string, string> | undefined> {
if (!(await fileExistsAsync(projectDir))) {
return undefined;
}
const credentialsJson = await readAsync(projectDir);
const npmToken = credentialsJson?.experimental?.npmToken;
return npmToken ? { NPM_TOKEN: npmToken } : undefined;
}
async function readAsync(projectDir: string): Promise<CredentialsJson> {
const credentialsJSONRaw = await readRawAsync(projectDir);
@@ -162,8 +99,18 @@ async function readAsync(projectDir: string): Promise<CredentialsJson> {
return credentialsJson;
}
export async function readRawAsync(projectDir: string): Promise<any> {
const credentialsJsonFilePath = path.join(projectDir, 'credentials.json');
export async function readRawAsync(
projectDir: string,
{ throwIfMissing = true } = {}
): Promise<any> {
const credentialsJsonFilePath = getCredentialsJsonPath(projectDir);
if (!(await fs.pathExists(credentialsJsonFilePath))) {
if (throwIfMissing) {
throw new Error('credentials.json does not exist in the project root directory');
} else {
return null;
}
}
try {
const credentialsJSONContents = await fs.readFile(credentialsJsonFilePath, 'utf8');
return JSON.parse(credentialsJSONContents);
@@ -0,0 +1,65 @@
import Joi from '@hapi/joi';
import { Keystore } from '../android/credentials';
export interface CredentialsJson {
android?: CredentialsJsonAndroidCredentials;
ios?: CredentialsJsonIosTargetCredentials | CredentialsJsonIosCredentials;
}
export interface CredentialsJsonAndroidCredentials {
keystore: {
keystorePath: string;
keystorePassword: string;
keyAlias: string;
keyPassword?: string;
};
}
export interface CredentialsJsonIosTargetCredentials {
provisioningProfilePath: string;
distributionCertificate: {
path: string;
password: string;
};
}
export type CredentialsJsonIosCredentials = Record<string, CredentialsJsonIosTargetCredentials>;
export interface AndroidCredentials {
keystore: Keystore;
}
export interface IosTargetCredentials {
provisioningProfile: string;
distributionCertificate: {
certificateP12: string;
certificatePassword: string;
};
}
export type IosCredentials = Record<string, IosTargetCredentials>;
const CredentialsJsonIosTargetCredentialsSchema = Joi.object({
provisioningProfilePath: Joi.string().required(),
distributionCertificate: Joi.object({
path: Joi.string().required(),
password: Joi.string().allow('').required(),
}).required(),
});
export const CredentialsJsonSchema = Joi.object({
android: Joi.object({
keystore: Joi.object({
keystorePath: Joi.string().required(),
keystorePassword: Joi.string().allow('').required(),
keyAlias: Joi.string().required(),
keyPassword: Joi.string().allow(''),
}).required(),
}),
ios: [
CredentialsJsonIosTargetCredentialsSchema,
Joi.object().pattern(
Joi.string().required(),
CredentialsJsonIosTargetCredentialsSchema.required()
),
],
});
@@ -1,56 +1,41 @@
import fs from 'fs-extra';
import zipObject from 'lodash/zipObject';
import nullthrows from 'nullthrows';
import path from 'path';
import { IosDistributionType as IosDistributionTypeGraphql } from '../../graphql/generated';
import { AndroidAppBuildCredentialsFragment, IosDistributionType } from '../../graphql/generated';
import Log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { confirmAsync } from '../../prompts';
import { gitStatusAsync } from '../../utils/git';
import { findApplicationTarget, findTargetByName } from '../../project/ios/target';
import vcs from '../../vcs';
import GitClient from '../../vcs/git';
import { Context } from '../context';
import { AppLookupParams } from '../ios/api/GraphqlClient';
import { CredentialsJson } from './read';
import { App, Target, TargetCredentials } from '../ios/types';
import { readRawAsync } from './read';
import {
CredentialsJson,
CredentialsJsonIosCredentials,
CredentialsJsonIosTargetCredentials,
} from './types';
import { getCredentialsJsonPath } from './utils';
/**
* Update Android credentials.json with values from www, content of credentials.json
* is not validated
*/
export async function updateAndroidCredentialsAsync(ctx: Context): Promise<void> {
const credentialsJsonFilePath = path.join(ctx.projectDir, 'credentials.json');
let rawCredentialsJsonObject: any = {};
if (await fs.pathExists(credentialsJsonFilePath)) {
try {
// reading raw file without validation (used only to access keystorePath)
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.');
throw error;
}
}
const accountName = await getProjectAccountName(ctx.exp, ctx.user);
const experienceName = `@${accountName}/${ctx.exp.slug}`;
const keystore = await ctx.android.fetchKeystoreAsync(experienceName);
export async function updateAndroidCredentialsAsync(
ctx: Context,
buildCredentials: AndroidAppBuildCredentialsFragment
): Promise<void> {
const rawCredentialsJson: any =
(await readRawAsync(ctx.projectDir, { throwIfMissing: false })) ?? {};
const keystore = buildCredentials.androidKeystore;
if (!keystore) {
throw new Error('There are no credentials configured for this project on EAS servers');
}
const isKeystoreComplete =
keystore.keystore && keystore.keystorePassword && keystore.keyPassword && keystore.keyAlias;
if (!isKeystoreComplete) {
const confirm = await confirmAsync({
message:
'Credentials on EAS servers might be invalid or incomplete. Are you sure you want to continue?',
});
if (!confirm) {
Log.warn('Aborting...');
return;
}
}
const keystorePath =
rawCredentialsJsonObject?.android?.keystore?.keystorePath ?? 'android/keystores/keystore.jks';
rawCredentialsJson?.android?.keystore?.keystorePath ?? 'credentials/android/keystore.jks';
Log.log(`Writing Keystore to ${keystorePath}`);
await updateFileAsync(ctx.projectDir, keystorePath, keystore.keystore);
const shouldWarnKeystore = await isFileUntrackedAsync(keystorePath);
@@ -60,11 +45,11 @@ export async function updateAndroidCredentialsAsync(ctx: Context): Promise<void>
keystorePath,
keystorePassword: keystore.keystorePassword,
keyAlias: keystore.keyAlias,
keyPassword: keystore.keyPassword,
keyPassword: keystore.keyPassword ?? undefined,
},
};
rawCredentialsJsonObject.android = androidCredentials;
await fs.writeJson(credentialsJsonFilePath, rawCredentialsJsonObject, {
rawCredentialsJson.android = androidCredentials;
await fs.writeJson(getCredentialsJsonPath(ctx.projectDir), rawCredentialsJson, {
spaces: 2,
});
const shouldWarnCredentialsJson = await isFileUntrackedAsync('credentials.json');
@@ -80,124 +65,224 @@ export async function updateAndroidCredentialsAsync(ctx: Context): Promise<void>
}
/**
* Update iOS credentials in credentials.json with values from www, content
* of credentials.json is not validated, if www has incomplete credentials
* Update iOS credentials in credentials.json with values from www, contents
* of credentials.json are not validated, if www has incomplete credentials
* credentials.json will be updated partially
*/
export async function updateIosCredentialsAsync(
ctx: Context,
appLookupParams: AppLookupParams,
iosDistributionTypeGraphql: IosDistributionTypeGraphql
app: App,
targets: Target[],
distributionType: IosDistributionType
): Promise<void> {
const credentialsJsonFilePath = path.join(ctx.projectDir, 'credentials.json');
let rawCredentialsJsonObject: any = {};
if (await fs.pathExists(credentialsJsonFilePath)) {
try {
// reading raw file without validation (used only to access paths)
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.');
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 rawCredentialsJson: any =
(await readRawAsync(ctx.projectDir, { throwIfMissing: false })) ?? {};
if (typeof rawCredentialsJson.ios?.provisioningProfilePath === 'string') {
const applicationTarget = findApplicationTarget(targets);
rawCredentialsJson.ios = {
[applicationTarget.targetName]: rawCredentialsJson.ios,
};
}
const profilePath =
rawCredentialsJsonObject?.ios?.provisioningProfilePath ?? 'ios/certs/profile.mobileprovision';
const distCertPath =
rawCredentialsJsonObject?.ios?.distributionCertificate?.path ?? 'ios/certs/dist-cert.p12';
const iosAppCredentials = await ctx.ios.getIosAppCredentialsWithCommonFieldsAsync(
appLookupParams
const targetBuildCredentialsList = await Promise.all(
targets.map(target => getTargetBuildCredentialsAsync(ctx, app, target, distributionType))
);
const targetBuildsCredentialsMap = zipObject(
targets.map(({ targetName }) => targetName),
targetBuildCredentialsList
);
const buildCredentials =
iosAppCredentials?.iosAppBuildCredentialsList.find(
buildCredentials => buildCredentials.iosDistributionType === iosDistributionTypeGraphql
) ?? null;
if (!buildCredentials) {
throw new Error(
`There are no credentials configured for the ${iosDistributionTypeGraphql} distribution of this project on EAS servers`
);
}
const areCredentialsComplete =
buildCredentials.provisioningProfile?.provisioningProfile &&
buildCredentials.distributionCertificate?.certificateP12 &&
buildCredentials.distributionCertificate.certificatePassword;
if (!areCredentialsComplete) {
const confirm = await confirmAsync({
message:
'Credentials on EAS servers might be invalid or incomplete. Are you sure you want to continue?',
});
if (!confirm) {
Log.warn('Aborting...');
return;
let areAllTargetsConfigured = true;
const notConfiguredTargetLabels: string[] = [];
for (const [targetName, targetAppBuildCredentials] of Object.entries(
targetBuildsCredentialsMap
)) {
if (!targetAppBuildCredentials) {
areAllTargetsConfigured = false;
const { bundleIdentifier } = findTargetByName(targets, targetName);
notConfiguredTargetLabels.push(`${targetName} (Bundle Identifier: ${bundleIdentifier})`);
}
}
const provisioningProfile =
buildCredentials.provisioningProfile?.provisioningProfile ?? undefined;
const distributionCertificateP12 =
buildCredentials.distributionCertificate?.certificateP12 ?? undefined;
const distributionCertificatePassword =
buildCredentials.distributionCertificate?.certificatePassword ?? undefined;
Log.log(`Writing Provisioning Profile to ${profilePath}`);
await updateFileAsync(ctx.projectDir, profilePath, provisioningProfile);
const shouldWarnPProfile = await isFileUntrackedAsync(profilePath);
Log.log(`Writing Distribution Certificate to ${distCertPath}`);
await updateFileAsync(ctx.projectDir, distCertPath, distributionCertificateP12);
const shouldWarnDistCert = await isFileUntrackedAsync(distCertPath);
if (!areAllTargetsConfigured) {
const errorMessage =
targets.length === 1
? `There are no credentials configured for the ${distributionType} distribution of this project on EAS servers`
: `Some of the build targets don't have credentials configured for the ${distributionType} distribution of this project on EAS servers: ${notConfiguredTargetLabels}`;
throw new Error(errorMessage);
}
const iosCredentials: Partial<CredentialsJson['ios']> = {
...(provisioningProfile ? { provisioningProfilePath: profilePath } : {}),
...(distributionCertificateP12 && distributionCertificatePassword
? {
distributionCertificate: {
path: distCertPath,
password: distributionCertificatePassword,
},
}
: {}),
};
rawCredentialsJsonObject.ios = iosCredentials;
await fs.writeJson(credentialsJsonFilePath, rawCredentialsJsonObject, {
const iosCredentials: CredentialsJsonIosCredentials = {};
const targetCredentialsPathsMap = createTargetCredentialsPathsMap(
targets,
rawCredentialsJson.ios
);
for (const target of targets) {
iosCredentials[target.targetName] = await backupTargetCredentialsAsync(ctx, {
// app build credentials must exist for target because otherwise an error must have been thrown earlier
targetCredentials: nullthrows(targetBuildsCredentialsMap[target.targetName]),
targetCredentialsPaths: targetCredentialsPathsMap[target.targetName],
});
}
if (Object.keys(iosCredentials).length === 1) {
rawCredentialsJson.ios = iosCredentials[Object.keys(iosCredentials)[0]];
} else {
rawCredentialsJson.ios = iosCredentials;
}
await fs.writeJson(getCredentialsJsonPath(ctx.projectDir), rawCredentialsJson, {
spaces: 2,
});
const shouldWarnCredentialsJson = await isFileUntrackedAsync('credentials.json');
const newFilePaths = [];
if (shouldWarnPProfile) {
newFilePaths.push(profilePath);
for (const [, targetCredentials] of Object.entries(iosCredentials)) {
if (await isFileUntrackedAsync(targetCredentials.distributionCertificate.path)) {
newFilePaths.push(targetCredentials.distributionCertificate.path);
}
if (await isFileUntrackedAsync(targetCredentials.provisioningProfilePath)) {
newFilePaths.push(targetCredentials.provisioningProfilePath);
}
}
if (shouldWarnDistCert) {
newFilePaths.push(distCertPath);
}
if (shouldWarnCredentialsJson) {
if (await isFileUntrackedAsync('credentials.json')) {
newFilePaths.push('credentials.json');
}
displayUntrackedFilesWarning(newFilePaths);
}
async function updateFileAsync(projectDir: string, filePath: string, base64Data?: string) {
interface TargetCredentialsPaths {
provisioningProfilePath: string;
distCertPath: string;
}
type TargetCredentialsPathsMap = Record<string, TargetCredentialsPaths>;
function createTargetCredentialsPathsMap(
targets: Target[],
rawCredentialsJsonMap?: any
): TargetCredentialsPathsMap {
const hasManyTargets = targets.length > 1;
const paths: TargetCredentialsPathsMap = {};
// 1. Create initial target credentials paths map
for (const target of targets) {
const rawTargetCredentialsJson = rawCredentialsJsonMap?.[target.targetName];
const filePrefix = hasManyTargets ? `${target.targetName}-` : '';
paths[target.targetName] = {
provisioningProfilePath:
rawTargetCredentialsJson?.provisioningProfilePath ??
`credentials/ios/${filePrefix}profile.mobileprovision`,
distCertPath:
rawTargetCredentialsJson?.distributionCertificate?.path ??
`credentials/ios/${filePrefix}dist-cert.p12`,
};
}
// 2. Look for duplicates and prefix them with target names
const deduplicatedPaths: TargetCredentialsPathsMap = {};
const usedProfilePaths = new Set<string>();
const usedDistCertPaths = new Set<string>();
for (const [targetName, { provisioningProfilePath, distCertPath }] of Object.entries(paths)) {
const newProvisioningProfilePath = usedProfilePaths.has(provisioningProfilePath)
? path.join(
path.dirname(provisioningProfilePath),
`${targetName}-${path.basename(provisioningProfilePath)}`
)
: provisioningProfilePath;
usedProfilePaths.add(newProvisioningProfilePath);
const newDistCertPath = usedDistCertPaths.has(distCertPath)
? path.join(path.dirname(distCertPath), `${targetName}-${path.basename(distCertPath)}`)
: distCertPath;
usedDistCertPaths.add(newDistCertPath);
deduplicatedPaths[targetName] = {
distCertPath: newDistCertPath,
provisioningProfilePath: newProvisioningProfilePath,
};
}
return deduplicatedPaths;
}
async function getTargetBuildCredentialsAsync(
ctx: Context,
app: App,
target: Target,
iosDistributionType: IosDistributionType
): Promise<TargetCredentials | null> {
const appCredentials = await ctx.ios.getIosAppCredentialsWithCommonFieldsAsync({
account: app.account,
projectName: app.projectName,
bundleIdentifier: target.bundleIdentifier,
parentBundleIdentifier: target.parentBundleIdentifier,
});
const appBuildCredentials =
appCredentials?.iosAppBuildCredentialsList.find(
appBuildCredentials => appBuildCredentials.iosDistributionType === iosDistributionType
) ?? null;
if (appBuildCredentials === null) {
return null;
}
if (
!(
appBuildCredentials.provisioningProfile?.provisioningProfile !== undefined &&
appBuildCredentials.distributionCertificate?.certificateP12 !== undefined &&
appBuildCredentials.distributionCertificate?.certificatePassword !== undefined
)
) {
return null;
}
return {
distributionCertificate: {
certificateP12: nullthrows(appBuildCredentials.distributionCertificate.certificateP12),
certificatePassword: nullthrows(
appBuildCredentials.distributionCertificate.certificatePassword
),
},
provisioningProfile: nullthrows(appBuildCredentials.provisioningProfile.provisioningProfile),
};
}
async function backupTargetCredentialsAsync(
ctx: Context,
{
targetCredentials,
targetCredentialsPaths,
}: {
targetCredentials: TargetCredentials;
targetCredentialsPaths: TargetCredentialsPaths;
}
): Promise<CredentialsJsonIosTargetCredentials> {
const { provisioningProfilePath, distCertPath } = targetCredentialsPaths;
Log.log(`Writing Provisioning Profile to ${provisioningProfilePath}`);
await updateFileAsync(
ctx.projectDir,
provisioningProfilePath,
targetCredentials.provisioningProfile
);
Log.log(`Writing Distribution Certificate to ${distCertPath}`);
await updateFileAsync(
ctx.projectDir,
distCertPath,
targetCredentials.distributionCertificate.certificateP12
);
return {
distributionCertificate: {
path: distCertPath,
password: targetCredentials.distributionCertificate.certificatePassword,
},
provisioningProfilePath,
};
}
async function updateFileAsync(
projectDir: string,
filePath: string,
base64Data?: string
): Promise<void> {
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectDir, filePath);
if (await fs.pathExists(absolutePath)) {
await fs.remove(absolutePath);
@@ -209,25 +294,22 @@ async function updateFileAsync(projectDir: string, filePath: string, base64Data?
}
async function isFileUntrackedAsync(path: string): Promise<boolean> {
const withUntrackedFiles = await gitStatusAsync({ showUntracked: true });
const trackedFiles = await gitStatusAsync({ showUntracked: false });
const pathWithoutLeadingDot = path.replace(/^\.\//, ''); // remove leading './' from path
return (
withUntrackedFiles.includes(pathWithoutLeadingDot) &&
!trackedFiles.includes(pathWithoutLeadingDot)
);
if (vcs instanceof GitClient) {
return await vcs.isFileUntrackedAsync(path);
}
return false;
}
function displayUntrackedFilesWarning(newFilePaths: string[]) {
if (newFilePaths.length === 1) {
Log.warn(
`File ${newFilePaths[0]} is currently untracked, remember to add it to .gitignore or to encrypt it. (e.g. with git-crypt)`
`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(
`Files ${newFilePaths.join(
', '
)} are currently untracked, remember to add them to .gitignore or to encrypt them. (e.g. with git-crypt)`
)} are currently untracked, remember to add them to .gitignore, or to encrypt them (e.g. with git-crypt).`
);
}
}
@@ -0,0 +1,33 @@
import path from 'path';
import { Target } from '../ios/types';
import { IosCredentials } from './types';
export function getCredentialsJsonPath(projectDir: string): string {
return path.join(projectDir, 'credentials.json');
}
export function ensureAllTargetsAreConfigured(
targets: Target[],
credentialsJson: IosCredentials
): void {
const notConfiguredTargets: string[] = [];
for (const target of targets) {
if (!(target.targetName in credentialsJson)) {
notConfiguredTargets.push(target.targetName);
continue;
}
}
if (notConfiguredTargets.length > 0) {
const errorMessage =
targets.length === 1
? 'Credentials are not defined in credentials.json'
: `Credentials for target${
notConfiguredTargets.length === 1 ? '' : 's'
} ${notConfiguredTargets
.map(i => `'${i}'`)
.join(',')} are not defined in credentials.json`;
throw new Error(errorMessage);
}
}
@@ -1,22 +1,20 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource, IosDistributionType, IosEnterpriseProvisioning } from '@expo/eas-json';
import { CredentialsManager } from '../CredentialsManager';
import { findApplicationTarget } from '../../project/ios/target';
import { Context } from '../context';
import * as credentialsJsonReader from '../credentialsJson/read';
import type { IosCredentials } from '../credentialsJson/read';
import { ensureAllTargetsAreConfigured } from '../credentialsJson/utils';
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
import { AppLookupParams } from './api/GraphqlClient';
import { IosCapabilitiesOptions } from './appstore/ensureAppExists';
import { App, IosCredentials, Target } from './types';
import { isAdHocProfile } from './utils/provisioningProfile';
export type { IosCredentials };
interface Options {
app: AppLookupParams;
app: App;
targets: Target[];
distribution: IosDistributionType;
enterpriseProvisioning?: IosEnterpriseProvisioning;
skipCredentialsCheck?: boolean;
iosCapabilitiesOptions?: IosCapabilitiesOptions;
}
@@ -36,19 +34,33 @@ export default class IosCredentialsProvider {
}
private async getLocalAsync(): Promise<IosCredentials> {
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);
const applicationTarget = findApplicationTarget(this.options.targets);
const iosCredentials = await credentialsJsonReader.readIosCredentialsAsync(
this.ctx.projectDir,
applicationTarget
);
ensureAllTargetsAreConfigured(this.options.targets, iosCredentials);
for (const target of this.options.targets) {
this.assertProvisioningProfileType(
iosCredentials[target.targetName].provisioningProfile,
target.targetName
);
}
return credentials;
return iosCredentials;
}
private assertProvisioningProfileType(provisionigProfile: string, targetName?: string) {
const isAdHoc = isAdHocProfile(provisionigProfile);
private async getRemoteAsync(): Promise<IosCredentials> {
return await new SetupBuildCredentials({
app: this.options.app,
targets: this.options.targets,
distribution: this.options.distribution,
enterpriseProvisioning: this.options.enterpriseProvisioning,
iosCapabilitiesOptions: this.options.iosCapabilitiesOptions,
}).runAsync(this.ctx);
}
private assertProvisioningProfileType(provisioningProfile: string, targetName?: string): void {
const isAdHoc = isAdHocProfile(provisioningProfile);
if (this.options.distribution === 'internal' && !isAdHoc) {
throw new Error(
`You must use an adhoc provisioning profile${
@@ -63,21 +75,4 @@ export default class IosCredentialsProvider {
);
}
}
private async getRemoteAsync(): Promise<IosCredentials> {
const manager = new CredentialsManager(this.ctx);
const { provisioningProfile, distributionCertificate } = await new SetupBuildCredentials({
app: this.options.app,
distribution: this.options.distribution,
enterpriseProvisioning: this.options.enterpriseProvisioning,
iosCapabilitiesOptions: this.options.iosCapabilitiesOptions,
}).runAsync(manager, this.ctx);
return {
provisioningProfile,
distributionCertificate: {
certP12: distributionCertificate.certificateP12,
certPassword: distributionCertificate.certificatePassword,
},
};
}
}
@@ -2,11 +2,13 @@ import { CredentialsSource } from '@expo/eas-json';
import { vol } from 'memfs';
import { IosAppBuildCredentialsFragment } from '../../../graphql/generated';
import { findApplicationTarget } from '../../../project/ios/target';
import { getAppstoreMock } from '../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../__tests__/fixtures-context';
import {
getNewIosApiMockWithoutCredentials,
testIosAppCredentialsWithBuildCredentialsQueryResult,
getNewIosApiMock,
testCommonIosAppCredentialsFragment,
testTargets,
} from '../../__tests__/fixtures-ios';
import IosCredentialsProvider from '../IosCredentialsProvider';
import { getAppLookupParamsFromContext } from '../actions/BuildCredentialsUtils';
@@ -54,20 +56,23 @@ describe(IosCredentialsProvider, () => {
appStore: getAppstoreMock(),
projectDir: '/app',
ios: {
...getNewIosApiMockWithoutCredentials(),
...getNewIosApiMock(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(() => null),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(
ctx,
findApplicationTarget(testTargets)
);
const provider = new IosCredentialsProvider(ctx, {
app: {
account: {
id: `id-${appLookupParams.account.name}`,
name: appLookupParams.account.name,
},
bundleIdentifier: appLookupParams.bundleIdentifier,
projectName: appLookupParams.projectName,
},
targets: [{ targetName: 'testapp', bundleIdentifier: appLookupParams.bundleIdentifier }],
distribution: 'store',
});
await expect(provider.getCredentialsAsync(CredentialsSource.REMOTE)).rejects.toThrowError(
@@ -81,39 +86,43 @@ describe(IosCredentialsProvider, () => {
appStore: getAppstoreMock(),
projectDir: '/app',
ios: {
...getNewIosApiMockWithoutCredentials(),
...getNewIosApiMock(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
() => testIosAppCredentialsWithBuildCredentialsQueryResult
() => testCommonIosAppCredentialsFragment
),
getDistributionCertificateForAppAsync: jest.fn(
() =>
testIosAppCredentialsWithBuildCredentialsQueryResult.iosAppBuildCredentialsList[0]
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0]
.distributionCertificate
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx);
const appLookupParams = getAppLookupParamsFromContext(
ctx,
findApplicationTarget(testTargets)
);
const provider = new IosCredentialsProvider(ctx, {
app: {
account: {
id: `id-${appLookupParams.account.name}`,
name: appLookupParams.account.name,
},
bundleIdentifier: appLookupParams.bundleIdentifier,
projectName: appLookupParams.projectName,
},
targets: [{ targetName: 'testapp', bundleIdentifier: appLookupParams.bundleIdentifier }],
distribution: 'store',
});
const buildCredentials =
testIosAppCredentialsWithBuildCredentialsQueryResult.iosAppBuildCredentialsList[0];
const buildCredentials = testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0];
await expect(provider.getCredentialsAsync(CredentialsSource.REMOTE)).resolves.toMatchObject(
{
distributionCertificate: {
certP12: buildCredentials.distributionCertificate?.certificateP12,
certPassword: buildCredentials.distributionCertificate?.certificatePassword,
testapp: {
distributionCertificate: {
certificateP12: buildCredentials.distributionCertificate?.certificateP12,
certificatePassword: buildCredentials.distributionCertificate?.certificatePassword,
},
provisioningProfile: buildCredentials.provisioningProfile?.provisioningProfile,
},
provisioningProfile: buildCredentials.provisioningProfile?.provisioningProfile,
}
);
});
@@ -14,3 +14,7 @@ export async function resolveAppleTeamIfAuthenticatedAsync(
appleTeamName: ctx.appStore.authCtx.team.name,
});
}
export function formatAppleTeam({ appleTeamIdentifier, appleTeamName }: AppleTeamFragment): string {
return `Team ID: ${appleTeamIdentifier}${appleTeamName ? `, Team name: ${appleTeamName}` : ''}`;
}
@@ -0,0 +1,26 @@
import { ApplePushKeyFragment, CommonIosAppCredentialsFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
export class AssignPushKey {
constructor(private app: AppLookupParams) {}
public async runAsync(
ctx: Context,
pushKey: ApplePushKeyFragment
): Promise<CommonIosAppCredentialsFragment> {
const appleTeam =
(await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app)) ?? pushKey.appleTeam ?? null;
const appCredentials = await ctx.ios.createOrGetIosAppCredentialsWithCommonFieldsAsync(
this.app,
{ appleTeam: appleTeam ?? undefined }
);
const updatedAppCredentials = await ctx.ios.updateIosAppCredentialsAsync(appCredentials, {
applePushKeyId: pushKey.id,
});
Log.succeed(`Push Key assigned to ${this.app.projectName}: ${this.app.bundleIdentifier}`);
return updatedAppCredentials;
}
}
@@ -7,11 +7,11 @@ import {
IosDistributionType as GraphQLIosDistributionType,
IosAppBuildCredentialsFragment,
} from '../../../graphql/generated';
import { getBundleIdentifier } from '../../../project/ios/bundleIdentifier';
import { getProjectAccountName } from '../../../project/projectUtils';
import { findAccountByName } from '../../../user/Account';
import { Context } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
import { App, Target } from '../types';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
export async function getAllBuildCredentialsAsync(
@@ -82,7 +82,7 @@ export async function assignBuildCredentialsAsync(
});
}
export function getAppLookupParamsFromContext(ctx: Context): AppLookupParams {
export function getAppFromContext(ctx: Context): App {
ctx.ensureProjectContext();
const projectName = ctx.exp.slug;
const accountName = getProjectAccountName(ctx.exp, ctx.user);
@@ -90,8 +90,13 @@ export function getAppLookupParamsFromContext(ctx: Context): AppLookupParams {
if (!account) {
throw new Error(`You do not have access to account: ${accountName}`);
}
return {
account,
projectName,
};
}
const bundleIdentifier = getBundleIdentifier(ctx.projectDir, ctx.exp);
return { account, projectName, bundleIdentifier };
export function getAppLookupParamsFromContext(ctx: Context, target: Target): AppLookupParams {
const app = getAppFromContext(ctx);
return { ...app, bundleIdentifier: target.bundleIdentifier };
}
@@ -0,0 +1,20 @@
import { ApplePushKeyFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Account } from '../../../user/Account';
import { Context } from '../../context';
import { provideOrGeneratePushKeyAsync } from './PushKeyUtils';
export class CreatePushKey {
constructor(private account: Account) {}
public async runAsync(ctx: Context): Promise<ApplePushKeyFragment> {
if (ctx.nonInteractive) {
throw new Error(`A new push key cannot be created in non-interactive mode.`);
}
const pushKey = await provideOrGeneratePushKeyAsync(ctx, this.account.name);
const result = await ctx.ios.createPushKeyAsync(this.account, pushKey);
Log.succeed('Created push key');
return result;
}
}
@@ -2,10 +2,7 @@ import assert from 'assert';
import chalk from 'chalk';
import dateformat from 'dateformat';
import {
AppleDistributionCertificateFragment,
AppleTeamFragment,
} from '../../../graphql/generated';
import { AppleDistributionCertificateFragment } from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
import { promptAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
@@ -21,6 +18,7 @@ import {
import { filterRevokedDistributionCertsFromEasServers } from '../appstore/CredentialsUtils';
import { distributionCertificateSchema } from '../credentials';
import { validateDistributionCertificateAsync } from '../validators/validateDistributionCertificate';
import { formatAppleTeam } from './AppleTeamUtils';
export function formatDistributionCertificate(
distributionCertificate: AppleDistributionCertificateFragment,
@@ -63,10 +61,6 @@ export function formatDistributionCertificate(
return line;
}
function formatAppleTeam({ appleTeamIdentifier, appleTeamName }: AppleTeamFragment): string {
return `Team ID: ${appleTeamIdentifier}${appleTeamName ? `, Team name: ${appleTeamName}` : ''}`;
}
async function _selectDistributionCertificateAsync(
distCerts: AppleDistributionCertificateFragment[],
validDistributionCertificates?: AppleDistributionCertificateFragment[]
@@ -0,0 +1,202 @@
import chalk from 'chalk';
import { ApplePushKeyFragment } from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
import { confirmAsync, promptAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
import { fromNow } from '../../../utils/date';
import { Context } from '../../context';
import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
import { PushKey, PushKeyStoreInfo } from '../appstore/Credentials.types';
import { filterRevokedAndUntrackedPushKeysFromEasServers } from '../appstore/CredentialsUtils';
import { APPLE_KEYS_TOO_MANY_GENERATED_ERROR } from '../appstore/pushKey';
import { pushKeySchema } from '../credentials';
import { isPushKeyValidAndTrackedAsync } from '../validators/validatePushKey';
import { formatAppleTeam } from './AppleTeamUtils';
export async function provideOrGeneratePushKeyAsync(
ctx: Context,
accountName: string
): Promise<PushKey> {
if (!ctx.nonInteractive) {
const userProvided = await promptForPushKeyAsync(ctx);
if (userProvided) {
if (!ctx.appStore.authCtx) {
Log.warn('Unable to validate push key due to insufficient Apple Credentials');
return userProvided;
} else {
const isValidAndTracked = await isPushKeyValidAndTrackedAsync(ctx, userProvided);
if (isValidAndTracked) {
return userProvided;
}
// user could've just input the id wrong, and the p8 could still be valid
const useUserProvided = await confirmAsync({
message: `Push Key with ID ${userProvided.apnsKeyId} could not be found on Apple's servers. Proceed anyway?`,
});
if (useUserProvided) {
return userProvided;
}
return await provideOrGeneratePushKeyAsync(ctx, accountName);
}
}
}
return await generatePushKeyAsync(ctx, accountName);
}
async function promptForPushKeyAsync(ctx: Context): Promise<PushKey | null> {
let initialValues: { teamId?: string } = {};
if (ctx.appStore.authCtx) {
initialValues = {
teamId: ctx.appStore.authCtx.team.id,
};
}
const userProvided = await askForUserProvidedAsync(pushKeySchema, initialValues);
if (!userProvided) {
return null;
}
if (ctx.appStore.authCtx && userProvided.teamId === initialValues.teamId) {
return {
...userProvided,
teamName: ctx.appStore.authCtx.team.name,
};
}
return userProvided;
}
async function generatePushKeyAsync(ctx: Context, accountName: string): Promise<PushKey> {
await ctx.appStore.ensureAuthenticatedAsync();
try {
return await ctx.appStore.createPushKeyAsync();
} catch (e) {
if (e.message === APPLE_KEYS_TOO_MANY_GENERATED_ERROR) {
const pushKeys = await ctx.appStore.listPushKeysAsync();
Log.warn('Maximum number of Push Notifications Keys generated on Apple Developer Portal.');
Log.warn(APPLE_KEYS_TOO_MANY_GENERATED_ERROR);
if (ctx.nonInteractive) {
throw new Error(
"Start the CLI without the '--non-interactive' flag to revoke existing push notification keys."
);
}
Log.log(chalk.grey(`⚠️ Revoking a Push Key will affect other apps that rely on it`));
Log.log(learnMore('https://docs.expo.io/distribution/app-signing/#push-notification-keys'));
Log.log();
const { pushKeysToRevoke } = await promptAsync({
type: 'multiselect',
name: 'pushKeysToRevoke',
message: 'Select Push Notifications Key to revoke.',
// @ts-expect-error property missing from `@types/prompts`
optionsPerPage: 20,
choices: pushKeys.map(pushKey => ({
value: pushKey,
title: formatPushKeyFromApple(pushKey),
})),
});
if (pushKeysToRevoke.length > 0) {
const ids = pushKeysToRevoke.map(({ id }: PushKeyStoreInfo) => id);
await ctx.appStore.revokePushKeyAsync(ids);
}
} else {
throw e;
}
}
return await generatePushKeyAsync(ctx, accountName);
}
function formatPushKeyFromApple(pushKey: PushKeyStoreInfo): string {
const { name, id } = pushKey;
const keyIdText = id ? ` - Key ID: ${id}` : ``;
return `${name}${keyIdText}`;
}
/**
* select a push key from an account (validity status shown on a best effort basis)
* */
export async function selectPushKeyAsync(
ctx: Context,
account: Account
): Promise<ApplePushKeyFragment | null> {
const pushKeysForAccount = await ctx.ios.getPushKeysForAccountAsync(account);
if (pushKeysForAccount.length === 0) {
Log.warn(`There are no Push Keys available in your EAS account.`);
return null;
}
if (!ctx.appStore.authCtx) {
return selectPushKeysAsync(pushKeysForAccount);
}
const validPushKeys = await getValidAndTrackedPushKeysOnEasServersAsync(ctx, pushKeysForAccount);
return selectPushKeysAsync(pushKeysForAccount, validPushKeys);
}
export async function getValidAndTrackedPushKeysOnEasServersAsync(
ctx: Context,
pushKeysForAccount: ApplePushKeyFragment[]
): Promise<ApplePushKeyFragment[]> {
const pushInfoFromApple = await ctx.appStore.listPushKeysAsync();
return await filterRevokedAndUntrackedPushKeysFromEasServers(
pushKeysForAccount,
pushInfoFromApple
);
}
async function selectPushKeysAsync(
pushKeys: ApplePushKeyFragment[],
validPushKeys?: ApplePushKeyFragment[]
): Promise<ApplePushKeyFragment | null> {
const validPushKeyIdentifiers = validPushKeys?.map(pushKey => pushKey.keyIdentifier);
const sortedPushKeys = sortPushKeys(pushKeys, validPushKeys);
const { chosenPushKey } = await promptAsync({
type: 'select',
name: 'chosenPushKey',
message: 'Select a push key from the list.',
choices: sortedPushKeys.map(pushKey => ({
title: formatPushKey(pushKey, validPushKeyIdentifiers),
value: pushKey,
})),
});
return chosenPushKey;
}
export function sortPushKeys(
pushKeys: ApplePushKeyFragment[],
validPushKeys?: ApplePushKeyFragment[]
): ApplePushKeyFragment[] {
const validPushKeyIdentifiers = validPushKeys?.map(pushKey => pushKey.keyIdentifier);
return pushKeys.sort((pushKeyA, pushKeyB) => {
if (validPushKeyIdentifiers?.includes(pushKeyA.keyIdentifier)) {
return -1;
} else if (validPushKeyIdentifiers?.includes(pushKeyB.keyIdentifier)) {
return 1;
}
return 0;
});
}
export function formatPushKey(
pushKey: ApplePushKeyFragment,
validPushKeyIdentifiers?: string[]
): string {
const { keyIdentifier, appleTeam, updatedAt } = pushKey;
let line: string = '';
line += `Push Key ID: ${pushKey.keyIdentifier}`;
line += ` ${appleTeam ? `, ${formatAppleTeam(appleTeam)}` : ''}`;
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago,`);
const apps = pushKey.iosAppCredentialsList.map(appCredentials => appCredentials.app);
if (apps.length) {
const appFullNames = apps.map(app => app.fullName).join(',');
line += chalk.gray(`\n 📲 Used by: ${appFullNames}`);
}
if (validPushKeyIdentifiers?.includes(keyIdentifier)) {
line += chalk.gray("\n ✅ Currently valid on Apple's servers.");
} else {
line += '';
}
return line;
}
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more