Compare commits

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

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

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

* [eas-cli] Remove isExpoAdmin from user query

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

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

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

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

* [eas-cli] Regenerate graphql types

* [eas-cli] Add project utils unit tests

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

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

* [eas-cli] Simplify expo config mock

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Update authCredentials.ts

* Update authCredentials.ts

* clean up

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

* Apply suggestions from code review

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

* fix for internal distribution

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

* clean up spinner logs

* Update ConfigureProvisioningProfile.ts

* Update validateProvisioningProfile.ts

* Update ensureAppExists.ts

* Update ensureAppExists.ts

* Update create.ts

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

* "App" instead of "Artificat url"

* fix typos

* fix error

* typo

* typo

* EAS Account -> Expo Account

* Update ensureProjectExists.ts

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

* Downgrade error log to warning

* Update create.ts

* add underline to logs url

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

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

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

* [eas-cli] address feedback

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

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

* [eas-cli] add prompt

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

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

* [eas-cli] better 404 check

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

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

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

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

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

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

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

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

* Fix remaining type errors

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

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

* Update language support

* Fixes

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

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

* Fix eslint warnings

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

* bump apple-utils

* Update ensureAppExists.ts

* refactor

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

* Add fastlane utility

* Fastlane-based app store create

* Update flags description

* Password prompt type

* Update help

* Address some feedback

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

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

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

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

* [config] fix tests

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

* [eas-cli] fix lint warning

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

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

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

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

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

* [eas-cli] fix lint warning

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

* Fix linter warnings

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

* Update ensureAppExists.ts

* added dev types packages

* fix loading indicators

* refactor names

* Update provisioningProfileAdhoc.ts

* Update provisioningProfileAdhoc.ts

* Update provisioningProfileAdhoc.ts

* Update provisioningProfile.ts

* move method

* refactor

* Update authenticate.ts

* Properly throw AppleTooManyCertsError error

* refactor names

* Update provisioningProfile.ts

* batch

* Update distributionCertificate.ts

* Update bundleId.ts

* Update provisioningProfileAdhoc.ts

* Integrate auth state changes

* Update authenticate.ts

* remove extra auth check

* bump

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

* Apply suggestions from code review

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

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

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

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

* pr feedback

* update annotation

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

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

* [eas-cli] address feedback

* [eas-cli] fix tests

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

* credentials.json support + fixes for managed workflow

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

* Extract promptForAppleId in credentials

* iOS upload core functionality

* Revert extracting "promptForAppleId" from credentials

Discussed with @wkozyra95

* Minor renames and suggestions

* Improve infering archive source type

* iOS submit command test

* Extract common code, print summary (#58)

* Submitter class hierarchy

* Extract commons from SubmitCommand

* Submission Summary

* Remove note about storing passwords

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

* Error handling

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

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

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

* Copy Android submission-service code from Expo-CLI

* Delete all occurences of offline submission mode

* Fix prompts

* Remove all XDL usages

* Turtle v2 builds and fix upload

* Refactor exports

* Fixes and suggestions

* Latest build graphql

* Apply suggestions

* Remove validator lib

* Revert iOS archive type

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

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

No files matched your search

+6 -1
View File
@@ -1,2 +1,7 @@
build
**/build/**
!packages/eas-cli/src/commands/build/**
!packages/eas-cli/src/build/**
packages/eas-cli/src/graphql/generated.ts
node_modules
+11
View File
@@ -0,0 +1,11 @@
# Why
Please describe the motivation for this PR, and link to relevant GitHub issues, forums posts, or feature requests.
# How
How did you build this feature or fix this bug and why?
# Test Plan
Please describe how you tested this change and how a reviewer could reproduce your test, especially if this PR does not include automated tests! If possible, please also provide terminal output and/or screenshots demonstrating your test/reproduction.
+18
View File
@@ -0,0 +1,18 @@
name: Installer tests
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize]
jobs:
test:
name: Run the installer
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v2
- run: bash bin/install.sh
- run: eas --version
+115
View File
@@ -0,0 +1,115 @@
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
name: Release workflow
jobs:
release:
name: Create a release
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ github.ref }}
draft: true
build-linux:
name: Build for Linux
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14'
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: yarn oclif-dev pack --targets linux-x64,linux-arm
- name: Upload linux-x64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/eas-linux-x64.tar.gz
asset_name: eas-linux-x64.tar.gz
asset_content_type: application/gzip
- name: Upload linux-arm tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/eas-linux-arm.tar.gz
asset_name: eas-linux-arm.tar.gz
asset_content_type: application/gzip
build-mac:
name: Build for macOS
# Use macOS to get the correct platform specific dependencies
runs-on: macos-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14'
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: yarn oclif-dev pack --targets darwin-x64
- name: Upload darwin-x64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/eas-darwin-x64.tar.gz
asset_name: eas-darwin-x64.tar.gz
asset_content_type: application/gzip
build-windows:
name: Build for Windows
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14'
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
- run: yarn oclif-dev pack:win
working-directory: ${{ github.workspace }}/packages/eas-cli
- id: x64
run: echo "::set-output name=filename::$(ls eas-*-x64.exe)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win
- id: x86
run: echo "::set-output name=filename::$(ls eas-*-x86.exe)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win
- name: Upload x64 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload x86 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win/${{ steps.x86.outputs.filename }}
asset_name: ${{ steps.x86.outputs.filename }}
asset_content_type: application/gzip
+18
View File
@@ -0,0 +1,18 @@
name: Size
on:
pull_request:
types: [synchronize, opened]
jobs:
compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14'
- uses: preactjs/compressed-size-action@v2
with:
build-script: 'build:tarballs:linux'
repo-token: '${{ secrets.GITHUB_TOKEN }}'
pattern: './packages/eas-cli/dist/*.tar.gz'
compression: 'none'
+13 -3
View File
@@ -11,15 +11,25 @@ jobs:
strategy:
matrix:
node: ['12', '14']
include:
- node: '14'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 1
- name: Setup node
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- run: yarn install --frozen-lockfile --check-files
- run: yarn lerna run build
- run: yarn test
- run: yarn lint
if: ${{ !matrix.coverage }}
- run: yarn test --coverage
if: ${{ matrix.coverage }}
- run: yarn lint --max-warnings=0
- run: shellcheck bin/*.sh
- uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
+6 -1
View File
@@ -1,8 +1,13 @@
*-debug.log
*-error.log
**/build/**
!packages/eas-cli/src/commands/build/**
!packages/eas-cli/src/build/**
dist
build
/package-lock.json
tmp
node_modules
coverage
.history
+8
View File
@@ -0,0 +1,8 @@
## Releasing a new version
1. Add release notes to `CHANGELOG.md`.
1. Run `yarn release` in the repository root folder. This will create a new tag
like `v1.0.0`.
1. Wait until the `release` GitHub workflow has finished.
1. Once the release ready to go, you can go to
https://github.com/expo/eas-cli/releases and mark it as not draft.
+131
View File
@@ -0,0 +1,131 @@
#!/bin/bash
# This script downloads the latest release version of EAS CLI from GitHub and
# installs it in /usr/local/bin
{
# All code is inside a block to ensure the script executes only when
# downloaded completely.
set -euo pipefail
prefix="${EAS_PREFIX:-/usr/local}"
temp_dir="$(mktemp -d)"
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]
then
# Use colors when stdout is a terminal and NO_COLOR isn't set.
text_bold=$'\033[1m'
text_normal=$'\033[22m'
text_red=$'\033[31m'
text_default_color=$'\033[39m'
else
# Disable colors
text_bold=""
text_normal=""
text_red=""
text_default_color=""
fi
abort() {
echo -e "$text_bold$text_red$1$text_normal$text_default_color"
exit 1
}
install() {
needs_sudo=""
# Make sure path exists and can be written to by the current user.
if ! mkdir -p "$prefix/lib" || [[ ! -w "$prefix/lib" ]]
then
needs_sudo=true
fi
if ! mkdir -p "$prefix/bin" || [[ ! -w "$prefix/bin" ]]
then
needs_sudo=true
fi
if ! rm -rf "$prefix/lib/eas"
then
needs_sudo=true
fi
# Run commands with sudo if necessary.
if [ -n "$needs_sudo" ]
then
if [ "$EUID" -ne 0 ]
then
echo "Installing requires superuser access."
echo "The sudo command will prompt for your password."
fi
sudo rm -rf "$prefix/lib/eas"
sudo mv "$temp_dir/eas" "$prefix/lib/eas"
sudo ln -fs "$prefix/lib/eas/bin/eas" "$prefix/bin/eas"
else
mv "$temp_dir/eas" "$prefix/lib/eas"
ln -fs "$prefix/lib/eas/bin/eas" "$prefix/bin/eas"
fi
}
if [[ ! ":$PATH:" == *":$prefix/bin:"* ]]; then
abort "Your path is missing $prefix/bin, you need to add this to \$PATH use this installer."
fi
hardware="$(uname -m)"
case "$hardware" in
x86_64)
arch="x64"
;;
arm*)
arch="arm"
;;
*)
abort "Unsupported CPU architecture $hardware."
;;
esac
case "$(uname -s)" in
Darwin*)
platform="darwin"
if [[ "$arch" != "x64" ]]; then
abort "Unsupported CPU architecture $hardware on macOS."
fi
;;
Linux*)
platform="linux"
;;
*)
abort "This installer is supported only on macOS, Linux, and Windows Subsystem for Linux."
;;
esac
url="https://github.com/expo/eas-cli/releases/latest/download/eas-$platform-$arch.tar.gz"
echo "Installing EAS CLI from $url"
mkdir -p "$temp_dir"
cd "$temp_dir"
echo
echo "Downloading..."
if [ "$(command -v curl)" ]
then
curl --location --progress-bar "$url" | tar xz
else
wget -O- "$url" | tar xz
fi
# Move the installation over and link the executable (may require sudo)
install
# Test the bundled node binary and if it doesn't work remove it (fall back to
# node installed in path).
"$prefix/lib/eas/bin/node" -v > /dev/null || {
rm "$prefix/lib/eas/bin/node"
}
# Test the CLI
executable=$(command -v eas) || abort "Could not run eas after installation."
echo
echo -e "EAS CLI is installed to ${executable}"
eas --version
echo
echo -e "Type ${text_bold}eas --help${text_normal} to get started."
}
+18
View File
@@ -0,0 +1,18 @@
comment: false
coverage:
round: up
precision: 2
range: 30..100
status:
patch: false
project:
default:
informational: true
eas-cli:
informational: true
paths:
- packages/eas-cli
eas-json:
informational: true
paths:
- packages/eas-json
+1 -1
View File
@@ -1,7 +1,7 @@
module.exports = {
projects: [
require('./packages/eas-cli/jest.config.js'),
require('./packages/config/jest.config.js'),
require('./packages/eas-json/jest.config.js'),
],
testPathIgnorePatterns: ['.*'],
};
+1
View File
@@ -5,4 +5,5 @@ module.exports = {
'^.+\\.[jt]sx?$': ['babel-jest', { configFile: require.resolve('./babel.config.js') }],
},
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
};
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.0.0"
"version": "0.1.0"
}
+4
View File
@@ -4,11 +4,15 @@
"packages/*"
],
"scripts": {
"build": "lerna run build",
"build:tarballs:linux": "yarn build && yarn workspace eas-cli oclif-dev pack --targets linux-x64",
"eas": "packages/eas-cli/bin/run",
"lint": "eslint . --ext .ts",
"release": "lerna version",
"test": "jest"
},
"devDependencies": {
"@expo/oclif-dev-cli": "1.24.0-expo",
"@types/jest": "^26.0.13",
"eslint": "^6.6.0",
"eslint-config-universe": "^4.0.0",
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
rules: {
'import/no-cycle': 'error',
'import/no-extraneous-dependencies': ['error', { devDependencies: [
'**/__tests__/**/*',
'**/__mocks__/**/*'
] }],
},
};
+209 -63
View File
@@ -8,68 +8,193 @@ EAS command line tool
[![License](https://img.shields.io/npm/l/eas-cli.svg)](https://github.com/expo/eas-cli/blob/main/package.json)
<!-- toc -->
- [eas-cli](#eas-cli)
- [Usage](#usage)
- [Commands](#commands)
* [eas-cli](#eas-cli)
* [Usage](#usage)
* [Commands](#commands)
<!-- tocstop -->
# Usage
<!-- usage -->
```sh-session
$ npm install -g eas-cli
$ eas COMMAND
running command...
$ eas (-v|--version|version)
eas-cli/0.0.0 darwin-x64 node-v12.13.0
eas-cli/0.1.0 darwin-x64 node-v14.15.0
$ eas --help [COMMAND]
USAGE
$ eas COMMAND
...
```
<!-- usagestop -->
# Commands
<!-- commands -->
* [`eas account:login`](#eas-accountlogin)
* [`eas account:logout`](#eas-accountlogout)
* [`eas account:view`](#eas-accountview)
* [`eas build`](#eas-build)
* [`eas build:configure`](#eas-buildconfigure)
* [`eas build:list`](#eas-buildlist)
* [`eas build:view [BUILDID]`](#eas-buildview-buildid)
* [`eas credentials`](#eas-credentials)
* [`eas device:create`](#eas-devicecreate)
* [`eas device:list`](#eas-devicelist)
* [`eas device:view [UDID]`](#eas-deviceview-udid)
* [`eas help [COMMAND]`](#eas-help-command)
* [`eas submit --platform=(android|ios)`](#eas-submit---platformandroidios)
- [`eas build`](#eas-build)
- [`eas build:status`](#eas-buildstatus)
- [`eas help [COMMAND]`](#eas-help-command)
- [`eas login`](#eas-login)
- [`eas logout`](#eas-logout)
- [`eas update`](#eas-update)
- [`eas update:show`](#eas-updateshow)
- [`eas whoami`](#eas-whoami)
## `eas account:login`
log in with your Expo account
```
USAGE
$ eas account:login
ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/account/login.ts)_
## `eas account:logout`
log out
```
USAGE
$ eas account:logout
ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/account/logout.ts)_
## `eas account:view`
show the username you are logged in as
```
USAGE
$ eas account:view
ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/account/view.ts)_
## `eas build`
build an app binary for your project
Start a build
```
USAGE
$ eas build
OPTIONS
-p, --platform=(android|ios|all)
--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
--wait Wait for build(s) to complete
```
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/build/index.ts)_
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/build/index.ts)_
## `eas build:status`
## `eas build:configure`
get the status of the latest builds for your project
Configure the project to support EAS Build.
```
USAGE
$ eas build:status
$ eas build:configure
OPTIONS
-p, --platform=(android|ios|all) Platform to configure
--allow-experimental Enable experimental configuration steps.
```
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/build/configure.ts)_
## `eas build:list`
list all builds for your project
```
USAGE
$ eas build:list
OPTIONS
--limit=limit
--platform=(all|android|ios)
--status=(in-queue|in-progress|errored|finished)
```
_See code: [src/commands/build/status.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/build/status.ts)_
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/build/list.ts)_
## `eas build:view [BUILDID]`
view a build for your project
```
USAGE
$ eas build:view [BUILDID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/build/view.ts)_
## `eas credentials`
Manage your credentials
```
USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/credentials.ts)_
## `eas device:create`
register new Apple Devices to use for internal distribution
```
USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/device/create.ts)_
## `eas device:list`
list all registered devices for your account
```
USAGE
$ eas device:list
OPTIONS
--apple-team-id=apple-team-id
```
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/device/list.ts)_
## `eas device:view [UDID]`
view a device for your project
```
USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -88,62 +213,83 @@ OPTIONS
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
## `eas login`
## `eas submit --platform=(android|ios)`
log in with your EAS account
Submits build artifact to app store
```
USAGE
$ eas login
```
$ eas submit --platform=(android|ios)
_See code: [src/commands/login.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/login.ts)_
OPTIONS
-p, --platform=(android|ios) For which platform you want to submit a build
## `eas logout`
Android specific options=android-package Android package name (default: expo.android.package
from app config)
log out
iOS specific options=app-name The name of your app as it will appear on the App
Store (default: expo.name from app config)
```
USAGE
$ eas logout
```
iOS specific options=apple-id Your Apple ID username (you can also set
EXPO_APPLE_ID env variable)
_See code: [src/commands/logout.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/logout.ts)_
iOS specific options=apple-team-id Your Apple Developer Team ID
## `eas update`
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
create a revision for given channel
iOS specific options=bundle-identifier Your iOS Bundle Identifier (default:
expo.ios.bundleIdentifier from app config)
```
USAGE
$ eas update
iOS specific options=company-name The name of your company, needed only for the first
upload of any app to App Store
--id=id ID of the build to submit
Android specific options=key Path to the JSON key used to authenticate with
Google Play
iOS specific options=language [default: en-US] Primary language (e.g. English,
German, ...)
--latest Submit the latest build for specified platform
--path=path Path to the .apk/.aab file
Android specific options=(completed|draft|halted|inProgress) [default: completed] Release status (used when
uploading new APKs/AABs)
iOS specific options=sku An unique ID for your app that is not visible on
the App Store, will be generated unless provided
Android specific options=(production|beta|alpha|internal|rollout) [default: internal] The track of the application to
use
Android specific options=(apk|aab) Android archive type
--url=url App archive url
--verbose Always print logs from Submission Service
ALIASES
$ eas update:publish
$ eas build:submit
EXAMPLES
$ eas submit --platform=ios
- Fully interactive iOS submission
$ eas submit --platform=android
- Fully interactive Android submission
$ eas submit -p android --latest --key=/path/to/google-services.json
- Minimal non-interactive Android submission, however it can ask you for other params if not specified
$ EXPO_APPLE_APP_SPECIFIC_PASSWORD=xxx eas submit -p ios --latest [email protected] --asc-app-id=1234567890,
- Minimal non-interactive iOS submission, assuming you already have an app in App Store Connect
and provide its App ID
```
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/update/index.ts)_
## `eas update:show`
details about a particular revision
```
USAGE
$ eas update:show
```
_See code: [src/commands/update/show.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/update/show.ts)_
## `eas whoami`
show the username you are logged in as
```
USAGE
$ eas whoami
```
_See code: [src/commands/whoami.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/whoami.ts)_
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/submit.ts)_
<!-- commandsstop -->
+1 -1
View File
@@ -1,3 +1,3 @@
const getConfig = jest.fn();
const getConfig = jest.fn(() => ({ exp: {} }));
export { getConfig };
+6
View File
@@ -0,0 +1,6 @@
export default jest.fn().mockReturnValue({
start: jest.fn().mockReturnValue({
fail: jest.fn(),
succeed: jest.fn(),
}),
});
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
OUTFILE="src/graphql/generated.ts"
content="/**
* This file was generated using GraphQL Codegen
* Command: yarn generate-graphql-code
* Run this during development for automatic type generation when editing GraphQL documents
* For more info and docs, visit https://graphql-code-generator.com/
*/
"
printf '%s\n%s\n' "$content" "$(cat $OUTFILE)" > $OUTFILE
+12
View File
@@ -0,0 +1,12 @@
overwrite: true
schema: 'https://staging.exp.host/--/graphql'
documents:
- 'src/graphql/**/!(*.d).{ts,tsx}'
generates:
src/graphql/generated.ts:
plugins:
- 'typescript'
- 'typescript-operations'
hooks:
afterOneFileWrite:
- ./annotate-graphql-codegen.sh
+60 -15
View File
@@ -1,39 +1,56 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.0.0",
"version": "0.1.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@eas/config": "0.0.1",
"@expo/config": "^3.3.9",
"@expo/apple-utils": "0.0.0-alpha.16",
"@expo/config": "~3.3.19",
"@expo/config-plugins": "~1.0.9",
"@expo/eas-build-job": "0.1.4",
"@expo/eas-json": "^0.1.0-alpha.20",
"@expo/json-file": "^8.2.24",
"@expo/plist": "^0.0.10",
"@expo/plugin-warn-if-update-available": "^1.7.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "^1.5.0",
"@hapi/joi": "^17.1.1",
"@oclif/command": "^1",
"@oclif/config": "^1",
"@oclif/plugin-help": "^3",
"@urql/core": "^1.13.0",
"chalk": "^4.1.0",
"cli-table3": "^0.6.0",
"dateformat": "^3.0.3",
"env-paths": "^2.2.0",
"fast-glob": "^3.2.4",
"figures": "^3.2.0",
"form-data": "^3.0.0",
"fs-extra": "^9.0.1",
"getenv": "^1.0.0",
"got": "^11.5.2",
"graphql": "^15.3.0",
"graphql-tag": "^2.11.0",
"indent-string": "^4.0.0",
"keychain": "^1.3.0",
"lodash": "^4.17.20",
"md5-file": "^5.0.0",
"minimatch": "^3.0.4",
"node-fetch": "^2.6.1",
"node-forge": "^0.10.0",
"nullthrows": "^1.1.1",
"ora": "^5.1.0",
"pkg-dir": "^4.2.0",
"progress": "^2.0.3",
"prompts": "^2.3.2",
"qrcode-terminal": "^0.12.0",
"strip-ansi": "^6.0.0",
"tar": "^6.0.5",
"tempy": "^0.7.0",
"tslib": "^1",
"untildify": "^4.0.0",
"uuid": "^8.3.0",
@@ -44,31 +61,32 @@
"@babel/preset-env": "^7.11.5",
"@babel/preset-typescript": "^7.10.4",
"@expo/babel-preset-cli": "^0.2.17",
"@expo/oclif-dev-cli": "1.23.0-expo",
"@graphql-codegen/cli": "^1.19.2",
"@graphql-codegen/typescript": "^1.17.11",
"@graphql-codegen/typescript-operations": "^1.17.10",
"@types/color-string": "^1.5.0",
"@types/dateformat": "^3.0.1",
"@types/form-data": "^2.5.0",
"@types/fs-extra": "^9.0.1",
"@types/getenv": "^1.0.0",
"@types/lodash": "^4.14.161",
"@types/node": "^12",
"@types/node-fetch": "^2.5.7",
"@types/node-forge": "^0.9.5",
"@types/progress": "^2.0.3",
"@types/prompts": "^2.0.8",
"@types/prompts": "^2.0.9",
"@types/tar": "^4.0.3",
"@types/tough-cookie": "^4.0.0",
"@types/uuid": "^8.3.0",
"@types/wordwrap": "^1.0.0",
"@vercel/ncc": "^0.23.0",
"axios": "^0.21.0",
"babel-jest": "^26.3.0",
"graphql": "^15.3.0",
"memfs": "^3.2.0",
"tempy": "^0.7.0",
"ts-node": "^8",
"typescript": "^3.9.7",
"wonka": "^4.0.14"
},
"optionalDependencies": {
"@expo/traveling-fastlane-darwin": "1.15.1",
"@expo/traveling-fastlane-linux": "1.15.1"
},
"engines": {
"node": ">=8.0.0"
},
@@ -89,26 +107,53 @@
"bin": "eas",
"commands": "./build/commands",
"plugins": [
"@oclif/plugin-help"
"@oclif/plugin-help",
"@expo/plugin-warn-if-update-available"
],
"helpClass": "./build/help",
"topics": {
"build": {
"description": "build app binaries"
},
"channel": {
"description": "manage update channels"
},
"release": {
"description": "manage update releases"
},
"update": {
"description": "roll out changes"
"description": "manage individual updates"
},
"webhook": {
"description": "manage webhooks"
}
},
"warn-if-update-available": {
"timeoutInDays": 0.5,
"message": "<%= chalk.bold('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available, please upgrade.') %>\n<%= chalk.dim('Proceeding with outdated version') %>\n"
},
"update": {
"node": {
"version": "12.13.0"
},
"s3": {
"templates": {
"target": {
"unversioned": "<%- bin %>-<%- platform %>-<%- arch %><%- ext %>",
"versioned": "<%- bin %>-<%- platform %>-<%- arch %><%- ext %>"
}
}
}
}
},
"repository": "expo/eas-cli",
"scripts": {
"postpack": "rm -f oclif.manifest.json",
"prepack": "rm -rf build && tsc -b && oclif-dev manifest && oclif-dev readme",
"prepack": "rm -rf build && yarn build && yarn oclif-dev manifest && yarn oclif-dev readme",
"build": "tsc",
"test": "jest",
"version": "oclif-dev readme && git add README.md"
"version": "yarn oclif-dev readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml"
},
"types": "build/index.d.ts"
}
@@ -0,0 +1,62 @@
import { Actor } from '../user/User';
interface MockProject {
projectRoot: string;
projectTree: Record<string, string>;
appJSON: AppJSON;
packageJSON: PackageJSON;
}
interface PackageJSON {
name: string;
version: string;
description: string;
main: string;
}
interface AppJSON {
expo: {
name: string;
version: string;
slug: string;
sdkVersion: string;
owner?: string;
[key: string]: any;
};
}
function createTestProject(
user: Actor,
appJSONExtraData?: Record<string, object | string>
): MockProject {
const projectRoot = '/test-project';
const packageJSON: PackageJSON = {
name: 'testing123',
version: '0.1.0',
description: 'fake description',
main: 'index.js',
};
const owner = appJSONExtraData?.owner as string | undefined;
const appJSON: AppJSON = {
expo: {
name: 'testing 123',
version: '0.1.0',
slug: 'testing-123',
sdkVersion: '33.0.0',
owner: owner ?? (user.__typename === 'User' ? user.username : undefined),
...appJSONExtraData,
},
};
return {
appJSON,
packageJSON,
projectRoot,
projectTree: {
[projectRoot + '/package.json']: JSON.stringify(packageJSON, null, 2),
[projectRoot + '/app.json']: JSON.stringify(appJSON, null, 2),
},
};
}
export { createTestProject };
+12 -37
View File
@@ -1,16 +1,6 @@
import { createClient as createUrqlClient } from '@urql/core';
import got, { NormalizedOptions } from 'got';
import fetch from 'node-fetch';
import { getAccessToken, getSessionSecret } from './user/User';
type AccessTokenHeaders = {
authorization: string;
};
type SessionHeaders = {
'expo-session': string;
};
import { getAccessToken, getSessionSecret } from './user/sessionStorage';
export const apiClient = got.extend({
prefixUrl: getExpoApiBaseUrl() + '/--/api/v2/',
@@ -31,37 +21,22 @@ export const apiClient = got.extend({
},
});
export const graphqlClient = createUrqlClient({
url: getExpoApiBaseUrl() + '/--/graphql',
// @ts-expect-error Type 'typeof fetch' is not assignable to type '(input: RequestInfo, init?: RequestInit | undefined) => Promise<Response>'.
fetch,
fetchOptions: (): { headers?: AccessTokenHeaders | SessionHeaders } => {
const token = getAccessToken();
if (token) {
return {
headers: {
authorization: `Bearer ${token}`,
},
};
}
const sessionSecret = getSessionSecret();
if (sessionSecret) {
return {
headers: {
'expo-session': sessionSecret,
},
};
}
return {};
},
});
export function getExpoApiBaseUrl(): string {
if (process.env.EXPO_STAGING) {
return `https://staging.exp.host`;
} else if (process.env.EXPO_LOCAL) {
return `http://exp.test`;
return `http://127.0.0.1:3000`;
} else {
return `https://exp.host`;
}
}
export function getExpoWebsiteBaseUrl(): string {
if (process.env.EXPO_STAGING) {
return `https://staging.expo.io`;
} else if (process.env.EXPO_LOCAL) {
return `http://expo.test`;
} else {
return `https://expo.io`;
}
}
@@ -0,0 +1,294 @@
import { Workflow } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import prompts from 'prompts';
import { asMock } from '../../__tests__/utils';
import { CredentialsProvider } from '../../credentials/CredentialsProvider';
import { ensureCredentialsAsync } from '../credentials';
jest.mock('prompts');
function createMockCredentialsProvider({
hasRemote,
hasLocal,
isLocalSynced,
}: any): CredentialsProvider {
return {
platform: 'android',
hasRemoteAsync: jest.fn().mockImplementation(() => hasRemote || false),
hasLocalAsync: jest.fn().mockImplementation(() => hasLocal || false),
isLocalSyncedAsync: jest.fn().mockImplementation(() => isLocalSynced || false),
} as CredentialsProvider;
}
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
});
beforeEach(() => {
asMock(prompts).mockReset();
asMock(prompts).mockImplementation(() => {
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
});
});
describe(ensureCredentialsAsync, () => {
describe('for generic builds', () => {
it("should use credentials.json if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(src).toBe('local');
});
it("should use credentials from server if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(src).toBe('remote');
});
it('should use credentials.json if credentials are the same in credentials.json and on server', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
it('should ask which credentials to use when local and remote crednitals have different values', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { select: 'local' };
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(1);
expect(src).toBe('local');
});
it('should ask when local and remote are not the same (select remote)', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { select: 'remote' };
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(1);
expect(src).toBe('remote');
});
it('should should throw an error when local and remote are not the same (in non interactive mode)', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
try {
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, true);
throw new Error('ensureCredentialsAsync should throw an Error');
} catch (e) {
expect(e.message).toMatch(
'Contents of your local credentials.json for Android are not the same as credentials on Expo servers'
);
}
expect(prompts).toHaveBeenCalledTimes(0);
});
it('should ask when no credentials are present and return remote if confirm=true', async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: false,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { value: true }; //confirm
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(1);
expect(src).toBe('remote');
});
it('should ask when no credentials are present and abort if confimr=false', async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: false,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { value: false }; //confirm
});
try {
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, false);
throw new Error('ensureCredentialsAsync should throw an Error');
} catch (e) {
expect(e.message).toMatch(
'Aborting build process, credentials are not configured for Android'
);
}
expect(prompts).toHaveBeenCalledTimes(1);
});
it('should throw an error when no credentials are present in non interactive mode', async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: false,
isLocalSynced: false,
});
asMock(prompts).mockImplementation(() => {
return { value: true }; // confirm
});
try {
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, true);
throw new Error('ensureCredentialsAsync should throw an Error');
} catch (e) {
expect(e.message).toMatch('Credentials for this app are not configured');
}
expect(prompts).toHaveBeenCalledTimes(0);
});
});
describe('for managed builds', () => {
it("should use credentials.json if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
CredentialsSource.AUTO,
false
);
expect(src).toBe('local');
});
it("should use credentials from server if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
CredentialsSource.AUTO,
false
);
expect(src).toBe('remote');
});
it('should use credentials.json if credentials are the same in credentials.json and on server', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
it('should use credentials.json even if credentials on server are different', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
});
describe('generic builds with credentialsSource set to local', () => {
it('should use credentials.json without asking even if credentials on server are different', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.LOCAL,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
});
describe('generic builds with credentialsSource set to remote', () => {
it('should use credentials from server even if credentials.json exists and have different values', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.REMOTE,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('remote');
});
});
});
@@ -0,0 +1,93 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import fs from 'fs-extra';
import log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { ensureValidVersions } from '../utils/updates';
export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
const buildGradleContent = await fs.readFile(buildGradlePath, 'utf8');
if (!AndroidConfig.Updates.isBuildGradleConfigured(buildGradleContent, projectDir, exp)) {
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir, exp);
await fs.writeFile(
buildGradlePath,
`${buildGradleContent}\n// Integration with Expo updates\n${gradleScriptApply}\n`
);
}
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
androidManifestPath
);
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
const result = await AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, result);
}
}
export async function syncUpdatesConfigurationAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
try {
await ensureUpdatesConfiguredAsync(projectDir, exp);
} catch (error) {
log.error(
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
);
throw error;
}
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
let androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(androidManifestPath);
if (!AndroidConfig.Updates.areVersionsSynced(exp, androidManifest)) {
androidManifest = AndroidConfig.Updates.setVersionsConfig(exp, androidManifest);
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, androidManifest);
}
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
log.warn(
'Native project configuration is not synced with values present in your app.json, run "eas build:configure" to make sure all values are applied in the native project'
);
}
}
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
const buildGradleContent = await fs.readFile(buildGradlePath, 'utf8');
if (!AndroidConfig.Updates.isBuildGradleConfigured(buildGradleContent, projectDir, exp)) {
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir, exp);
throw new Error(`Missing ${gradleScriptApply} in ${buildGradlePath}`);
}
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
androidManifestPath
);
if (!AndroidConfig.Updates.isMainApplicationMetaDataSet(androidManifest)) {
throw new Error('Missing values in AndroidManifest.xml');
}
}
@@ -0,0 +1,187 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import assert from 'assert';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import log from '../../log';
import {
ensureAppIdentifierIsDefinedAsync,
getAndroidApplicationIdAsync,
getProjectConfigDescription,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { gitAddAsync } from '../../utils/git';
import { Platform } from '../types';
enum ApplicationIdSource {
AndroidProject,
AppJson,
}
function isApplicationIdValid(applicationId: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(applicationId);
}
export async function ensureApplicationIdIsValidAsync(projectDir: string) {
const applicationId = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.Android);
if (!isApplicationIdValid(applicationId)) {
const configDescription = getProjectConfigDescription(projectDir);
log.error(
`Invalid format of Android applicationId. Only alphanumeric characters, '.' and '_' are allowed, and each '.' must be followed by a letter.`
);
log.error(`Update "android.package" in ${configDescription} and run this command again.`);
throw new Error('Invalid applicationId');
}
}
export async function configureApplicationIdAsync(
projectDir: string,
exp: ExpoConfig,
allowExperimental: boolean
): Promise<void> {
const configDescription = getProjectConfigDescription(projectDir);
const applicationIdFromConfig = AndroidConfig.Package.getPackage(exp);
const applicationIdFromAndroidProject = await getAndroidApplicationIdAsync(projectDir);
if (applicationIdFromAndroidProject && applicationIdFromConfig) {
if (applicationIdFromConfig !== applicationIdFromAndroidProject) {
log.newLine();
log.warn(
`We detected that your Android project is configured with a different application id than the one defined in ${configDescription}.`
);
const hasApplicationIdInStaticConfig = await hasApplicationIdInStaticConfigAsync(
projectDir,
exp
);
if (!hasApplicationIdInStaticConfig) {
log(`If you choose the one defined in ${configDescription} we'll automatically configure your Android project with it.
However, if you choose the one defined in the Android project you'll have to update ${configDescription} on your own.`);
}
log.newLine();
const { applicationIdSource } = await promptAsync({
type: 'select',
name: 'applicationIdSource',
message: 'Which application id should we use?',
choices: [
{
title: `${chalk.bold(
applicationIdFromAndroidProject
)} - In build.gradle in Android project`,
value: ApplicationIdSource.AndroidProject,
},
{
title: `${chalk.bold(applicationIdFromConfig)} - In your ${configDescription}`,
value: ApplicationIdSource.AppJson,
},
],
});
switch (applicationIdSource) {
case ApplicationIdSource.AppJson: {
await updateApplicationIdInBuildGradleAsync(projectDir, exp);
break;
}
case ApplicationIdSource.AndroidProject: {
if (hasApplicationIdInStaticConfig) {
await updateAppJsonConfigAsync(projectDir, exp, applicationIdFromAndroidProject);
} else {
throw new Error(missingPackageMessage(configDescription));
}
break;
}
}
}
} else if (!applicationIdFromAndroidProject && !applicationIdFromConfig) {
throw new Error(missingPackageMessage(configDescription));
} else if (applicationIdFromAndroidProject && !applicationIdFromConfig) {
if (getConfigFilePaths(projectDir).staticConfigPath) {
await updateAppJsonConfigAsync(projectDir, exp, applicationIdFromAndroidProject);
} else {
throw new Error(missingPackageMessage(configDescription));
}
} else if (!applicationIdFromAndroidProject && applicationIdFromConfig) {
// This should never happen, adding warning just in case
log.warn(
'applicationId is not specified in your ./android/app/build.gradle file. Make sure your project is configured correctly before building.'
);
return;
}
if (allowExperimental) {
// this step is optional, the Play store is using applicationId value from build.gradle
// to identify the app, so package name does not need to be updated
await updatePackageNameAsync(projectDir, exp);
await gitAddAsync(path.join(projectDir, 'android'), { intentToAdd: true });
}
}
function missingPackageMessage(configDescription: string): string {
return `Please define "android.package" in ${configDescription} and run "eas build:configure" again.`;
}
async function updateAppJsonConfigAsync(
projectDir: string,
exp: ExpoConfig,
newApplicationId: string
): Promise<void> {
const paths = getConfigFilePaths(projectDir);
assert(paths.staticConfigPath, "can't update dynamic configs");
const rawStaticConfig = await fs.readJSON(paths.staticConfigPath);
rawStaticConfig.expo = {
...rawStaticConfig.expo,
android: { ...rawStaticConfig.expo?.android, package: newApplicationId },
};
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
exp.android = { ...exp.android, package: newApplicationId };
}
/**
* Check if static config exists and if expo.android.package is defined there.
* It will return false if the value in static config is different than "android.package" in ExpoConfig
*/
async function hasApplicationIdInStaticConfigAsync(
projectDir: string,
exp: ExpoConfig
): Promise<boolean> {
if (!exp.android?.package) {
return false;
}
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath) {
return false;
}
const rawStaticConfig = await fs.readJson(paths.staticConfigPath);
return rawStaticConfig?.expo?.android?.package === exp.android.package;
}
async function updateApplicationIdInBuildGradleAsync(projectDir: string, exp: ExpoConfig) {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
const updatedBuildGradleContents = AndroidConfig.Package.setPackageInBuildGradle(
exp,
buildGradleContents
);
await fs.writeFile(buildGradlePath, updatedBuildGradleContents);
}
async function updatePackageNameAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
await AndroidConfig.Package.renamePackageOnDisk(exp, projectDir);
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
androidManifestPath
);
const updatedAndroidManifest = AndroidConfig.Package.setPackageInAndroidManifest(
exp,
androidManifest
);
await AndroidConfig.Manifest.writeAndroidManifestAsync(
androidManifestPath,
updatedAndroidManifest
);
}
+113
View File
@@ -0,0 +1,113 @@
import { Workflow } from '@expo/eas-build-job';
import { EasConfig } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import AndroidCredentialsProvider, {
AndroidCredentials,
} from '../../credentials/android/AndroidCredentialsProvider';
import { createCredentialsContextAsync } from '../../credentials/context';
import log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
import { CredentialsResult, prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
import { ensureCredentialsAsync } from '../credentials';
import { Platform } from '../types';
import { ensureApplicationIdIsValidAsync } from './applicationId';
import { validateAndSyncProjectConfigurationAsync } from './configure';
import { prepareJobAsync } from './prepareJob';
export async function prepareAndroidBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig
): Promise<() => Promise<string>> {
const buildCtx = createBuildContext<Platform.Android>({
commandCtx,
platform: Platform.Android,
easConfig,
});
const { buildProfile } = buildCtx;
if (
buildProfile.workflow === Workflow.Generic &&
!(await fs.pathExists(path.join(commandCtx.projectDir, 'android')))
) {
throw new Error(
`"android" directory not found. If you're trying to build a managed project, set ${chalk.bold(
`builds.android.${commandCtx.profile}.workflow`
)} in "eas.json" to "managed".`
);
}
if (
buildProfile.workflow === Workflow.Generic &&
buildProfile.distribution === 'internal' &&
buildProfile.gradleCommand?.match(/bundle/)
) {
log.addNewLineIfNone();
log.warn(
`You're building your Android app for internal distribution. However, we've detected that the Gradle command you defined (${chalk.underline(
buildProfile.gradleCommand
)}) includes string 'bundle'.
This means that it will most likely produce an AAB and you will not be able to install it on your Android devices straight from the Expo website.`
);
log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Would you like to proceed?' });
if (!confirmed) {
log.error('Please update eas.json and come back again.');
process.exit(1);
}
}
await ensureApplicationIdIsValidAsync(commandCtx.projectDir);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {},
ensureCredentialsAsync: ensureAndroidCredentialsAsync,
ensureProjectConfiguredAsync: async () => {
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
await validateAndSyncProjectConfigurationAsync(commandCtx.projectDir, commandCtx.exp);
}
},
prepareJobAsync,
});
}
function shouldProvideCredentials(ctx: BuildContext<Platform.Android>): boolean {
return (
ctx.buildProfile.workflow === Workflow.Managed ||
(ctx.buildProfile.workflow === Workflow.Generic && !ctx.buildProfile.withoutCredentials)
);
}
async function ensureAndroidCredentialsAsync(
ctx: BuildContext<Platform.Android>
): Promise<CredentialsResult<AndroidCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
}
const provider = new AndroidCredentialsProvider(
await createCredentialsContextAsync(ctx.commandCtx.projectDir, {
nonInteractive: ctx.commandCtx.nonInteractive,
}),
{
app: {
projectName: ctx.commandCtx.projectName,
accountName: ctx.commandCtx.accountName,
},
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
}
);
const credentialsSource = await ensureCredentialsAsync(
provider,
ctx.buildProfile.workflow,
ctx.buildProfile.credentialsSource,
ctx.commandCtx.nonInteractive
);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
source: credentialsSource,
};
}
@@ -0,0 +1,50 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import log from '../../log';
import { getAndroidApplicationIdAsync } from '../../project/projectUtils';
import { gitAddAsync } from '../../utils/git';
import { ConfigureContext } from '../context';
import { isExpoUpdatesInstalled } from '../utils/updates';
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
import { configureApplicationIdAsync, ensureApplicationIdIsValidAsync } from './applicationId';
export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasAndroidNativeProject) {
return;
}
await AndroidConfig.EasBuild.configureEasBuildAsync(ctx.projectDir);
await configureApplicationIdAsync(ctx.projectDir, ctx.exp, ctx.allowExperimental);
await ensureApplicationIdIsValidAsync(ctx.projectDir);
const easGradlePath = AndroidConfig.EasBuild.getEasBuildGradlePath(ctx.projectDir);
await gitAddAsync(easGradlePath, { intentToAdd: true });
if (isExpoUpdatesInstalled(ctx.projectDir)) {
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
}
log.withTick('Successfully configured the Android project.');
}
export async function validateAndSyncProjectConfigurationAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
const applicationIdFromAndroidProject = await getAndroidApplicationIdAsync(projectDir);
const packageNameFromConfig = AndroidConfig.Package.getPackage(exp);
const isApplicationIdConfigured =
applicationIdFromAndroidProject && applicationIdFromAndroidProject === packageNameFromConfig;
const isEasBuildGradleConfigured = await AndroidConfig.EasBuild.isEasBuildGradleConfiguredAsync(
projectDir
);
if (!isEasBuildGradleConfigured || !isApplicationIdConfigured) {
throw new Error(
'Project is not configured. Please run "eas build:configure" to configure the project.'
);
}
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
}
}
@@ -0,0 +1,113 @@
import {
Android,
ArchiveSource,
ArchiveSourceType,
Job,
Workflow,
sanitizeJob,
} from '@expo/eas-build-job';
import { AndroidGenericBuildProfile, AndroidManagedBuildProfile } from '@expo/eas-json';
import path from 'path';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { readSecretEnvsAsync } from '../../credentials/credentialsJson/read';
import { getProjectAccountNameAsync } from '../../project/projectUtils';
import { gitRootDirectoryAsync } from '../../utils/git';
import { BuildContext } from '../context';
import { Platform } from '../types';
interface JobData {
archiveBucketKey: string;
credentials?: AndroidCredentials;
}
export async function prepareJobAsync(
ctx: BuildContext<Platform.Android>,
jobData: JobData
): Promise<Job> {
if (ctx.buildProfile.workflow === Workflow.Generic) {
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else {
throw new Error("Unknown workflow. Shouldn't happen");
}
}
interface CommonJobProperties {
platform: Platform.Android;
projectArchive: ArchiveSource;
secrets: {
buildCredentials?: {
keystore: Android.Keystore;
};
secretEnvs?: Record<string, string>;
};
}
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.Android>,
jobData: JobData
): Promise<Partial<CommonJobProperties>> {
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
const credentials = jobData.credentials;
const buildCredentials = credentials
? {
buildCredentials: {
keystore: {
dataBase64: credentials.keystore.keystore,
keystorePassword: credentials.keystore.keystorePassword,
keyAlias: credentials.keystore.keyAlias,
keyPassword: credentials.keystore.keyPassword,
},
},
}
: {};
return {
platform: Platform.Android,
projectArchive: {
type: ArchiveSourceType.S3,
bucketKey: jobData.archiveBucketKey,
},
secrets: {
...(secretEnvs ? { secretEnvs } : {}),
...buildCredentials,
},
};
}
async function prepareGenericJobAsync(
ctx: BuildContext<Platform.Android>,
jobData: JobData,
buildProfile: AndroidGenericBuildProfile
): Promise<Partial<Android.GenericJob>> {
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.Generic,
gradleCommand: buildProfile.gradleCommand,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
};
}
async function prepareManagedJobAsync(
ctx: BuildContext<Platform.Android>,
jobData: JobData,
buildProfile: AndroidManagedBuildProfile
): Promise<Partial<Android.ManagedJob>> {
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.Managed,
username: accountName,
buildType: buildProfile.buildType,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
};
}
+220
View File
@@ -0,0 +1,220 @@
import { Job } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import fs from 'fs-extra';
import { apiClient } from '../api';
import log from '../log';
import { promptAsync } from '../prompts';
import { UploadType, uploadAsync } from '../uploads';
import { createProgressTracker } from '../utils/progress';
import { platformDisplayNames } from './constants';
import { BuildContext } from './context';
import { collectMetadata } from './metadata';
import { AnalyticsEvent, Platform, TrackingContext } from './types';
import Analytics from './utils/analytics';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
commitPromptAsync,
isGitStatusCleanAsync,
makeProjectTarballAsync,
showDiffAsync,
} from './utils/repository';
export interface CredentialsResult<Credentials> {
source: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
credentials: Credentials;
}
interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration> {
ctx: BuildContext<TPlatform>;
projectConfiguration: ProjectConfiguration;
ensureCredentialsAsync(
ctx: BuildContext<TPlatform>
): Promise<CredentialsResult<Credentials> | undefined>;
ensureProjectConfiguredAsync(ctx: BuildContext<TPlatform>): Promise<void>;
prepareJobAsync(
ctx: BuildContext<TPlatform>,
jobData: {
archiveBucketKey: string;
credentials?: Credentials;
projectConfiguration?: ProjectConfiguration;
}
): Promise<Job>;
}
export async function prepareBuildRequestForPlatformAsync<
TPlatform extends Platform,
Credentials,
ProjectConfiguration
>(builder: Builder<TPlatform, Credentials, ProjectConfiguration>): Promise<() => Promise<string>> {
const credentialsResult = await withAnalyticsAsync(
async () => await builder.ensureCredentialsAsync(builder.ctx),
{
successEvent: AnalyticsEvent.GATHER_CREDENTIALS_SUCCESS,
failureEvent: AnalyticsEvent.GATHER_CREDENTIALS_FAIL,
trackingCtx: builder.ctx.trackingCtx,
}
);
if (!builder.ctx.commandCtx.skipProjectConfiguration) {
await withAnalyticsAsync(async () => await builder.ensureProjectConfiguredAsync(builder.ctx), {
successEvent: AnalyticsEvent.CONFIGURE_PROJECT_SUCCESS,
failureEvent: AnalyticsEvent.CONFIGURE_PROJECT_FAIL,
trackingCtx: builder.ctx.trackingCtx,
});
}
if (!(await isGitStatusCleanAsync())) {
log.addNewLineIfNone();
const projectType = builder.ctx.platform === Platform.Android ? 'Android' : 'Xcode';
// Currently we are only updaing runtime version durring build, but if it changes in a future
// this message should also contain more info on that (or be more generic)
await reviewAndCommitChangesAsync(`Update runtime version in the ${projectType} project`, {
nonInteractive: builder.ctx.commandCtx.nonInteractive,
});
}
const archiveBucketKey = await uploadProjectAsync(builder.ctx);
const metadata = await collectMetadata(builder.ctx, {
credentialsSource: credentialsResult?.source,
});
const job = await builder.prepareJobAsync(builder.ctx, {
archiveBucketKey,
credentials: credentialsResult?.credentials,
projectConfiguration: builder.projectConfiguration,
});
return async () => {
try {
return await withAnalyticsAsync(
async () => {
if (log.isDebug) {
log(`Starting ${platformDisplayNames[job.platform]} build`);
}
const {
data: { buildId, deprecationInfo },
} = await apiClient
.post(`projects/${builder.ctx.commandCtx.projectId}/builds`, {
json: {
job,
metadata,
},
})
.json();
printDeprecationWarnings(deprecationInfo);
return buildId;
},
{
successEvent: AnalyticsEvent.BUILD_REQUEST_SUCCESS,
failureEvent: AnalyticsEvent.BUILD_REQUEST_FAIL,
trackingCtx: builder.ctx.trackingCtx,
}
);
} catch (error) {
if (error.code === 'TURTLE_DEPRECATED_JOB_FORMAT') {
log.error('EAS Build API changed, upgrade to latest expo-cli');
}
throw error;
}
};
}
async function uploadProjectAsync<TPlatform extends Platform>(
ctx: BuildContext<TPlatform>
): Promise<string> {
let projectTarballPath;
try {
return await withAnalyticsAsync(
async () => {
const projectTarball = await makeProjectTarballAsync();
projectTarballPath = projectTarball.path;
const { bucketKey } = await uploadAsync(
UploadType.TURTLE_PROJECT_SOURCES,
projectTarball.path,
createProgressTracker({
total: projectTarball.size,
message: 'Uploading to EAS Build',
completedMessage: `Uploaded to EAS`,
})
);
return bucketKey;
},
{
successEvent: AnalyticsEvent.PROJECT_UPLOAD_SUCCESS,
failureEvent: AnalyticsEvent.PROJECT_UPLOAD_FAIL,
trackingCtx: ctx.trackingCtx,
}
);
} finally {
if (projectTarballPath) {
await fs.remove(projectTarballPath);
}
}
}
async function withAnalyticsAsync<Result>(
fn: () => Promise<Result>,
analytics: {
successEvent: AnalyticsEvent;
failureEvent: AnalyticsEvent;
trackingCtx: TrackingContext;
}
): Promise<Result> {
try {
const result = await fn();
Analytics.logEvent(analytics.successEvent, analytics.trackingCtx);
return result;
} catch (error) {
Analytics.logEvent(analytics.failureEvent, {
...analytics.trackingCtx,
reason: error.message,
});
throw error;
}
}
enum ShouldCommitChanges {
Yes,
ShowDiffFirst,
Abort,
}
async function reviewAndCommitChangesAsync(
commitMessage: string,
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
): Promise<void> {
if (nonInteractive) {
throw new Error(
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode to review and commit changes.'
);
}
const { selected } = await promptAsync({
type: 'select',
name: 'selected',
message: 'Can we commit these changes to git for you?',
choices: [
{ title: 'Yes', value: ShouldCommitChanges.Yes },
...(askedFirstTime
? [{ title: 'Show the diff and ask me again', value: ShouldCommitChanges.ShowDiffFirst }]
: []),
{
title: 'Abort build process',
value: ShouldCommitChanges.Abort,
},
],
});
if (selected === ShouldCommitChanges.Abort) {
throw new Error(
"Aborting, run the command again once you're ready. Make sure to commit any changes you've made."
);
} else if (selected === ShouldCommitChanges.Yes) {
await commitPromptAsync(commitMessage);
log.withTick('Successfully committed changes.');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await showDiffAsync();
await reviewAndCommitChangesAsync(commitMessage, { nonInteractive, askedFirstTime: false });
}
}
+161
View File
@@ -0,0 +1,161 @@
import { getConfig } from '@expo/config';
import { EasJsonReader } from '@expo/eas-json';
import fs from 'fs-extra';
import path from 'path';
import log from '../log';
import { promptAsync } from '../prompts';
import { ensureLoggedInAsync } from '../user/actions';
import { gitAddAsync } from '../utils/git';
import { configureAndroidAsync } from './android/configure';
import { ConfigureContext } from './context';
import { configureIosAsync } from './ios/configure';
import { RequestedPlatform } from './types';
import {
commitPromptAsync,
ensureGitRepoExistsAsync,
isGitStatusCleanAsync,
maybeBailOnGitStatusAsync,
showDiffAsync,
} from './utils/repository';
const configureCommitMessage = {
[RequestedPlatform.Android]: 'Configure EAS Build for Android',
[RequestedPlatform.iOS]: 'Configure EAS Build for iOS',
[RequestedPlatform.All]: 'Configure EAS Build',
};
export async function configureAsync(options: {
platform: RequestedPlatform;
projectDir: string;
allowExperimental: boolean;
}): Promise<void> {
await ensureGitRepoExistsAsync();
await maybeBailOnGitStatusAsync();
const { exp } = getConfig(options.projectDir, { skipSDKVersionRequirement: true });
const ctx: ConfigureContext = {
user: await ensureLoggedInAsync(),
projectDir: options.projectDir,
exp,
allowExperimental: options.allowExperimental,
requestedPlatform: options.platform,
shouldConfigureAndroid: [RequestedPlatform.All, RequestedPlatform.Android].includes(
options.platform
),
shouldConfigureIos: [RequestedPlatform.All, RequestedPlatform.iOS].includes(options.platform),
hasAndroidNativeProject: await fs.pathExists(path.join(options.projectDir, 'android')),
hasIosNativeProject: await fs.pathExists(path.join(options.projectDir, 'ios')),
};
log.newLine();
await ensureEasJsonExistsAsync(ctx);
if (ctx.shouldConfigureAndroid) {
await configureAndroidAsync(ctx);
}
if (ctx.shouldConfigureIos) {
await configureIosAsync(ctx);
}
if (!(await isGitStatusCleanAsync())) {
log.newLine();
await reviewAndCommitChangesAsync(configureCommitMessage[options.platform]);
} else {
log.newLine();
log.withTick('No changes were necessary, the project is already configured correctly.');
}
}
export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<void> {
const easJsonPath = path.join(ctx.projectDir, 'eas.json');
let existingEasJson;
if (await fs.pathExists(easJsonPath)) {
const reader = new EasJsonReader(ctx.projectDir, ctx.requestedPlatform);
await reader.validateAsync();
existingEasJson = await reader.readRawAsync();
log.withTick('Validated eas.json.');
// If we have already populated eas.json with the default fields for the
// platform then proceed
if (ctx.requestedPlatform !== 'all' && existingEasJson.builds[ctx.requestedPlatform]) {
return;
} else if (
ctx.requestedPlatform === 'all' &&
existingEasJson.builds.ios &&
existingEasJson.builds.android
) {
return;
}
}
const shouldInitIOS =
['all', 'ios'].includes(ctx.requestedPlatform) && !existingEasJson?.builds.ios;
const shouldInitAndroid =
['all', 'android'].includes(ctx.requestedPlatform) && !existingEasJson?.builds.android;
const easJson = {
builds: {
...existingEasJson,
...(shouldInitAndroid
? {
android: {
release: {
workflow: ctx.hasAndroidNativeProject ? 'generic' : 'managed',
},
},
}
: null),
...(shouldInitIOS
? {
ios: {
release: {
workflow: ctx.hasIosNativeProject ? 'generic' : 'managed',
},
},
}
: null),
},
};
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
await gitAddAsync(easJsonPath, { intentToAdd: true });
log.withTick(`${existingEasJson ? 'Updated' : 'Generated'} eas.json.`);
}
enum ShouldCommitChanges {
Yes,
ShowDiffFirst,
Skip,
}
async function reviewAndCommitChangesAsync(
commitMessage: string,
askedFirstTime: boolean = true
): Promise<void> {
const { selected } = await promptAsync({
type: 'select',
name: 'selected',
message: 'Can we commit these changes to git for you?',
choices: [
{ title: 'Yes', value: ShouldCommitChanges.Yes },
...(askedFirstTime
? [{ title: 'Show the diff and ask me again', value: ShouldCommitChanges.ShowDiffFirst }]
: []),
{
title: 'Skip committing changes, I will do it later on my own',
value: ShouldCommitChanges.Skip,
},
],
});
if (selected === ShouldCommitChanges.Yes) {
await commitPromptAsync(commitMessage);
log.withTick('Committed changes.');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await showDiffAsync();
await reviewAndCommitChangesAsync(commitMessage, false);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Platform, RequestedPlatform } from './types';
export const platformDisplayNames = {
[RequestedPlatform.iOS]: 'iOS',
[RequestedPlatform.Android]: 'Android',
[RequestedPlatform.All]: 'Android and iOS',
};
export const platformEmojis = {
[Platform.iOS]: '🍎',
[Platform.Android]: '🤖',
};
+114
View File
@@ -0,0 +1,114 @@
import { ExpoConfig, getConfig } from '@expo/config';
import { AndroidBuildProfile, EasConfig, iOSBuildProfile } from '@expo/eas-json';
import { getProjectAccountName } from '../project/projectUtils';
import { Actor } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { Platform, RequestedPlatform, TrackingContext } from './types';
export interface CommandContext {
requestedPlatform: RequestedPlatform;
profile: string;
projectDir: string;
projectId: string;
user: Actor;
accountName: string;
projectName: string;
exp: ExpoConfig;
trackingCtx: TrackingContext;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
}
export async function createCommandContextAsync({
requestedPlatform,
profile,
projectDir,
projectId,
trackingCtx,
nonInteractive = false,
skipCredentialsCheck = false,
skipProjectConfiguration = false,
waitForBuildEnd,
}: {
requestedPlatform: RequestedPlatform;
profile: string;
projectId: string;
projectDir: string;
trackingCtx: TrackingContext;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
}): Promise<CommandContext> {
const user = await ensureLoggedInAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = getProjectAccountName(exp, user);
const projectName = exp.slug;
return {
requestedPlatform,
profile,
projectDir,
projectId,
user,
accountName,
projectName,
exp,
trackingCtx,
nonInteractive,
skipCredentialsCheck,
skipProjectConfiguration,
waitForBuildEnd,
};
}
export interface ConfigureContext {
user: Actor;
projectDir: string;
exp: ExpoConfig;
allowExperimental: boolean;
requestedPlatform: RequestedPlatform;
shouldConfigureAndroid: boolean;
shouldConfigureIos: boolean;
hasAndroidNativeProject: boolean;
hasIosNativeProject: boolean;
}
type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
? AndroidBuildProfile
: iOSBuildProfile;
export interface BuildContext<T extends Platform> {
commandCtx: CommandContext;
trackingCtx: TrackingContext;
platform: T;
buildProfile: PlatformBuildProfile<T>;
}
export function createBuildContext<T extends Platform>({
platform,
easConfig,
commandCtx,
}: {
platform: T;
easConfig: EasConfig;
commandCtx: CommandContext;
}): BuildContext<T> {
const buildProfile = easConfig.builds[platform] as PlatformBuildProfile<T> | undefined;
if (!buildProfile) {
throw new Error(`${platform} build profile does not exist`);
}
const builderTrackingCtx = {
...commandCtx.trackingCtx,
platform,
};
return {
commandCtx,
trackingCtx: builderTrackingCtx,
platform,
buildProfile,
};
}
+143
View File
@@ -0,0 +1,143 @@
import { EasJsonReader } from '@expo/eas-json';
import chalk from 'chalk';
import ora from 'ora';
import { apiClient } from '../api';
import log from '../log';
import { sleep } from '../utils/promise';
import { prepareAndroidBuildAsync } from './android/build';
import { CommandContext } from './context';
import { prepareIosBuildAsync } from './ios/build';
import { Build, BuildStatus, Platform, RequestedPlatform } from './types';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureGitRepoExistsAsync, ensureGitStatusIsCleanAsync } from './utils/repository';
export async function buildAsync(commandCtx: CommandContext): Promise<void> {
await ensureGitRepoExistsAsync();
await ensureGitStatusIsCleanAsync(commandCtx.nonInteractive);
const scheduledBuilds = await startBuildsAsync(commandCtx);
log.newLine();
printLogsUrls(commandCtx.accountName, scheduledBuilds);
log.newLine();
if (commandCtx.waitForBuildEnd) {
const builds = await waitForBuildEndAsync(
commandCtx,
scheduledBuilds.map(i => i.buildId)
);
log.newLine();
printBuildResults(commandCtx.accountName, builds);
}
}
async function startBuildsAsync(
commandCtx: CommandContext
): Promise<{ platform: Platform; buildId: string }[]> {
const shouldBuildAndroid = [RequestedPlatform.Android, RequestedPlatform.All].includes(
commandCtx.requestedPlatform
);
const shouldBuildiOS = [RequestedPlatform.iOS, RequestedPlatform.All].includes(
commandCtx.requestedPlatform
);
const easConfig = await new EasJsonReader(
commandCtx.projectDir,
commandCtx.requestedPlatform
).readAsync(commandCtx.profile);
const builds: {
platform: Platform;
sendBuildRequestAsync: () => Promise<string>;
}[] = [];
if (shouldBuildAndroid) {
const sendBuildRequestAsync = await prepareAndroidBuildAsync(commandCtx, easConfig);
builds.push({ platform: Platform.Android, sendBuildRequestAsync });
}
if (shouldBuildiOS) {
const sendBuildRequestAsync = await prepareIosBuildAsync(commandCtx, easConfig);
builds.push({ platform: Platform.iOS, sendBuildRequestAsync });
}
return Promise.all(
builds.map(async ({ platform, sendBuildRequestAsync }) => ({
platform,
buildId: await sendBuildRequestAsync(),
}))
);
}
async function waitForBuildEndAsync(
commandCtx: CommandContext,
buildIds: string[],
{ timeoutSec = 1800, intervalSec = 30 } = {}
): Promise<(Build | null)[]> {
log('Waiting for build to complete. You can press Ctrl+C to exit.');
const spinner = ora().start();
let time = new Date().getTime();
const endTime = time + timeoutSec * 1000;
while (time <= endTime) {
const builds: (Build | null)[] = await Promise.all(
buildIds.map(async buildId => {
try {
const { data } = await apiClient
.get(`projects/${commandCtx.projectId}/builds/${buildId}`)
.json();
return data;
} catch (err) {
return null;
}
})
);
if (builds.length === 1) {
switch (builds[0]?.status) {
case BuildStatus.FINISHED:
spinner.succeed('Build finished');
return builds;
case BuildStatus.IN_QUEUE:
spinner.text = 'Build queued...';
break;
case BuildStatus.IN_PROGRESS:
spinner.text = 'Build in progress...';
break;
case BuildStatus.ERRORED:
spinner.fail('Build failed');
throw new Error(`Standalone build failed!`);
default:
spinner.warn('Unknown status.');
throw new Error(`Unknown status: ${builds} - aborting!`);
}
} else {
if (builds.filter(build => build?.status === BuildStatus.FINISHED).length === builds.length) {
spinner.succeed('All builds have finished');
return builds;
} else if (
builds.filter(build =>
build?.status ? [BuildStatus.FINISHED, BuildStatus.ERRORED].includes(build.status) : false
).length === builds.length
) {
spinner.fail('Some of the builds failed');
return builds;
} else {
const inQueue = builds.filter(build => build?.status === BuildStatus.IN_QUEUE).length;
const inProgress = builds.filter(build => build?.status === BuildStatus.IN_PROGRESS).length;
const errored = builds.filter(build => build?.status === BuildStatus.ERRORED).length;
const finished = builds.filter(build => build?.status === BuildStatus.FINISHED).length;
const unknownState = builds.length - inQueue - inProgress - errored - finished;
spinner.text = [
inQueue && `Builds in queue: ${inQueue}`,
inProgress && `Builds in progress: ${inProgress}`,
errored && chalk.red(`Builds failed: ${errored}`),
finished && chalk.green(`Builds finished: ${finished}`),
unknownState && chalk.red(`Builds in unknown state: ${unknownState}`),
]
.filter(i => i)
.join('\t');
}
}
time = new Date().getTime();
await sleep(intervalSec * 1000);
}
spinner.warn('Timed out');
throw new Error(
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
);
}
+100
View File
@@ -0,0 +1,100 @@
import { Workflow } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import chalk from 'chalk';
import { CredentialsProvider } from '../credentials/CredentialsProvider';
import log from '../log';
import { confirmAsync, promptAsync } from '../prompts';
import { platformDisplayNames } from './constants';
const USING_CREDENTIALS_JSON_MSG = 'Using credentials from the local credentials.json file';
const USING_REMOTE_CREDENTIALS_MSG = 'Using credentials stored on the Expo servers';
async function ensureCredentialsAutoAsync(
provider: CredentialsProvider,
workflow: Workflow,
nonInteractive: boolean
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
const platform = platformDisplayNames[provider.platform];
switch (workflow) {
case Workflow.Managed:
if (await provider.hasLocalAsync()) {
return CredentialsSource.LOCAL;
} else {
return CredentialsSource.REMOTE;
}
case Workflow.Generic: {
const hasLocal = await provider.hasLocalAsync();
const hasRemote = await provider.hasRemoteAsync();
if (hasRemote && hasLocal) {
if (!(await provider.isLocalSyncedAsync())) {
if (nonInteractive) {
throw new Error(
`Contents of your local credentials.json for ${platform} are not the same as credentials on Expo servers. To use the desired credentials, set the "builds.${platform}.{profile}.credentialsSource" field in the credentials.json file to one of the following: "local", "remote".`
);
} else {
log(
`Contents of your local credentials.json for ${platform} are not the same as credentials on Expo servers`
);
}
const { select } = await promptAsync({
type: 'select',
name: 'select',
message: 'Which credentials you want to use for this build?',
choices: [
{ title: 'Local credentials.json', value: CredentialsSource.LOCAL },
{ title: 'Credentials stored on Expo servers.', value: CredentialsSource.REMOTE },
],
});
return select;
} else {
return CredentialsSource.LOCAL;
}
} else if (hasLocal) {
log(chalk.bold(USING_CREDENTIALS_JSON_MSG));
return CredentialsSource.LOCAL;
} else if (hasRemote) {
log(chalk.bold(USING_REMOTE_CREDENTIALS_MSG));
return CredentialsSource.REMOTE;
} else {
if (nonInteractive) {
throw new Error(
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}. Either configure credentials.json, or launch the build without "--non-interactive" flag to get a prompt to generate credentials automatically.`
);
} else if (log.isDebug) {
log.warn(
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}`
);
}
const confirm = await confirmAsync({
message: 'Do you want to generate new credentials?',
});
if (confirm) {
return CredentialsSource.REMOTE;
} else {
throw new Error(`Aborting build process, credentials are not configured for ${platform}`);
}
}
}
}
}
export async function ensureCredentialsAsync(
provider: CredentialsProvider,
workflow: Workflow,
src: CredentialsSource,
nonInteractive: boolean
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
switch (src) {
case CredentialsSource.LOCAL:
log(chalk.bold(USING_CREDENTIALS_JSON_MSG));
return CredentialsSource.LOCAL;
case CredentialsSource.REMOTE:
log(chalk.bold(USING_REMOTE_CREDENTIALS_MSG));
return CredentialsSource.REMOTE;
case CredentialsSource.AUTO:
return await ensureCredentialsAutoAsync(provider, workflow, nonInteractive);
}
}
@@ -0,0 +1,99 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import plist from '@expo/plist';
import fs from 'fs-extra';
import path from 'path';
import log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { gitAddAsync } from '../../utils/git';
import { ensureValidVersions } from '../utils/updates';
export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
let xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, exp, xcodeProject)) {
xcodeProject = IOSConfig.Updates.ensureBundleReactNativePhaseContainsConfigurationScript(
projectDir,
exp,
xcodeProject
);
await fs.writeFile(IOSConfig.Paths.getPBXProjectPath(projectDir), xcodeProject.writeSync());
}
let expoPlist = await readExpoPlistAsync(projectDir);
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
expoPlist = IOSConfig.Updates.setUpdatesConfig(exp, expoPlist, accountName);
await writeExpoPlistAsync(projectDir, expoPlist);
}
// TODO: ensure ExpoPlist in pbxproj
}
export async function syncUpdatesConfigurationAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
try {
await ensureUpdatesConfiguredAsync(projectDir, exp);
} catch (error) {
log.error(
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
);
throw error;
}
let expoPlist = await readExpoPlistAsync(projectDir);
if (!IOSConfig.Updates.isPlistVersionConfigurationSynced(exp, expoPlist)) {
expoPlist = IOSConfig.Updates.setVersionsConfig(exp, expoPlist);
await writeExpoPlistAsync(projectDir, expoPlist);
}
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
log.warn(
'Native project configuration is not synced with values present in you app.json, run "eas build:configure" to make sure all values are applied in the native project'
);
}
}
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
const xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, exp, xcodeProject)) {
const script = 'expo-updates/scripts/create-manifest-ios.sh';
const buildPhase = '"Bundle React Native code and images"';
throw new Error(`Path to ${script} is missing in a ${buildPhase} build phase.`);
}
const expoPlist = await readExpoPlistAsync(projectDir);
if (!IOSConfig.Updates.isPlistConfigurationSet(expoPlist)) {
throw new Error('Missing values in Expo.plist');
}
}
async function readExpoPlistAsync(projectDir: string): Promise<IOSConfig.ExpoPlist> {
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
let expoPlist = {};
if (await fs.pathExists(expoPlistPath)) {
const expoPlistContent = await fs.readFile(expoPlistPath, 'utf8');
expoPlist = plist.parse(expoPlistContent);
}
return expoPlist;
}
async function writeExpoPlistAsync(
projectDir: string,
expoPlist: IOSConfig.ExpoPlist
): Promise<void> {
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
const expoPlistContent = plist.build(expoPlist);
await fs.mkdirp(path.dirname(expoPlistPath));
await fs.writeFile(expoPlistPath, expoPlistContent);
await gitAddAsync(expoPlistPath, { intentToAdd: true });
}
+93
View File
@@ -0,0 +1,93 @@
import { IOSConfig } from '@expo/config-plugins';
import { 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 log from '../../log';
import { promptAsync } from '../../prompts';
import { prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
import { Platform } from '../types';
import { ensureBundleIdentifierIsValidAsync } from './bundleIdentifer';
import { validateAndSyncProjectConfigurationAsync } from './configure';
import { ensureIosCredentialsAsync } from './credentials';
import { prepareJobAsync } from './prepareJob';
export async function prepareIosBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig
): Promise<() => Promise<string>> {
const buildCtx = createBuildContext<Platform.iOS>({
commandCtx,
platform: Platform.iOS,
easConfig,
});
if (
buildCtx.buildProfile.workflow === Workflow.Generic &&
!(await fs.pathExists(path.join(commandCtx.projectDir, 'ios')))
) {
throw new Error(
`"ios" directory not found. If you're trying to build a managed project, set ${chalk.bold(
`builds.ios.${commandCtx.profile}.workflow`
)} in "eas.json" to "managed".`
);
}
let iosNativeProjectScheme: string | undefined;
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
iosNativeProjectScheme = buildCtx.buildProfile.scheme ?? (await resolveSchemeAsync(buildCtx));
}
await ensureBundleIdentifierIsValidAsync(commandCtx.projectDir);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {
iosNativeProjectScheme,
},
ensureCredentialsAsync: ensureIosCredentialsAsync,
ensureProjectConfiguredAsync: async () => {
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
await validateAndSyncProjectConfigurationAsync(commandCtx.projectDir, commandCtx.exp);
}
},
prepareJobAsync,
});
}
async function resolveSchemeAsync(ctx: BuildContext<Platform.iOS>): Promise<string> {
const schemes = IOSConfig.Scheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
if (schemes.length === 1) {
return schemes[0];
}
const sortedSchemes = sortBy(schemes);
log.newLine();
log(
`We've found multiple schemes in your Xcode project: ${chalk.bold(sortedSchemes.join(', '))}`
);
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(`You've run Expo CLI in non-interactive mode, choosing the ${chalk.bold(scheme)} scheme.`);
log.newLine();
return scheme;
} else {
const { selectedScheme } = await promptAsync({
type: 'select',
name: 'selectedScheme',
message: 'Which scheme would you like to build now?',
choices: sortedSchemes.map(scheme => ({ title: scheme, value: scheme })),
});
log.newLine();
return selectedScheme as string;
}
}
@@ -0,0 +1,148 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import assert from 'assert';
import chalk from 'chalk';
import fs from 'fs-extra';
import log from '../../log';
import {
ensureAppIdentifierIsDefinedAsync,
getProjectConfigDescription,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { Platform } from '../types';
enum BundleIdentiferSource {
XcodeProject,
AppJson,
}
function isBundleIdentifierValid(bundleIdentifier: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(bundleIdentifier);
}
export async function ensureBundleIdentifierIsValidAsync(projectDir: string) {
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.iOS);
if (!isBundleIdentifierValid(bundleIdentifier)) {
const configDescription = getProjectConfigDescription(projectDir);
log.error(
`Invalid format of iOS bundleId. Only alphanumeric characters, '.' and '-' are allowed, and each '.' must be followed by a letter.`
);
log.error(`Update "ios.bundleIdentifier" in ${configDescription} and run this command again.`);
throw new Error('Invalid bundleIdentifier');
}
}
export async function configureBundleIdentifierAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
const configDescription = getProjectConfigDescription(projectDir);
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdenitifer.getBundleIdentifierFromPbxproj(
projectDir
);
const bundleIdentifierFromConfig = IOSConfig.BundleIdenitifer.getBundleIdentifier(exp);
if (bundleIdentifierFromPbxproj && bundleIdentifierFromConfig) {
if (bundleIdentifierFromPbxproj !== bundleIdentifierFromConfig) {
log.addNewLineIfNone();
log.warn(
`We detected that your Xcode project is configured with a different bundle identifier than the one defined in ${configDescription}.`
);
const hasBundleIdentifierInStaticConfig = await hasBundleIdentifierInStaticConfigAsync(
projectDir,
exp
);
if (!hasBundleIdentifierInStaticConfig) {
log(`If you choose the one defined in ${configDescription} we'll automatically configure your Xcode project with it.
However, if you choose the one defined in the Xcode project you'll have to update ${configDescription} on your own.`);
}
log.newLine();
const { bundleIdentifierSource } = await promptAsync({
type: 'select',
name: 'bundleIdentifierSource',
message: 'Which bundle identifier should we use?',
choices: [
{
title: `${chalk.bold(bundleIdentifierFromPbxproj)} - In Xcode project`,
value: BundleIdentiferSource.XcodeProject,
},
{
title: `${chalk.bold(bundleIdentifierFromConfig)} - In your ${configDescription}`,
value: BundleIdentiferSource.AppJson,
},
],
});
switch (bundleIdentifierSource) {
case BundleIdentiferSource.AppJson: {
IOSConfig.BundleIdenitifer.setBundleIdentifierForPbxproj(
projectDir,
bundleIdentifierFromConfig
);
break;
}
case BundleIdentiferSource.XcodeProject: {
if (hasBundleIdentifierInStaticConfig) {
await updateAppJsonConfigAsync(projectDir, exp, bundleIdentifierFromPbxproj);
} else {
throw new Error(missingBundleIdentifierMessage(configDescription));
}
break;
}
}
}
} else if (!bundleIdentifierFromPbxproj && !bundleIdentifierFromConfig) {
throw new Error(missingBundleIdentifierMessage(configDescription));
} else if (bundleIdentifierFromPbxproj && !bundleIdentifierFromConfig) {
if (getConfigFilePaths(projectDir).staticConfigPath) {
await updateAppJsonConfigAsync(projectDir, exp, bundleIdentifierFromPbxproj);
} else {
throw new Error(missingBundleIdentifierMessage(configDescription));
}
} else if (!bundleIdentifierFromPbxproj && bundleIdentifierFromConfig) {
IOSConfig.BundleIdenitifer.setBundleIdentifierForPbxproj(
projectDir,
bundleIdentifierFromConfig
);
}
}
function missingBundleIdentifierMessage(configDescription: string): string {
return `Please define "ios.bundleIdentifier" in ${configDescription} and run "eas build:configure" again.`;
}
async function updateAppJsonConfigAsync(
projectDir: string,
exp: ExpoConfig,
newBundleIdentifier: string
): Promise<void> {
const paths = getConfigFilePaths(projectDir);
assert(paths.staticConfigPath, "can't update dynamic configs");
const rawStaticConfig = await fs.readJSON(paths.staticConfigPath);
rawStaticConfig.expo = {
...rawStaticConfig.expo,
ios: { ...rawStaticConfig.expo?.ios, bundleIdentifier: newBundleIdentifier },
};
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
exp.ios = { ...exp.ios, bundleIdentifier: newBundleIdentifier };
}
/**
* Check if static config exists and if ios.bundleIdentifier is defined there.
* It will return false if the value in static config is different than "ios.bundleIdentifier" in ExpoConfig
*/
async function hasBundleIdentifierInStaticConfigAsync(
projectDir: string,
exp: ExpoConfig
): Promise<boolean> {
if (!exp.ios?.bundleIdentifier) {
return false;
}
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath) {
return false;
}
const rawStaticConfig = await fs.readJson(paths.staticConfigPath);
return rawStaticConfig?.expo?.ios?.bundleIdentifier === exp.ios.bundleIdentifier;
}
@@ -0,0 +1,41 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import log from '../../log';
import { ConfigureContext } from '../context';
import { isExpoUpdatesInstalled } from '../utils/updates';
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
import {
configureBundleIdentifierAsync,
ensureBundleIdentifierIsValidAsync,
} from './bundleIdentifer';
export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasIosNativeProject) {
return;
}
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
await ensureBundleIdentifierIsValidAsync(ctx.projectDir);
if (isExpoUpdatesInstalled(ctx.projectDir)) {
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
}
log.withTick('Successfully configured the iOS project.');
}
export async function validateAndSyncProjectConfigurationAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdenitifer.getBundleIdentifierFromPbxproj(
projectDir
);
if (!bundleIdentifierFromPbxproj || bundleIdentifierFromPbxproj !== exp.ios?.bundleIdentifier) {
throw new Error(
'Bundle identifier is not configured correctly in your Xcode project. Please run "eas build:configure" to configure it.'
);
}
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
}
}
@@ -0,0 +1,73 @@
import { Workflow } from '@expo/eas-build-job';
import { CredentialsSource, DistributionType } from '@expo/eas-json';
import assert from 'assert';
import { createCredentialsContextAsync } from '../../credentials/context';
import IosCredentialsProvider, {
IosCredentials,
} from '../../credentials/ios/IosCredentialsProvider';
import { AppLookupParams } from '../../credentials/ios/credentials';
import { CredentialsResult } from '../build';
import { BuildContext } from '../context';
import { ensureCredentialsAsync } from '../credentials';
import { Platform } from '../types';
export async function ensureIosCredentialsAsync(
ctx: BuildContext<Platform.iOS>
): Promise<CredentialsResult<IosCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
}
assert(ctx.commandCtx.exp?.ios?.bundleIdentifier, 'ios.bundleIdentifier is required');
return await resolveIosCredentialsAsync(ctx.commandCtx.projectDir, {
app: {
accountName: ctx.commandCtx.accountName,
projectName: ctx.commandCtx.projectName,
bundleIdentifier: ctx.commandCtx.exp.ios.bundleIdentifier,
},
workflow: ctx.buildProfile.workflow,
credentialsSource: ctx.buildProfile.credentialsSource,
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
nonInteractive: ctx.commandCtx.nonInteractive,
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
});
}
interface ResolveCredentialsParams {
app: AppLookupParams;
workflow: Workflow;
credentialsSource: CredentialsSource;
distribution: DistributionType;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
}
export async function resolveIosCredentialsAsync(
projectDir: string,
params: ResolveCredentialsParams
): Promise<CredentialsResult<IosCredentials>> {
const provider = new IosCredentialsProvider(
await createCredentialsContextAsync(projectDir, {
nonInteractive: params.nonInteractive,
}),
{
app: params.app,
distribution: params.distribution,
skipCredentialsCheck: params.skipCredentialsCheck,
}
);
const credentialsSource = await ensureCredentialsAsync(
provider,
params.workflow,
params.credentialsSource,
params.nonInteractive
);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
source: credentialsSource,
};
}
function shouldProvideCredentials(ctx: BuildContext<Platform.iOS>): boolean {
return true;
}
@@ -0,0 +1,118 @@
import {
ArchiveSource,
ArchiveSourceType,
Job,
Workflow,
iOS,
sanitizeJob,
} from '@expo/eas-build-job';
import { iOSGenericBuildProfile, iOSManagedBuildProfile } from '@expo/eas-json';
import path from 'path';
import { readSecretEnvsAsync } from '../../credentials/credentialsJson/read';
import { IosCredentials } from '../../credentials/ios/IosCredentialsProvider';
import { getProjectAccountNameAsync } from '../../project/projectUtils';
import { gitRootDirectoryAsync } from '../../utils/git';
import { BuildContext } from '../context';
import { Platform } from '../types';
interface JobData {
archiveBucketKey: string;
credentials?: IosCredentials;
projectConfiguration: {
iosNativeProjectScheme?: string;
};
}
export async function prepareJobAsync(
ctx: BuildContext<Platform.iOS>,
jobData: JobData
): Promise<Job> {
if (ctx.buildProfile.workflow === Workflow.Generic) {
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else {
throw new Error("Unknown workflow. Shouldn't happen");
}
}
interface CommonJobProperties {
platform: Platform.iOS;
projectArchive: ArchiveSource;
releaseChannel: string;
secrets: {
buildCredentials?: {
provisioningProfileBase64: string;
distributionCertificate: {
dataBase64: string;
password: string;
};
};
secretEnvs?: Record<string, string>;
};
}
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.iOS>,
jobData: JobData
): Promise<Partial<CommonJobProperties>> {
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
const buildCredentials = jobData.credentials
? {
buildCredentials: {
provisioningProfileBase64: jobData.credentials.provisioningProfile,
distributionCertificate: {
dataBase64: jobData.credentials.distributionCertificate.certP12,
password: jobData.credentials.distributionCertificate.certPassword,
},
},
}
: {};
return {
platform: Platform.iOS,
projectArchive: {
type: ArchiveSourceType.S3,
bucketKey: jobData.archiveBucketKey,
},
secrets: {
...(secretEnvs ? { secretEnvs } : {}),
...buildCredentials,
},
};
}
async function prepareGenericJobAsync(
ctx: BuildContext<Platform.iOS>,
jobData: JobData,
buildProfile: iOSGenericBuildProfile
): Promise<Partial<iOS.GenericJob>> {
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.Generic,
scheme: jobData.projectConfiguration.iosNativeProjectScheme,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
};
}
async function prepareManagedJobAsync(
ctx: BuildContext<Platform.iOS>,
jobData: JobData,
buildProfile: iOSManagedBuildProfile
): Promise<Partial<iOS.ManagedJob>> {
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.Managed,
username: accountName,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
};
}
+34
View File
@@ -0,0 +1,34 @@
import { CredentialsSource, DistributionType } from '@expo/eas-json';
import { getAppIdentifierAsync } from '../project/projectUtils';
import { BuildContext } from './context';
import { BuildMetadata, Platform } from './types';
/**
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
* the src directory and would change the directory structure of the emitted files
* under the build directory
*/
const packageJSON = require('../../package.json');
export async function collectMetadata<T extends Platform>(
ctx: BuildContext<T>,
{
credentialsSource,
}: {
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
}
): Promise<BuildMetadata> {
return {
appVersion: ctx.commandCtx.exp.version!,
cliVersion: packageJSON.version,
workflow: ctx.buildProfile.workflow,
credentialsSource,
sdkVersion: ctx.commandCtx.exp.sdkVersion,
trackingContext: ctx.trackingCtx,
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
appName: ctx.commandCtx.exp.name,
appIdentifier:
(await getAppIdentifierAsync(ctx.commandCtx.projectDir, ctx.platform)) ?? undefined,
};
}
+124
View File
@@ -0,0 +1,124 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import {
AndroidBuildProfile,
CredentialsSource,
DistributionType,
iOSBuildProfile,
} from '@expo/eas-json';
export enum RequestedPlatform {
Android = 'android',
iOS = 'ios',
All = 'all',
}
export { Platform };
export enum BuildStatus {
IN_QUEUE = 'in-queue',
IN_PROGRESS = 'in-progress',
ERRORED = 'errored',
FINISHED = 'finished',
}
export type TrackingContext = Record<string, string | number>;
export interface Build {
id: string;
status: BuildStatus;
platform: Platform;
createdAt: string;
updatedAt: string;
artifacts?: BuildArtifacts;
metadata?: Partial<BuildMetadata>;
}
interface BuildArtifacts {
buildUrl?: string;
logsUrl: string;
}
export type BuildMetadata = {
/**
* Application version (the expo.version key in app.json/app.config.js)
*/
appVersion: string;
/**
* EAS CLI version
*/
cliVersion: string;
/**
* Build workflow
* It's either 'generic' or 'managed'
*/
workflow: Workflow;
/**
* Credentials source
* Credentials could be obtained either from credential.json or Expo servers.
*/
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
/**
* Expo SDK version
* It's determined by the expo package version in package.json.
* It's undefined if the expo package is not installed for the project.
*/
sdkVersion?: string;
/**
* Release channel (for expo-updates)
* It's undefined if the expo-updates package is not installed for the project.
*/
releaseChannel?: string;
/**
* Tracking context
* It's used to track build process across different Expo services and tools.
*/
trackingContext: TrackingContext;
/**
* Distribution type
* Indicates whether this is a build for store or internal distribution.
*/
distribution: DistributionType;
/**
* App name (expo.name in app.json/app.config.js)
*/
appName?: string;
/**
* App identifier:
* - iOS builds: the bundle identifier (expo.ios.bundleIdentifier in app.json/app.config.js)
* - Android builds: the application id (expo.android.package in app.json/app.config.js)
*/
appIdentifier?: string;
};
export type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
? AndroidBuildProfile
: iOSBuildProfile;
export enum AnalyticsEvent {
BUILD_COMMAND = 'builds cli build command',
PROJECT_UPLOAD_SUCCESS = 'builds cli project upload success',
PROJECT_UPLOAD_FAIL = 'builds cli project upload fail',
GATHER_CREDENTIALS_SUCCESS = 'builds cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'builds cli gather credentials fail',
CONFIGURE_PROJECT_SUCCESS = 'builds cli configure project success',
CONFIGURE_PROJECT_FAIL = 'builds cli configure project fail',
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'builds cli build request fail',
BUILD_STATUS_COMMAND = 'builds cli build status',
CREDENTIALS_SYNC_COMMAND = 'builds cli credentials sync command',
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'builds cli credentials sync update local success',
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'builds cli credentials sync update local fail',
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'builds cli credentials sync update remote success',
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'builds cli credentials sync update remote fail',
}
@@ -0,0 +1,3 @@
export default {
logEvent: (a: any, b: any) => {},
};
@@ -0,0 +1,67 @@
import chalk from 'chalk';
import formatFields from '../../utils/formatFields';
import { platformDisplayNames } from '../constants';
import { Build, BuildStatus } from '../types';
import { getBuildLogsUrl } from './url';
interface Options {
accountName: string;
}
export default function formatBuild(build: Build, { accountName }: Options) {
const fields: { label: string; value: string }[] = [
{ label: 'ID', value: build.id },
{
label: 'Platform',
value: platformDisplayNames[build.platform],
},
{
label: 'Status',
get value() {
switch (build.status) {
case BuildStatus.IN_PROGRESS:
return chalk.blue('in progress');
case BuildStatus.FINISHED:
return chalk.green('finished');
case BuildStatus.ERRORED:
return chalk.red('errored');
default:
return 'unknown';
}
},
},
{
label: 'Logs',
value: getBuildLogsUrl({ buildId: build.id, account: accountName }),
},
{
label: 'Artifact',
get value() {
if (build.status === BuildStatus.IN_PROGRESS) {
return '<in progress>';
}
const url = build.artifacts?.buildUrl;
if (!url) {
return chalk.red('not found');
}
return url;
},
},
{ label: 'Started at', value: new Date(build.createdAt).toLocaleString() },
{
label: 'Finished at',
value:
build.status === BuildStatus.IN_PROGRESS
? '<in progress>'
: new Date(build.updatedAt).toLocaleString(),
},
// TODO: we can do it once we migrate to GraphQL
// { label: 'Started by', get: build => 'TODO' },
];
return formatFields(fields);
}
@@ -0,0 +1,90 @@
import { DistributionType } from '@expo/eas-json';
import assert from 'assert';
import chalk from 'chalk';
import log from '../../log';
import { platformDisplayNames, platformEmojis } from '../constants';
import { Build } from '../types';
import { getBuildLogsUrl } from './url';
export interface DeprecationInfo {
type: 'user-facing' | 'internal';
message: string;
}
export function printLogsUrls(
accountName: string,
builds: { platform: 'android' | 'ios'; buildId: string }[]
): void {
if (builds.length === 1) {
const { buildId } = builds[0];
const logsUrl = getBuildLogsUrl({
buildId,
account: accountName,
});
log(`Build details: ${chalk.underline(logsUrl)}`);
} else {
builds.forEach(({ buildId, platform }) => {
const logsUrl = getBuildLogsUrl({
buildId,
account: accountName,
});
log(`${platformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`);
});
}
}
export function printBuildResults(accountName: string, builds: (Build | null)[]): void {
if (builds.length === 1) {
const [build] = builds;
assert(build, 'Build should be defined');
printBuildResult(accountName, build);
} else {
(builds.filter(i => i) as Build[])
.filter(build => build.status === 'finished')
.forEach(build => printBuildResult(accountName, build));
}
}
function printBuildResult(accountName: string, build: Build): void {
if (build.metadata?.distribution === DistributionType.INTERNAL) {
const logsUrl = getBuildLogsUrl({
buildId: build.id,
account: accountName,
});
log(
`${platformEmojis[build.platform]} Open this link on your ${
platformDisplayNames[build.platform]
} devices to install the app:`
);
log(`${chalk.underline(logsUrl)}`);
log.newLine();
} else {
// TODO: it looks like buildUrl could possibly be undefined, based on the code below.
// we should account for this case better if it is possible
const url = build.artifacts?.buildUrl ?? '';
log(`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} app:`);
log(`${chalk.underline(url)}`);
log.newLine();
}
}
export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): void {
if (!deprecationInfo) {
return;
}
if (deprecationInfo.type === 'internal') {
log.warn('This command is using API that soon will be deprecated, please update expo-cli.');
log.warn("Changes won't affect your project config.");
log.warn(deprecationInfo.message);
} else if (deprecationInfo.type === 'user-facing') {
log.warn('This command is using API that soon will be deprecated, please update expo-cli.');
log.warn(
'There might be some changes necessary to your project config, latest expo-cli will provide more specific error messages.'
);
log.warn(deprecationInfo.message);
} else {
log.warn('An unexpected warning was encountered. Please report it as a bug:');
log.warn(deprecationInfo);
}
}
@@ -0,0 +1,141 @@
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import fs from 'fs-extra';
import ora from 'ora';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import {
doesGitRepoExistAsync,
getGitDiffOutputAsync,
gitDiffAsync,
gitRootDirectoryAsync,
gitStatusAsync,
isGitInstalledAsync,
} from '../../utils/git';
import { getTmpDirectory } from '../../utils/paths';
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("We're going to make an initial commit for you repository.");
await spawnAsync('git', ['add', '-A']);
await commitPromptAsync('Initial commit');
}
async function isGitStatusCleanAsync(): Promise<boolean> {
const changes = await gitStatusAsync();
return changes.length === 0;
}
async function maybeBailOnGitStatusAsync(): Promise<void> {
if (await isGitStatusCleanAsync()) {
return;
}
log.addNewLineIfNone();
log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
log(
`It's recommended to ${chalk.bold(
'commit all your changes before proceeding'
)}, so you can revert the changes made by this command if necessary.`
);
const answer = await confirmAsync({
message: `Would you like to proceed?`,
});
if (!answer) {
throw new Error('Please commit all changes. Aborting...');
}
}
async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void> {
if (await isGitStatusCleanAsync()) {
return;
}
log.addNewLineIfNone();
log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
log(
`This operation needs to be run on a clean working tree, please ${chalk.bold(
'commit all your changes before proceeding'
)}.`
);
if (nonInteractive) {
throw new Error('Please commit all changes. Aborting...');
}
const answer = await confirmAsync({
message: `Commit changes to git?`,
});
if (answer) {
await commitPromptAsync();
} else {
throw new Error('Please commit all changes. Aborting...');
}
}
async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
const spinner = ora('Preparing project to be uploaded').start();
await fs.mkdirp(getTmpDirectory());
const tarPath = path.join(getTmpDirectory(), `${uuidv4()}.tar.gz`);
await spawnAsync(
'git',
['archive', '--format=tar.gz', '--prefix', 'project/', '-o', tarPath, 'HEAD'],
{ cwd: await gitRootDirectoryAsync() }
);
spinner.succeed('Project ready to be uploaded');
const { size } = await fs.stat(tarPath);
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?: string): Promise<void> {
const { message } = await promptAsync({
type: 'text',
name: 'message',
message: 'Commit message:',
initial: initialCommitMessage,
validate: (input: string) => input !== '',
});
// Add changed files only
await spawnAsync('git', ['add', '-u']);
await spawnAsync('git', ['commit', '-m', message]);
}
export {
isGitStatusCleanAsync,
showDiffAsync,
commitPromptAsync,
ensureGitRepoExistsAsync,
ensureGitStatusIsCleanAsync,
maybeBailOnGitStatusAsync,
makeProjectTarballAsync,
};
@@ -0,0 +1,14 @@
import { ExpoConfig, getPackageJson } from '@expo/config';
export function isExpoUpdatesInstalled(projectDir: string) {
const packageJson = getPackageJson(projectDir);
return packageJson.dependencies && 'expo-updates' in packageJson.dependencies;
}
export function ensureValidVersions(exp: ExpoConfig): void {
if (!exp.runtimeVersion && !exp.sdkVersion) {
throw new Error(
"Couldn't find either 'runtimeVersion' or 'sdkVersion' to configure 'expo-updates'. Please specify at least one of these properties under the 'expo' key in 'app.json'"
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { getExpoWebsiteBaseUrl } from '../../api';
export function getBuildLogsUrl({
buildId,
account,
}: {
buildId: string;
account: string;
}): string {
return `${getExpoWebsiteBaseUrl()}/accounts/${account}/builds/${buildId}`;
}
export function getArtifactUrl(artifactId: string): string {
return `${getExpoWebsiteBaseUrl()}/artifacts/${artifactId}`;
}
@@ -0,0 +1,16 @@
import { Command } from '@oclif/command';
import log from '../../log';
import { showLoginPromptAsync } from '../../user/actions';
export default class AccountLogin extends Command {
static description = 'log in with your Expo account';
static aliases = ['login'];
async run() {
log('Log in to EAS');
await showLoginPromptAsync();
log('Logged in');
}
}
@@ -0,0 +1,15 @@
import { Command } from '@oclif/command';
import log from '../../log';
import { logoutAsync } from '../../user/User';
export default class AccountLogout extends Command {
static description = 'log out';
static aliases = ['logout'];
async run() {
await logoutAsync();
log('Logged out');
}
}
@@ -0,0 +1,22 @@
import { Command } from '@oclif/command';
import chalk from 'chalk';
import log from '../../log';
import { getUserAsync } from '../../user/User';
import { getActorDisplayName } from '../../user/actions';
export default class AccountView extends Command {
static description = 'show the username you are logged in as';
static aliases = ['whoami'];
async run() {
const user = await getUserAsync();
if (user) {
log(chalk.green(getActorDisplayName(user)));
} else {
log.warn('Not logged in');
process.exit(1);
}
}
}
@@ -0,0 +1,100 @@
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import { configureAsync } from '../../build/configure';
import { RequestedPlatform } from '../../build/types';
import log, { learnMore } from '../../log';
import { findProjectRootAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
export default class BuildConfigure extends Command {
static description = 'Configure the project to support EAS Build.';
static flags = {
platform: flags.enum({
description: 'Platform to configure',
char: 'p',
options: ['android', 'ios', 'all'],
}),
'allow-experimental': flags.boolean({
description: 'Enable experimental configuration steps.',
default: false,
}),
};
async run() {
const { flags } = this.parse(BuildConfigure);
log(
'💡 The following process will configure your iOS and/or Android project to be compatible with EAS Build. These changes only apply to your local project files and you can safely revert them at any time.'
);
log.newLine();
const platform =
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
const allowExperimental = flags['allow-experimental'];
if (allowExperimental) {
log.warn(
`Project configuration will execute some additional steps that might fail if structure of your native project is significantly different from ${chalk.bold(
'expo eject'
)} or ${chalk.bold('expo init')}`
);
}
await ensureLoggedInAsync();
await configureAsync({
platform,
allowExperimental,
projectDir: (await findProjectRootAsync()) ?? process.cwd(),
});
log.newLine();
logSuccess(platform);
}
}
function logSuccess(platform: RequestedPlatform) {
let platformsText = 'iOS and Android projects are';
let storesText = 'the Apple App Store or Google Play Store';
if (platform === 'android') {
platformsText = 'Android project is';
storesText = 'the Google Play Store';
} else if (platform === 'ios') {
platformsText = 'iOS project is';
storesText = 'the Apple App Store';
}
log(`🎉 Your ${platformsText} ready to build.
- Run ${chalk.bold('eas build')} when you are ready to create your first build.
- Once the build is completed, run ${chalk.bold('eas submit')} to upload the app to ${storesText}
- ${learnMore(
'https://docs.expo.io/build/introduction',
'Learn more about other capabilities of EAS Build'
)}`);
}
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
const { platform } = await promptAsync({
type: 'select',
message: 'Which platforms would you like to configure for EAS Build?',
name: 'platform',
choices: [
{
title: 'All',
value: RequestedPlatform.All,
},
{
title: 'iOS',
value: RequestedPlatform.iOS,
},
{
title: 'Android',
value: RequestedPlatform.Android,
},
],
});
return platform;
}
+135 -6
View File
@@ -1,11 +1,140 @@
import { Command } from '@oclif/command';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
export default class BuildIndex extends Command {
static description = 'build an app binary for your project';
import { configureAsync } from '../../build/configure';
import { createCommandContextAsync } from '../../build/context';
import { buildAsync } from '../../build/create';
import { AnalyticsEvent, RequestedPlatform } from '../../build/types';
import Analytics from '../../build/utils/analytics';
import { isGitStatusCleanAsync } from '../../build/utils/repository';
import { learnMore } from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
static flags = {};
export default class Build extends Command {
static description = 'Start a build';
async run() {
throw new Error('Not implemented');
static flags = {
platform: flags.enum({ char: 'p', options: ['android', 'ios', 'all'] }),
'skip-credentials-check': flags.boolean({
default: false,
description: 'Skip validation of build credentials',
}),
'skip-project-configuration': flags.boolean({
default: false,
description: 'Skip project configuration',
}),
profile: flags.string({
default: 'release',
description: 'Name of the build profile from eas.json',
}),
'non-interactive': flags.boolean({
default: false,
description: 'Run command in --non-interactive mode',
}),
wait: flags.boolean({
default: true,
description: 'Wait for build(s) to complete',
}),
};
async run(): Promise<void> {
const { flags } = this.parse(Build);
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();
const projectId = await getProjectIdAsync(projectDir);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
await ensureProjectConfiguredAsync(projectDir);
const trackingCtx = {
tracking_id: uuidv4(),
requested_platform: flags.platform,
};
Analytics.logEvent(AnalyticsEvent.BUILD_COMMAND, trackingCtx);
const commandCtx = await createCommandContextAsync({
requestedPlatform: platform,
profile: flags.profile,
projectDir,
projectId,
trackingCtx,
nonInteractive,
skipCredentialsCheck: flags['skip-credentials-check'],
skipProjectConfiguration: flags['skip-project-configuration'],
waitForBuildEnd: flags.wait,
});
await buildAsync(commandCtx);
}
}
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
const { platform } = await promptAsync({
type: 'select',
message: 'Choose the platforms to build',
name: 'platform',
choices: [
{
title: 'All',
value: RequestedPlatform.All,
},
{
title: 'iOS',
value: RequestedPlatform.iOS,
},
{
title: 'Android',
value: RequestedPlatform.Android,
},
],
});
return platform;
}
async function ensureProjectConfiguredAsync(projectDir: string): Promise<void> {
if (await fs.pathExists(path.join(projectDir, 'eas.json'))) {
return;
}
const confirm = await confirmAsync({
message: "This app isn't setup for building with EAS. Set it up now?",
});
if (confirm) {
await configureAsync({
projectDir,
platform: RequestedPlatform.All,
allowExperimental: false,
});
if (!(await isGitStatusCleanAsync())) {
throw new Error(
'Build process requires clean git working tree, please commit all your changes and run `eas build` again'
);
}
} else {
throw new Error(
`Aborting, please run ${chalk.bold('eas build:configure')} or create eas.json (${learnMore(
'https://docs.expo.io/build/eas-json'
)})`
);
}
}
@@ -0,0 +1,68 @@
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import ora from 'ora';
import { apiClient } from '../../api';
import { Build } from '../../build/types';
import formatBuild from '../../build/utils/formatBuild';
import log from '../../log';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
export default class BuildList extends Command {
static description = 'list all builds for your project';
static flags = {
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished'] }),
limit: flags.integer(),
};
async run() {
const { platform, status, limit = 10 } = this.parse(BuildList).flags;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const accountName = await getProjectAccountNameAsync(projectDir);
const projectName = await getProjectFullNameAsync(projectDir);
const spinner = ora().start('Fetching the build list for the project…');
try {
const response = await apiClient.get<{ data: { builds: Build[] } }>(
`projects/${projectId}/builds`,
{
searchParams: { platform, status, limit },
responseType: 'json',
}
);
const { builds } = response.body.data;
if (builds.length) {
if (platform || status) {
spinner.succeed(
`Showing ${builds.length} matching builds for the project ${projectName}`
);
} else {
spinner.succeed(`Showing last ${builds.length} builds for the project ${projectName}`);
}
const list = builds
.map(build => formatBuild(build, { accountName }))
.join(`\n\n${chalk.dim('———')}\n\n`);
log(`\n${list}`);
} else {
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the build list ${projectName}`);
throw e;
}
}
}
@@ -1,15 +0,0 @@
import { Command, flags } from '@oclif/command';
export default class BuildStatus extends Command {
static description = 'get the status of the latest builds for your project';
static flags = {
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished'] }),
};
async run() {
// const { flags } = this.parse(Add);
throw new Error('Not implemented');
}
}
@@ -0,0 +1,86 @@
import { Command } from '@oclif/command';
import { HTTPError } from 'got';
import ora from 'ora';
import { apiClient } from '../../api';
import { Build } from '../../build/types';
import formatBuild from '../../build/utils/formatBuild';
import log from '../../log';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
export default class BuildView extends Command {
static description = 'view a build for your project';
static args = [{ name: 'buildId' }];
async run() {
const { buildId } = this.parse(BuildView).args;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const accountName = await getProjectAccountNameAsync(projectDir);
const projectName = await getProjectFullNameAsync(projectDir);
const spinner = ora().start('Fetching the build…');
try {
let build: Build;
if (buildId == null) {
const response = await apiClient.get<{ data: { builds: Build[] } }>(
`projects/${projectId}/builds`,
{
searchParams: { limit: 1 },
responseType: 'json',
}
);
build = response.body.data.builds[0];
} else {
const response = await apiClient.get<{ data: Build }>(
`projects/${projectId}/builds/${buildId}`,
{
responseType: 'json',
}
);
build = response.body.data;
}
if (buildId) {
spinner.succeed(`Found a matching build for the project ${projectName}`);
} else {
spinner.succeed(`Showing the last build for the project ${projectName}`);
}
log(`\n${formatBuild(build, { accountName })}`);
} catch (e) {
const error = e as HTTPError;
if (error.response.statusCode === 400) {
if (buildId) {
spinner.fail(
`Couldn't find a build for the project ${projectName} matching the id ${buildId}`
);
} else {
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
}
} else {
if (buildId) {
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
} else {
spinner.fail(
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
);
}
}
throw error;
}
}
}
@@ -0,0 +1,155 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateChannel } from '../../graphql/generated';
import log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getReleaseByNameAsync,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { createUpdateReleaseOnAppAsync } from '../release/create';
async function createUpdateChannelOnAppAsync({
appId,
channelName,
releaseId,
}: {
appId: string;
channelName: string;
releaseId: string;
}): Promise<UpdateChannel> {
// Point the new channel at a release with its same name.
const releaseMapping = JSON.stringify({
data: [{ releaseId, releaseMappingLogic: 'true' }],
version: 0,
});
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateChannel: { createUpdateChannelForApp: UpdateChannel } },
{ appId: string; channelName: string; releaseMapping: string }
>(
gql`
mutation CreateUpdateChannelForApp(
$appId: ID!
$channelName: String!
$releaseMapping: String!
) {
updateChannel {
createUpdateChannelForApp(
appId: $appId
channelName: $channelName
releaseMapping: $releaseMapping
) {
id
channelName
releaseMapping
}
}
}
`,
{
appId,
channelName,
releaseMapping,
}
)
.toPromise()
);
return data.updateChannel.createUpdateChannelForApp;
}
export default class ChannelCreate extends Command {
static hidden = true;
static description = 'Create a channel on the current project.';
static args = [
{
name: 'channelName',
required: false,
description: 'Name of the channel to create',
},
];
static flags = {
json: flags.boolean({
description:
'print output as a JSON object with the new channel ID, name and release mapping.',
default: false,
}),
};
async run() {
let {
args: { channelName },
flags: { json: jsonFlag },
} = this.parse(ChannelCreate);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
if (!channelName) {
const validationMessage = 'Channel name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ channelName } = await promptAsync({
type: 'text',
name: 'channelName',
message: 'Please name the channel:',
validate: value => (value ? true : validationMessage),
}));
}
let releaseId: string;
let releaseMessage: string;
try {
const existingRelease = await getReleaseByNameAsync({
appId: projectId,
releaseName: channelName,
});
releaseId = existingRelease.id;
releaseMessage = `We found a release with the same name`;
} catch (e) {
const newRelease = await createUpdateReleaseOnAppAsync({
appId: projectId,
releaseName: channelName,
});
releaseId = newRelease.id;
releaseMessage = `We also went ahead and made a release with the same name`;
}
const newChannel = await createUpdateChannelOnAppAsync({
appId: projectId,
channelName,
releaseId,
});
if (jsonFlag) {
log(newChannel);
return;
}
log.withTick(
`️Created a new channel ${chalk.bold(newChannel.channelName)} on project ${chalk.bold(
`@${accountName}/${slug}`
)}. ${releaseMessage} and have pointed the channel at it. You can now update your app by publishing!`
);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Command } from '@oclif/command';
import { runCredentialsManagerAsync } from '../credentials/CredentialsManager';
import { runStandaloneCredentialsManagerAsync } from '../credentials/CredentialsManager';
import { createCredentialsContextAsync } from '../credentials/context';
import { SelectPlatform } from '../credentials/manager/SelectPlatform';
@@ -9,6 +9,6 @@ export default class Credentials extends Command {
async run() {
const ctx = await createCredentialsContextAsync(process.cwd(), {});
await runCredentialsManagerAsync(ctx, new SelectPlatform());
await runStandaloneCredentialsManagerAsync(ctx, new SelectPlatform());
}
}
@@ -0,0 +1,91 @@
import { Command, flags } from '@oclif/command';
import assert from 'assert';
import chalk from 'chalk';
import ora from 'ora';
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import { AppleTeamQuery } from '../../credentials/ios/api/graphql/queries/AppleTeamQuery';
import formatDevice from '../../devices/utils/formatDevice';
import log from '../../log';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
export default class BuildList extends Command {
static description = 'list all registered devices for your account';
static flags = {
'apple-team-id': flags.string(),
};
async run() {
let appleTeamIdentifier = this.parse(BuildList).flags['apple-team-id'];
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const accountName = await getProjectAccountNameAsync(projectDir);
let spinner: ora.Ora;
if (!appleTeamIdentifier) {
spinner = ora().start('Fetching the list of teams for the project…');
try {
const teams = await AppleTeamQuery.getAllForAccountAsync(accountName);
if (teams.length) {
spinner.succeed();
if (teams.length === 1) {
appleTeamIdentifier = teams[0].appleTeamIdentifier;
} else {
const result = await promptAsync({
type: 'select',
name: 'appleTeamIdentifier',
message: 'What Apple Team would you like to list devices for?',
choices: teams.map(team => ({
title: `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`,
value: team.appleTeamIdentifier,
})),
});
appleTeamIdentifier = result.appleTeamIdentifier;
}
} else {
spinner.fail(`Couldn't find any teams for the account ${accountName}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the list of teams`);
throw e;
}
}
assert(appleTeamIdentifier, 'No team identifier is specified');
spinner = ora().start('Fetching the list of devices for the team…');
try {
const result = await AppleDeviceQuery.getAllForAppleTeamAsync(
accountName,
appleTeamIdentifier
);
if (result?.appleDevices.length) {
const { appleTeamName, appleDevices } = result;
spinner.succeed(`Found ${appleDevices.length} devices for team ${appleTeamName}`);
const list = appleDevices
.map(device =>
formatDevice(device, { appleTeamName, appleTeamIdentifier: appleTeamIdentifier! })
)
.join(`\n\n${chalk.dim('———')}\n\n`);
log(`\n${list}`);
} else {
spinner.fail(`Couldn't find any devices for the team ${appleTeamIdentifier}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the device list`);
throw e;
}
}
}
@@ -0,0 +1,51 @@
import { Command } from '@oclif/command';
import ora from 'ora';
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import formatDevice from '../../devices/utils/formatDevice';
import log from '../../log';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
export default class DeviceView extends Command {
static description = 'view a device for your project';
static args = [{ name: 'UDID' }];
async run() {
const { UDID } = this.parse(DeviceView).args;
if (!UDID) {
log(
`The device UDID is required to view a specific device. For example:
eas device:view 00005787-000872430189501D
If you are not sure what is the UDID of the device you are looking for, run:
eas device:list
`
);
throw new Error('Device UDID is missing');
}
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const accountName = await getProjectAccountNameAsync(projectDir);
const spinner = ora().start(`Fetching device details for ${UDID}…`);
try {
const device = await AppleDeviceQuery.getByDeviceIdentifier(accountName, UDID);
if (device) {
spinner.succeed('Successfully fetched device details');
log(`\n${formatDevice(device, device.appleTeam)}`);
} else {
spinner.fail(`Couldn't find a device with the UDID ${UDID}`);
}
} catch (e) {
console.log(e);
spinner.fail(`Something went wrong and we couldn't fetch the device with UDID ${UDID}`);
throw e;
}
}
}
-13
View File
@@ -1,13 +0,0 @@
import { Command } from '@oclif/command';
import { showLoginPromptAsync } from '../user/actions';
export default class Login extends Command {
static description = 'log in with your EAS account';
async run() {
this.log('Log in to EAS');
await showLoginPromptAsync();
this.log('Logged in.');
}
}
-12
View File
@@ -1,12 +0,0 @@
import { Command } from '@oclif/command';
import { logoutAsync } from '../user/User';
export default class Logout extends Command {
static description = 'log out';
async run() {
await logoutAsync();
this.log('Logged out.');
}
}
@@ -0,0 +1,113 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateRelease } from '../../graphql/generated';
import log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
import { getBranchNameAsync } from '../../utils/git';
export async function createUpdateReleaseOnAppAsync({
appId,
releaseName,
}: {
appId: string;
releaseName: string;
}): Promise<Pick<UpdateRelease, 'id' | 'releaseName'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateRelease: { createUpdateReleaseForApp: Pick<UpdateRelease, 'id' | 'releaseName'> } },
{ appId: string; releaseName: string }
>(
gql`
mutation CreateUpdateReleaseForApp($appId: ID!, $releaseName: String!) {
updateRelease {
createUpdateReleaseForApp(appId: $appId, releaseName: $releaseName) {
id
releaseName
}
}
}
`,
{
appId,
releaseName,
}
)
.toPromise()
);
return data.updateRelease.createUpdateReleaseForApp;
}
export default class ReleaseCreate extends Command {
static hidden = true;
static description = 'Create a release on the current project.';
static args = [
{
name: 'releaseName',
required: false,
description: 'Name of the release to create',
},
];
static flags = {
json: flags.boolean({
description: 'return a json with the new release ID and name.',
default: false,
}),
};
async run() {
let {
args: { releaseName },
flags,
} = this.parse(ReleaseCreate);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: exp.slug,
});
if (!releaseName) {
const validationMessage = 'Release name may not be empty.';
if (flags.json) {
throw new Error(validationMessage);
}
({ releaseName } = await promptAsync({
type: 'text',
name: 'releaseName',
message: 'Please name the release:',
initial:
(await getBranchNameAsync()) || `release-${Math.random().toString(36).substr(2, 4)}`,
validate: value => (value ? true : validationMessage),
}));
}
const newRelease = await createUpdateReleaseOnAppAsync({ appId: projectId, releaseName });
if (flags.json) {
log(newRelease);
return;
}
log.withTick(
`️Created a new release: ${chalk.bold(newRelease.releaseName)} on project ${chalk.bold(
`@${accountName}/${exp.slug}`
)}.`
);
}
}
@@ -0,0 +1,132 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { EditUpdateReleaseInput, UpdateRelease } from '../../graphql/generated';
import log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
async function renameUpdateReleaseOnAppAsync({
appId,
currentName,
newName,
}: {
appId: string;
currentName: string;
newName: string;
}): Promise<UpdateRelease> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateRelease: { editUpdateRelease: UpdateRelease } },
{
input: EditUpdateReleaseInput;
}
>(
gql`
mutation EditUpdateRelease($input: EditUpdateReleaseInput!) {
updateRelease {
editUpdateRelease(input: $input) {
id
releaseName
}
}
}
`,
{
input: {
appId,
releaseName: currentName,
newReleaseName: newName,
},
}
)
.toPromise()
);
return data.updateRelease.editUpdateRelease;
}
export default class ReleaseRename extends Command {
static hidden = true;
static description = 'Rename a release.';
static flags = {
from: flags.string({
description: 'current name of the release.',
required: false,
}),
to: flags.string({
description: 'new name of the release.',
required: false,
}),
json: flags.boolean({
description: `return a json with the edited release's ID and name.`,
default: false,
}),
};
async run() {
let {
flags: { json: jsonFlag, from: currentName, to: newName },
} = this.parse(ReleaseRename);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const { exp } = await getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: exp.slug,
});
if (!currentName) {
const validationMessage = 'current name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ currentName } = await promptAsync({
type: 'text',
name: 'currentName',
message: 'Please enter the current name of the release to rename:',
validate: value => (value ? true : validationMessage),
}));
}
if (!newName) {
const validationMessage = 'new name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ newName } = await promptAsync({
type: 'text',
name: 'newName',
message: `Please rename ${currentName}`,
validate: value => (value ? true : validationMessage),
}));
}
const editedRelease = await renameUpdateReleaseOnAppAsync({
appId: projectId,
currentName: currentName!,
newName: newName!,
});
if (jsonFlag) {
log(editedRelease);
return;
}
log.withTick(
`️Renamed release from ${currentName} to ${chalk.bold(
editedRelease.releaseName
)} on project ${chalk.bold(`@${accountName}/${exp.slug}`)}.`
);
}
}
@@ -0,0 +1,171 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import Table from 'cli-table3';
import gql from 'graphql-tag';
import { groupBy } from 'lodash';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { Update } from '../../graphql/generated';
import log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
const PAGE_LIMIT = 10_000;
type TruncatedUpdate = Pick<Update, 'updateGroup' | 'updateMessage' | 'createdAt' | 'actor'>;
async function viewUpdateReleaseAsync({
appId,
releaseName,
}: {
appId: string;
releaseName: string;
}): Promise<{
id: string;
releaseName: string;
updates: TruncatedUpdate[];
}> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{
app: {
byId: {
updateReleaseByReleaseName: {
id: string;
releaseName: string;
updates: TruncatedUpdate[];
};
};
};
},
{
appId: string;
releaseName: string;
}
>(
gql`
query ViewRelease($appId: String!, $releaseName: String!) {
app {
byId(appId: $appId) {
updateReleaseByReleaseName(releaseName: $releaseName) {
id
releaseName
updates(offset: 0, limit: ${PAGE_LIMIT}) {
updateGroup
updateMessage
createdAt
actor {
... on User {
firstName
}
... on Robot {
firstName
}
}
}
}
}
}
}
`,
{
appId,
releaseName,
}
)
.toPromise()
);
return data.app.byId.updateReleaseByReleaseName;
}
export default class ReleaseView extends Command {
static hidden = true;
static description = 'View a release.';
static args = [
{
name: 'releaseName',
required: false,
description: 'Name of the release to view',
},
];
static flags = {
json: flags.boolean({
description: `return a json with the release's ID name and recent update groups.`,
default: false,
}),
};
async run() {
let {
args: { releaseName },
flags: { json: jsonFlag },
} = this.parse(ReleaseView);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
if (!releaseName) {
const validationMessage = 'Release name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ releaseName } = await promptAsync({
type: 'text',
name: 'releaseName',
message: 'Please enter the name of the release to view:',
validate: value => (value ? true : validationMessage),
}));
}
const UpdateRelease = await viewUpdateReleaseAsync({
appId: projectId,
releaseName,
});
const updates = Object.values(groupBy(UpdateRelease.updates, u => u.updateGroup)).map(
updateGroup => updateGroup[0]
);
if (jsonFlag) {
log({ ...UpdateRelease, updates });
return;
}
const updateGroupTable = new Table({
head: ['createdAt', 'updateMessage', 'updateGroup', 'actor'],
wordWrap: true,
});
for (const update of updates) {
updateGroupTable.push([
new Date(update.createdAt).toLocaleString(),
update.updateMessage,
update.updateGroup,
update.actor?.firstName,
]);
}
log.withTick(
`️Release: ${chalk.bold(UpdateRelease.releaseName)} on project ${chalk.bold(
`@${accountName}/${slug}`
)}. Release ID: ${chalk.bold(UpdateRelease.id)}`
);
log(chalk.bold('Recent update groups published on this release:'));
log(updateGroupTable.toString());
}
}
+231
View File
@@ -0,0 +1,231 @@
import { Command, flags } from '@oclif/command';
import { learnMore } from '../log';
import { isEasEnabledForProjectAsync, warnEasUnavailable } from '../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectIdAsync } from '../project/projectUtils';
import { promptAsync } from '../prompts';
import AndroidSubmitCommand from '../submissions/android/AndroidSubmitCommand';
import IosSubmitCommand from '../submissions/ios/IosSubmitCommand';
import {
AndroidSubmitCommandFlags,
IosSubmitCommandFlags,
SubmissionPlatform,
} from '../submissions/types';
import { ensureLoggedInAsync } from '../user/actions';
const COMMON_FLAGS = '';
const ANDROID_FLAGS = 'Android specific options';
const IOS_FLAGS = 'iOS specific options';
export default class BuildSubmit extends Command {
static description = 'Submits build artifact to app store';
static usage = 'submit --platform=(android|ios)';
static aliases = ['build:submit'];
static examples = [
`$ eas submit --platform=ios
- Fully interactive iOS submission\n`,
`$ eas submit --platform=android
- Fully interactive Android submission\n`,
`$ eas submit -p android --latest --key=/path/to/google-services.json
- Minimal non-interactive Android submission, however it can ask you for other params if not specified\n`,
`$ EXPO_APPLE_APP_SPECIFIC_PASSWORD=xxx eas submit -p ios --latest [email protected] --asc-app-id=1234567890,
- Minimal non-interactive iOS submission, assuming you already have an app in App Store Connect
and provide its App ID`,
];
static flags = {
platform: flags.enum({
char: 'p',
description: 'For which platform you want to submit a build',
options: ['android', 'ios'],
helpLabel: COMMON_FLAGS,
}),
/* Common flags for both platforms */
latest: flags.boolean({
description: 'Submit the latest build for specified platform',
exclusive: ['id', 'path', 'url'],
default: false,
helpLabel: COMMON_FLAGS,
}),
id: flags.string({
description: 'ID of the build to submit',
exclusive: ['latest, path, url'],
helpLabel: COMMON_FLAGS,
}),
path: flags.string({
description: 'Path to the .apk/.aab file',
exclusive: ['latest', 'id', 'url'],
helpLabel: COMMON_FLAGS,
}),
url: flags.string({
description: 'App archive url',
exclusive: ['latest', 'id', 'path'],
helpLabel: COMMON_FLAGS,
}),
verbose: flags.boolean({
description: 'Always print logs from Submission Service',
default: false,
helpLabel: COMMON_FLAGS,
}),
/* Android specific flags */
type: flags.enum<'apk' | 'aab'>({
description: 'Android archive type',
options: ['apk', 'aab'],
helpLabel: ANDROID_FLAGS,
}),
key: flags.string({
description: 'Path to the JSON key used to authenticate with Google Play',
helpLabel: ANDROID_FLAGS,
}),
'android-package': flags.string({
description: 'Android package name (default: expo.android.package from app config)',
helpLabel: ANDROID_FLAGS,
}),
track: flags.enum({
description: 'The track of the application to use',
default: 'internal',
options: ['production', 'beta', 'alpha', 'internal', 'rollout'],
helpLabel: ANDROID_FLAGS,
}),
'release-status': flags.enum({
description: 'Release status (used when uploading new APKs/AABs)',
default: 'completed',
options: ['completed', 'draft', 'halted', 'inProgress'],
helpLabel: ANDROID_FLAGS,
}),
/* iOS specific flags */
'apple-id': flags.string({
description: 'Your Apple ID username (you can also set EXPO_APPLE_ID env variable)',
helpLabel: IOS_FLAGS,
}),
'asc-app-id': flags.string({
description: `App Store Connect unique application Apple ID number. Providing this param results in skipping app creation step. ${learnMore(
'https://expo.fyi/asc-app-id'
)}`,
helpLabel: IOS_FLAGS,
}),
'apple-team-id': flags.string({
description: 'Your Apple Developer Team ID',
helpLabel: IOS_FLAGS,
}),
'app-name': flags.string({
description:
'The name of your app as it will appear on the App Store (default: expo.name from app config)',
helpLabel: IOS_FLAGS,
}),
'bundle-identifier': flags.string({
description:
'Your iOS Bundle Identifier (default: expo.ios.bundleIdentifier from app config)',
helpLabel: IOS_FLAGS,
}),
sku: flags.string({
description:
'An unique ID for your app that is not visible on the App Store, will be generated unless provided',
helpLabel: IOS_FLAGS,
}),
language: flags.string({
description: 'Primary language (e.g. English, German, ...)',
default: 'en-US',
helpLabel: IOS_FLAGS,
}),
'company-name': flags.string({
description:
'The name of your company, needed only for the first upload of any app to App Store',
helpLabel: IOS_FLAGS,
}),
};
async run(): Promise<void> {
const {
flags: {
// android
'android-package': androidPackage,
'release-status': releaseStatus,
// ios
'apple-id': appleId,
'asc-app-id': ascAppId,
'apple-team-id': appleTeamId,
'app-name': appName,
'bundle-identifier': bundleIdentifier,
'company-name': companyName,
// common
platform: platformFromParams,
...flags
},
} = this.parse(BuildSubmit);
await ensureLoggedInAsync();
const platform = platformFromParams ?? (await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
// TODO: Make this work outside project dir
if (!projectDir) {
throw new Error("Please run this command inside your project's directory");
}
if (platform === SubmissionPlatform.Android) {
const options: AndroidSubmitCommandFlags = {
androidPackage,
releaseStatus,
...flags,
};
const ctx = AndroidSubmitCommand.createContext(projectDir, projectId, options);
const command = new AndroidSubmitCommand(ctx);
await command.runAsync();
} else if (platform === SubmissionPlatform.iOS) {
const options: IosSubmitCommandFlags = {
appleId,
ascAppId,
appleTeamId,
appName,
bundleIdentifier,
companyName,
...flags,
};
const ctx = IosSubmitCommand.createContext(projectDir, projectId, options);
const command = new IosSubmitCommand(ctx);
await command.runAsync();
} else {
throw new Error(`Unsupported platform: ${platform}!`);
}
}
}
async function promptForPlatformAsync(): Promise<SubmissionPlatform> {
const { platform } = await promptAsync({
type: 'select',
message: 'Choose a platform to submit to',
name: 'platform',
choices: [
{
title: 'iOS',
value: SubmissionPlatform.iOS,
},
{
title: 'Android',
value: SubmissionPlatform.Android,
},
],
});
return platform;
}
@@ -1,11 +0,0 @@
import { Command } from '@oclif/command';
export default class UpdateIndex extends Command {
static description = 'create a revision for given channel';
static aliases = ['update:publish'];
async run() {
throw new Error('Not implemented');
}
}
@@ -1,6 +1,7 @@
import { Command } from '@oclif/command';
export default class UpdateShow extends Command {
static hidden = true;
static description = 'details about a particular revision';
async run() {
-19
View File
@@ -1,19 +0,0 @@
import { Command } from '@oclif/command';
import chalk from 'chalk';
import log from '../log';
import { getUserAsync } from '../user/User';
export default class Whoami extends Command {
static description = 'show the username you are logged in as';
async run() {
const user = await getUserAsync();
if (user?.username) {
log(chalk.green(user.username));
} else {
log.warn('Not logged in.');
process.exit(1);
}
}
}
@@ -15,6 +15,14 @@ export interface Action {
export class QuitError extends Error {}
export async function runStandaloneCredentialsManagerAsync(
ctx: Context,
startAction: Action
): Promise<void> {
const manager = new CredentialsManagerImpl(ctx, startAction);
await manager.runManagerAsync(true);
}
export async function runCredentialsManagerAsync(ctx: Context, startAction: Action): Promise<void> {
const manager = new CredentialsManagerImpl(ctx, startAction);
await manager.runManagerAsync();
@@ -39,9 +47,9 @@ class CredentialsManagerImpl implements CredentialsManager {
await new CredentialsManagerImpl(this.ctx, action).doRunManagerAsync();
}
public async runManagerAsync(): Promise<void> {
public async runManagerAsync(isStandaloneManager: boolean = false): Promise<void> {
try {
await this.doRunManagerAsync();
await this.doRunManagerAsync(isStandaloneManager);
} catch (error) {
if (error instanceof QuitError) {
return;
@@ -50,7 +58,7 @@ class CredentialsManagerImpl implements CredentialsManager {
}
}
private async doRunManagerAsync(): Promise<void> {
private async doRunManagerAsync(isStandaloneManager: boolean = false): Promise<void> {
while (true) {
try {
const currentAction = this.popAction();
@@ -59,10 +67,11 @@ class CredentialsManagerImpl implements CredentialsManager {
}
await currentAction.runAsync(this, this.ctx);
} catch (error) {
if (error instanceof QuitError) {
if (!isStandaloneManager || error instanceof QuitError) {
throw error;
} else {
log.error(error);
}
log.error(error);
}
}
}
@@ -0,0 +1,8 @@
import { Platform } from '@expo/eas-build-job';
export interface CredentialsProvider {
platform: Platform;
hasRemoteAsync(): Promise<boolean>;
hasLocalAsync(): Promise<boolean>;
isLocalSyncedAsync(): Promise<boolean>;
}
@@ -1,14 +1,16 @@
import { User } from '../../user/User';
import { Actor } from '../../user/User';
export const jester: User = {
export const jester: Actor = {
__typename: 'User',
id: 'jester-id',
username: 'jester',
userId: 'jester-id',
accounts: [{ id: 'jester-account-id', name: 'jester' }],
};
export const jester2: User = {
export const jester2: Actor = {
__typename: 'User',
id: 'jester2-id',
username: 'jester2',
userId: 'jester2-id',
accounts: [{ id: 'jester2-account-id', name: 'jester2' }],
};
@@ -4,10 +4,11 @@ import { CredentialsManager } from '../CredentialsManager';
import { Context } from '../context';
import { getAndroidApiMockWithoutCredentials } from './fixtures-android';
import { testAppJson, testUsername } from './fixtures-constants';
import { getIosApiMockWithoutCredentials } from './fixtures-ios';
export function createCtxMock(mockOverride: Record<string, any> = {}): Context {
const defaultMock = {
ios: jest.fn(),
ios: getIosApiMockWithoutCredentials(),
android: getAndroidApiMockWithoutCredentials(),
appStore: jest.fn(),
ensureAppleCtx: jest.fn(),
@@ -16,7 +17,7 @@ export function createCtxMock(mockOverride: Record<string, any> = {}): Context {
},
hasAppleCtx: jest.fn(() => true),
hasProjectContext: true,
manifest: testAppJson,
exp: testAppJson,
projectDir: '.',
};
return merge(defaultMock, mockOverride) as any;
@@ -0,0 +1,155 @@
import {
DistributionCertificate,
DistributionCertificateStoreInfo,
ProvisioningProfile,
ProvisioningProfileStoreInfo,
PushKey,
PushKeyStoreInfo,
} from '../ios/appstore/Credentials.types';
import { IosDistCredentials, IosPushCredentials } from '../ios/credentials';
import { testProvisioningProfileBase64 } from './fixtures-base64-data';
import { testBundleIdentifier, testExperienceName } from './fixtures-constants';
const today = new Date();
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
export const testAppleTeam = {
id: 'test-team-id',
};
export const testProvisioningProfile: ProvisioningProfile = {
provisioningProfileId: 'test-id',
provisioningProfile: testProvisioningProfileBase64,
teamId: 'id',
};
export const testProvisioningProfiles = [testProvisioningProfile];
export const testProvisioningProfileFromApple: ProvisioningProfileStoreInfo = {
name: 'test-name',
status: 'Active',
expires: tomorrow.getTime(),
distributionMethod: 'test',
certificates: [],
provisioningProfileId: testProvisioningProfile.provisioningProfileId,
provisioningProfile: testProvisioningProfile.provisioningProfile,
teamId: 'id',
};
export const testProvisioningProfilesFromApple = [testProvisioningProfileFromApple];
export const testDistCert: DistributionCertificate = {
certP12: 'Y2VydHAxMg==',
certPassword: 'test-password',
distCertSerialNumber: 'test-serial',
teamId: 'test-team-id',
};
export const testIosDistCredential: IosDistCredentials = {
id: 1,
type: 'dist-cert',
...testDistCert,
};
export const testIosDistCredentials = [testIosDistCredential];
export const testDistCertFromApple: DistributionCertificateStoreInfo = {
id: 'test-id',
name: 'test-name',
status: 'Active',
created: today.getTime(),
expires: tomorrow.getTime(),
ownerName: 'test-owner',
ownerId: 'test-id',
serialNumber: testIosDistCredential.distCertSerialNumber as string,
};
export const testDistCertsFromApple = [testDistCertFromApple];
export const testPushKey: PushKey = {
apnsKeyP8: 'test-p8',
apnsKeyId: 'test-key-id',
teamId: 'test-team-id',
};
export const testIosPushCredential: IosPushCredentials = {
id: 2,
type: 'push-key',
...testPushKey,
};
export const testIosPushCredentials = [testIosPushCredential];
export const testPushKeyFromApple: PushKeyStoreInfo = {
id: testIosPushCredential.apnsKeyId,
name: 'test-name',
};
export const testPushKeysFromApple = [testPushKeyFromApple];
export const testLegacyPushCert = {
pushId: 'test-push-id',
pushP12: 'test-push-p12',
pushPassword: 'test-push-password',
};
export const testAppCredential = {
experienceName: testExperienceName,
bundleIdentifier: testBundleIdentifier,
distCredentialsId: testIosDistCredential.id,
pushCredentialsId: testIosPushCredential.id,
credentials: {
...testProvisioningProfile,
},
};
export const testAllCredentialsForApp = {
...testAppCredential,
pushCredentials: testPushKey,
distCredentials: testDistCert,
};
export const testAppCredentials = [testAppCredential];
export const testAllCredentials = {
userCredentials: [...testIosDistCredentials, ...testIosPushCredentials],
appCredentials: testAppCredentials,
};
export function getIosApiMock() {
return {
getAllCredentials: jest.fn(() => testAllCredentials),
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
getDistributionCertificateByIdAsync: jest.fn(id =>
id === testIosDistCredential.id ? testIosDistCredential : null
),
createDistributionCertificateAsync: jest.fn((owner, cert) => ({
id: 123,
type: 'dist-cert',
...cert,
})),
updateDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
useDistributionCertificateAsync: jest.fn(),
getPushKey: jest.fn(() => testIosPushCredential),
createPushKeyAsync: jest.fn((owner, key) => ({ id: 124, type: 'push-key', ...key })),
updatePushKeyAsync: jest.fn(),
deletePushKeyAsync: jest.fn(),
usePushKeyAsync: jest.fn(),
getAppCredentialsAsync: jest.fn(() => testAppCredentials),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfileAsync: jest.fn(),
};
}
export function getIosApiMockWithoutCredentials() {
return {
getAllCredentials: jest.fn(() => ({ appCredentials: [], userCredentials: [] })),
getDistributionCertificateAsync: jest.fn(() => null),
getDistributionCertificateByIdAsync: jest.fn(() => {
throw new Error('not found');
}),
createDistributionCertificateAsync: jest.fn(),
updateDistributionCertificateAsync: jest.fn(),
deleteDistributionCertificateAsync: jest.fn(),
useDistributionCertificateAsync: jest.fn(),
getPushKeyAsync: jest.fn(() => null),
createPushKeyAsync: jest.fn(),
updatePushKeyAsync: jest.fn(),
deletePushKeyAsync: jest.fn(),
usePushKeyAsync: jest.fn(),
getAppCredentialsAsync: jest.fn(app => ({
experienceName: `${app.accountName}/${app.projectName}`,
bundleIdentifier: app.bundleIdentifier,
})),
getProvisioningProfileAsync: jest.fn(() => null),
updateProvisioningProfileAsync: jest.fn(),
deleteProvisioningProfileAsync: jest.fn(),
};
}
@@ -0,0 +1,99 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import log from '../../log';
import { runCredentialsManagerAsync } from '../CredentialsManager';
import { CredentialsProvider } from '../CredentialsProvider';
import { Context } from '../context';
import * as credentialsJsonReader from '../credentialsJson/read';
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
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 implements CredentialsProvider {
public readonly platform = Platform.Android;
constructor(private ctx: Context, private options: Options) {}
private get projectFullName(): string {
const { projectName, accountName } = this.options.app;
return `@${accountName}/${projectName}`;
}
public async hasRemoteAsync(): Promise<boolean> {
const keystore = await this.ctx.android.fetchKeystoreAsync(this.projectFullName);
return !!keystore;
}
public async hasLocalAsync(): Promise<boolean> {
if (!(await credentialsJsonReader.fileExistsAsync(this.ctx.projectDir))) {
return false;
}
try {
const rawCredentialsJson = await credentialsJsonReader.readRawAsync(this.ctx.projectDir);
return !!rawCredentialsJson?.android;
} catch (err) {
log.error(err); // malformed json
return false;
}
}
public async isLocalSyncedAsync(): Promise<boolean> {
try {
const [remote, local] = await Promise.all([
this.ctx.android.fetchKeystoreAsync(this.projectFullName),
credentialsJsonReader.readAndroidCredentialsAsync(this.ctx.projectDir),
]);
const r = remote;
const l = local?.keystore;
return !!(
r &&
l &&
r.keystore === l.keystore &&
r.keystorePassword === l.keystorePassword &&
r.keyAlias === l.keyAlias &&
r.keyPassword === l.keyPassword
);
} catch (_) {
return false;
}
}
public async getCredentialsAsync(
src: CredentialsSource.LOCAL | CredentialsSource.REMOTE
): Promise<AndroidCredentials> {
switch (src) {
case CredentialsSource.LOCAL:
return await this.getLocalAsync();
case CredentialsSource.REMOTE:
return await this.getRemoteAsync();
}
throw new Error('Unknown');
}
private async getRemoteAsync(): Promise<AndroidCredentials> {
await runCredentialsManagerAsync(this.ctx, new SetupBuildCredentials(this.projectFullName));
const keystore = await this.ctx.android.fetchKeystoreAsync(this.projectFullName);
if (!keystore) {
throw new Error('Unable to set up credentials, failed to fetch keystore from Expo servers');
}
return { keystore };
}
private async getLocalAsync(): Promise<AndroidCredentials> {
return await credentialsJsonReader.readAndroidCredentialsAsync(this.ctx.projectDir);
}
}
@@ -1,5 +1,10 @@
import chalk from 'chalk';
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';
export class SetupBuildCredentials implements Action {
@@ -16,3 +21,27 @@ export class SetupBuildCredentials implements Action {
manager.pushNextAction(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 error;
}
if (!this.options.skipKeystoreValidation) {
await validateKeystoreAsync(localCredentials.keystore);
}
await ctx.android.updateKeystoreAsync(this.projectFullName, localCredentials.keystore);
log(chalk.green('Keystore updated successfully.'));
}
}
@@ -0,0 +1,20 @@
import chalk from 'chalk';
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> {
log('Updating Android credentials in credentials.json');
await updateAndroidCredentialsAsync(ctx);
log(
chalk.green(
'Android part of your local credentials.json is synced with values stored on Expo servers.'
)
);
}
}
@@ -73,7 +73,7 @@ describe('run UpdateKeystore when keystore exist on www', () => {
});
});
describe('run UpdateKeystore when keystore deos not exist on www', () => {
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: {
@@ -2,6 +2,7 @@ import spawnAsync from '@expo/spawn-async';
import crypto from 'crypto';
import fs from 'fs-extra';
import path from 'path';
import tempy from 'tempy';
import { v4 as uuidv4 } from 'uuid';
import log from '../../../log';
@@ -128,3 +129,24 @@ export async function generateRandomKeystoreAsync(): Promise<Keystore> {
};
return await createKeystoreAsync(keystoreData);
}
export async function validateKeystoreAsync(keystore: Keystore): Promise<void> {
await ensureKeytoolCommandExistsAsync();
try {
await tempy.write.task(Buffer.from(keystore.keystore, 'base64'), async keystorePath => {
await spawnAsync('keytool', [
'-list',
'-keystore',
keystorePath,
'-storepass',
keystore.keystorePassword,
'-alias',
keystore.keyAlias,
]);
});
} catch (e) {
throw new Error(
`An error occurred when validating the Android keystore: ${e.stdout ?? e.message}`
);
}
}
+16 -10
View File
@@ -3,11 +3,13 @@ import chalk from 'chalk';
import pick from 'lodash/pick';
import log from '../log';
import { getProjectAccountName } from '../project/projectUtils';
import { confirmAsync } from '../prompts';
import { User } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { Actor } from '../user/User';
import { ensureLoggedInAsync, getActorDisplayName } from '../user/actions';
import AndroidApi from './android/api/Client';
import iOSApi from './ios/api/Client';
import * as IosGraphqlClient from './ios/api/GraphqlClient';
import AppStoreApi from './ios/appstore/AppStoreApi';
interface AppleCtxOptions {
@@ -22,10 +24,11 @@ interface Options extends AppleCtxOptions {
export interface Context {
readonly projectDir: string;
readonly user: User;
readonly user: Actor;
readonly nonInteractive: boolean;
readonly android: AndroidApi;
readonly ios: iOSApi;
readonly newIos: typeof IosGraphqlClient;
readonly appStore: AppStoreApi;
readonly hasProjectContext: boolean;
readonly exp: ExpoConfig;
@@ -54,13 +57,14 @@ export async function createCredentialsContextAsync(
class CredentialsContext implements Context {
public readonly android = new AndroidApi();
public readonly ios = new iOSApi();
public readonly newIos = IosGraphqlClient;
public readonly appStore: AppStoreApi;
public readonly nonInteractive: boolean;
private shouldAskAuthenticateAppStore: boolean = true;
constructor(
public readonly projectDir: string,
public readonly user: User,
public readonly user: Actor,
private _exp: ExpoConfig | undefined,
options: Options
) {
@@ -87,15 +91,17 @@ class CredentialsContext implements Context {
public logOwnerAndProject() {
if (this.hasProjectContext) {
const owner = getProjectAccountName(this.exp, this.user);
// Figure out if User A is configuring credentials as admin for User B's project
const isProxyUser = this.exp.owner && this.exp.owner !== this.user.username;
const isProxyUser = this.user.__typename === 'Robot' || owner !== this.user.username;
log(
`Accessing credentials ${isProxyUser ? 'on behalf of' : 'for'} ${
this.exp.owner ?? this.user.username
} in project ${this.exp.slug}`
`Accessing credentials ${isProxyUser ? 'on behalf of' : 'for'} ${owner} in project ${
this.exp.slug
}`
);
} else {
log(`Accessing credentials for ${this.exp.owner ?? this.user.username}`);
log(`Accessing credentials for ${this.exp.owner ?? getActorDisplayName(this.user)}`);
}
}
@@ -116,7 +122,7 @@ class CredentialsContext implements Context {
);
log(
chalk.green(
'This is optional, but without Apple account access you will need to provide all the values manually and we can only run minimal validatation on them.'
'This is optional, but without Apple account access you will need to provide all the values manually and we can only run minimal validation on them.'
)
);
const confirm = await confirmAsync({
@@ -0,0 +1,207 @@
import { vol } from 'memfs';
import * as credentialsJsonReader from '../read';
jest.mock('fs');
beforeEach(() => {
vol.reset();
});
describe('credentialsJson', () => {
describe('readAndroidAsync', () => {
it('should read android credentials if everything is correct', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
android: {
keystore: {
keystorePath: 'keystore.jks',
keystorePassword: 'keystorePassword',
keyAlias: 'keyAlias',
keyPassword: 'keyPassword',
},
},
}),
'keystore.jks': 'somebinarydata',
});
const result = await credentialsJsonReader.readAndroidCredentialsAsync('.');
expect(result).toEqual({
keystore: {
keystore: 'c29tZWJpbmFyeWRhdGE=',
keystorePassword: 'keystorePassword',
keyAlias: 'keyAlias',
keyPassword: 'keyPassword',
},
});
});
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'
);
});
it('should throw error when android entry is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({}),
'keystore.jks': 'somebinarydata',
});
const promise = credentialsJsonReader.readAndroidCredentialsAsync('.');
await expect(promise).rejects.toThrow(
'Android credentials are missing from credentials.json'
);
});
it('should throw error when one of the required fields is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
android: {
keystore: {
keystorePassword: 'keystorePassword',
keyAlias: 'keyAlias',
keyPassword: 'keyPassword',
},
},
}),
'keystore.jks': 'somebinarydata',
});
const promise = credentialsJsonReader.readAndroidCredentialsAsync('.');
await expect(promise).rejects.toThrow(
'credentials.json is not valid [ValidationError: "android.keystore.keystorePath" is required]'
);
});
it('should throw error when file specified in cedentials.json is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
android: {
keystore: {
keystorePath: 'keystore.jks',
keystorePassword: 'keystorePassword',
keyAlias: 'keyAlias',
keyPassword: 'keyPassword',
},
},
}),
});
const promise = credentialsJsonReader.readAndroidCredentialsAsync('.');
await expect(promise).rejects.toThrow(
"ENOENT: no such file or directory, open 'keystore.jks'"
);
});
});
describe('readIosAsync', () => {
it('should read ios credentials if everything is correct', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const result = await credentialsJsonReader.readIosCredentialsAsync('.');
expect(result).toEqual({
provisioningProfile: 'c29tZWJpbmFyeWNvbnRlbnQ=',
distributionCertificate: {
certP12: 'c29tZWJpbmFyeWNvbnRlbnQy',
certPassword: 'certPass',
},
});
});
it('should throw error when credentials.json is missing', async () => {
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow(
'credentials.json must exist in the project root directory and contain a valid JSON'
);
});
it('should throw error if ios field is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow('iOS credentials are missing from credentials.json');
});
it('should throw error if some field is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow(
'credentials.json is not valid [ValidationError: "ios.provisioningProfilePath" is required]'
);
});
it('should throw error if dist cert file is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./pprofile': 'somebinarycontent',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow("ENOENT: no such file or directory, open 'cert.p12'");
});
it('should throw error if provisioningProfile file is missing', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./cert.p12': 'somebinarycontent2',
});
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
await expect(promise).rejects.toThrow("ENOENT: no such file or directory, open 'pprofile'");
});
});
describe('readSecretEnvsAsync', () => {
it('should read secretEnvs 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.readSecretEnvsAsync('.');
expect(result).toEqual({
NPM_TOKEN: 'VALUE',
});
});
});
});
@@ -0,0 +1,335 @@
import fs from 'fs-extra';
import { vol } from 'memfs';
import prompts from 'prompts';
import { asMock } from '../../../__tests__/utils';
import { testKeystore } from '../../__tests__/fixtures-android';
import { createCtxMock } from '../../__tests__/fixtures-context';
import {
testAllCredentialsForApp,
testIosDistCredential,
testProvisioningProfile,
} from '../../__tests__/fixtures-ios';
import { updateAndroidCredentialsAsync, updateIosCredentialsAsync } from '../update';
jest.mock('fs');
jest.mock('prompts');
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();
asMock(prompts).mockReset();
asMock(prompts).mockImplementation(() => {
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
});
});
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),
},
});
vol.fromJSON({
'./credentials.json': JSON.stringify({
android: {
keystore: {
keystorePath: 'keystore.jks',
keystorePassword: 'keystorePassword',
keyAlias: 'keyAlias',
keyPassword: 'keyPassword',
},
},
}),
'keystore.jks': 'somebinarydata',
});
await updateAndroidCredentialsAsync(ctx);
const keystore = await fs.readFile('./keystore.jks', 'base64');
const credJson = await fs.readJson('./credentials.json');
expect(keystore).toEqual(testKeystore.keystore);
expect(credJson).toEqual({
android: {
keystore: {
keystorePath: 'keystore.jks',
keystorePassword: testKeystore.keystorePassword,
keyAlias: testKeystore.keyAlias,
keyPassword: testKeystore.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 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,
},
},
});
});
it('should not do anything if keystore in www is missing', async () => {
const ctx = createCtxMock();
const credJson = {
android: {
keystore: {
keystorePath: 'keystore.jks',
keystorePassword: 'keystorePassword',
keyAlias: 'keyAlias',
keyPassword: 'keyPassword',
},
},
};
vol.fromJSON({
'./credentials.json': JSON.stringify(credJson),
'keystore.jks': 'somebinarydata',
});
try {
await updateAndroidCredentialsAsync(ctx);
throw new Error('updateAndroidCredentialsAsync should throw an error');
} catch (e) {
expect(e.message).toMatch(
'There are no credentials configured for this project on Expo servers'
);
}
const keystore = await fs.readFile('./keystore.jks', 'base64');
const newCredJson = await fs.readJson('./credentials.json');
expect(keystore).toEqual('c29tZWJpbmFyeWRhdGE='); // base64 "somebinarydata"
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),
},
});
vol.fromJSON({
'./credentials.json': JSON.stringify({ android: { test: '123' } }),
});
await updateAndroidCredentialsAsync(ctx);
const keystore = await fs.readFile('./android/keystores/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,
},
},
});
});
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 credJson = {
android: {
keystore: {
keystorePath: 'keystore.jks',
keystorePassword: 'keystorePassword',
keyAlias: 'keyAlias',
keyPassword: 'keyPassword',
},
},
ios: {
test: '123',
},
};
vol.fromJSON({
'./credentials.json': JSON.stringify(credJson),
'keystore.jks': 'somebinarydata',
});
await updateAndroidCredentialsAsync(ctx);
const keystore = await fs.readFile('./keystore.jks', 'base64');
const newCredJson = await fs.readJson('./credentials.json');
expect(keystore).toEqual(testKeystore.keystore);
expect(newCredJson).toEqual({
android: {
keystore: {
keystorePath: 'keystore.jks',
keystorePassword: testKeystore.keystorePassword,
keyAlias: testKeystore.keyAlias,
keyPassword: testKeystore.keyPassword,
},
},
ios: {
test: '123',
},
});
});
});
describe(updateIosCredentialsAsync, () => {
it('should update ios credentials in credentials.json if www returns valid credentials', async () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
},
});
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
}),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
const certP12 = await fs.readFile('./cert.p12', 'base64');
const pprofile = await fs.readFile('./pprofile', 'base64');
const credJson = await fs.readJson('./credentials.json');
expect(certP12).toEqual(testAllCredentialsForApp.distCredentials.certP12);
expect(pprofile).toEqual(testAllCredentialsForApp.credentials.provisioningProfile);
expect(credJson).toEqual({
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: testAllCredentialsForApp.distCredentials.certPassword,
},
},
});
});
it('should create credentials.json provisioning profile and distribution certificate if credentials.json does not exist', async () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
},
});
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
const certP12 = await fs.readFile('./ios/certs/dist-cert.p12', 'base64');
const pprofile = await fs.readFile('./ios/certs/profile.mobileprovision', 'base64');
const credJson = await fs.readJson('./credentials.json');
expect(certP12).toEqual(testAllCredentialsForApp.distCredentials.certP12);
expect(pprofile).toEqual(testAllCredentialsForApp.credentials.provisioningProfile);
expect(credJson).toEqual({
ios: {
provisioningProfilePath: 'ios/certs/profile.mobileprovision',
distributionCertificate: {
path: 'ios/certs/dist-cert.p12',
password: testAllCredentialsForApp.distCredentials.certPassword,
},
},
});
});
it('should not do anything if no credentials are returned from www', async () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => null),
getDistributionCertificateAsync: jest.fn(() => null),
getProvisioningProfileAsync: jest.fn(() => null),
},
});
const credJson = {
ios: {
provisioningProfilePath: 'pprofile',
distributionCertificate: {
path: 'cert.p12',
password: 'certPass',
},
},
};
vol.fromJSON({
'./credentials.json': JSON.stringify(credJson),
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
try {
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
throw new Error('updateIosCredentialsAsync should throw na error');
} catch (e) {
expect(e.message).toMatch(
'There are no credentials configured for this project on Expo servers'
);
}
const certP12 = await fs.readFile('./cert.p12', 'base64');
const pprofile = await fs.readFile('./pprofile', 'base64');
const newCredJson = await fs.readJson('./credentials.json');
expect(certP12).toEqual('c29tZWJpbmFyeWNvbnRlbnQy'); // base64 "somebinarycontent2"
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 () => {
const ctx = createCtxMock({
ios: {
getAppCredentialsAsync: jest.fn(() => ({
...testAllCredentialsForApp,
distCredentialsId: undefined,
distCredentials: undefined,
})),
getDistributionCertificateAsync: jest.fn(() => null),
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
},
});
(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',
});
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
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',
},
});
});
});
});
@@ -0,0 +1,141 @@
import Joi from '@hapi/joi';
import fs from 'fs-extra';
import path from 'path';
import { Keystore } from '../android/credentials';
export interface CredentialsJson {
android?: {
keystore: {
keystorePath: string;
keystorePassword: string;
keyAlias: string;
keyPassword: string;
};
};
ios?: {
provisioningProfilePath: string;
distributionCertificate: {
path: string;
password: string;
};
};
experimental?: {
npmToken?: string;
};
}
const CredentialsJsonSchema = Joi.object({
android: Joi.object({
keystore: Joi.object({
keystorePath: Joi.string().required(),
keystorePassword: Joi.string().required(),
keyAlias: Joi.string().required(),
keyPassword: Joi.string().required(),
}).required(),
}),
ios: Joi.object({
provisioningProfilePath: Joi.string().required(),
distributionCertificate: Joi.object({
path: Joi.string().required(),
password: Joi.string().required(),
}).required(),
}),
experimental: Joi.object({
npmToken: Joi.string(),
}),
});
interface AndroidCredentials {
keystore: Keystore;
}
interface iOSCredentials {
provisioningProfile: string;
distributionCertificate: {
certP12: string;
certPassword: string;
};
}
export async function fileExistsAsync(projectDir: string): Promise<boolean> {
return await fs.pathExists(path.join(projectDir, 'credentials.json'));
}
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');
}
const keystoreInfo = credentialsJson.android.keystore;
return {
keystore: {
keystore: await fs.readFile(getAbsolutePath(projectDir, keystoreInfo.keystorePath), 'base64'),
keystorePassword: keystoreInfo.keystorePassword,
keyAlias: keystoreInfo.keyAlias,
keyPassword: keystoreInfo.keyPassword,
},
};
}
export async function readIosCredentialsAsync(projectDir: string): Promise<iOSCredentials> {
const credentialsJson = await readAsync(projectDir);
if (!credentialsJson.ios) {
throw new Error('iOS credentials are missing from credentials.json');
}
return {
provisioningProfile: await fs.readFile(
getAbsolutePath(projectDir, credentialsJson.ios.provisioningProfilePath),
'base64'
),
distributionCertificate: {
certP12: await fs.readFile(
getAbsolutePath(projectDir, credentialsJson.ios.distributionCertificate.path),
'base64'
),
certPassword: credentialsJson.ios.distributionCertificate.password,
},
};
}
export async function readSecretEnvsAsync(
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);
const { value: credentialsJson, error } = CredentialsJsonSchema.validate(credentialsJSONRaw, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw new Error(`credentials.json is not valid [${error.toString()}]`);
}
return credentialsJson;
}
export async function readRawAsync(projectDir: string): Promise<any> {
const credentialsJsonFilePath = path.join(projectDir, 'credentials.json');
try {
const credentialsJSONContents = await fs.readFile(credentialsJsonFilePath, 'utf8');
return JSON.parse(credentialsJSONContents);
} catch (err) {
throw new Error(
`credentials.json must exist in the project root directory and contain a valid JSON`
);
}
}
function getAbsolutePath(projectDir: string, filePath: string): string {
return path.isAbsolute(filePath) ? filePath : path.join(projectDir, filePath);
}
@@ -0,0 +1,212 @@
import fs from 'fs-extra';
import path from 'path';
import log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { confirmAsync } from '../../prompts';
import { gitStatusAsync } from '../../utils/git';
import { Context } from '../context';
import { CredentialsJson } from './read';
/**
* 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);
if (!keystore) {
throw new Error('There are no credentials configured for this project on Expo servers');
}
const isKeystoreComplete =
keystore.keystore && keystore.keystorePassword && keystore.keyPassword && keystore.keyAlias;
if (!isKeystoreComplete) {
const confirm = await confirmAsync({
message:
'Credentials on Expo 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';
log(`Writing Keystore to ${keystorePath}`);
await updateFileAsync(ctx.projectDir, keystorePath, keystore.keystore);
const shouldWarnKeystore = await isFileUntrackedAsync(keystorePath);
const androidCredentials: Partial<CredentialsJson['android']> = {
keystore: {
keystorePath,
keystorePassword: keystore.keystorePassword,
keyAlias: keystore.keyAlias,
keyPassword: keystore.keyPassword,
},
};
rawCredentialsJsonObject.android = androidCredentials;
await fs.writeJson(credentialsJsonFilePath, rawCredentialsJsonObject, {
spaces: 2,
});
const shouldWarnCredentialsJson = await isFileUntrackedAsync('credentials.json');
const newFilePaths = [];
if (shouldWarnKeystore) {
newFilePaths.push(keystorePath);
}
if (shouldWarnCredentialsJson) {
newFilePaths.push('credentials.json');
}
displayUntrackedFilesWarning(newFilePaths);
}
/**
* Update iOS credentials in credentials.json with values from www, content
* of credentials.json is not validated, if www has incomplete credentials
* credentials.json will be updated partially
*/
export async function updateIosCredentialsAsync(
ctx: Context,
bundleIdentifier: string
): 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;
}
}
const accountName = await getProjectAccountName(ctx.exp, ctx.user);
const appLookupParams = {
accountName,
projectName: ctx.exp.slug,
bundleIdentifier,
};
const profilePath =
rawCredentialsJsonObject?.ios?.provisioningProfilePath ?? 'ios/certs/profile.mobileprovision';
const distCertPath =
rawCredentialsJsonObject?.ios?.distributionCertificate?.path ?? 'ios/certs/dist-cert.p12';
const appCredentials = await ctx.ios.getAppCredentialsAsync(appLookupParams);
const distCredentials = await ctx.ios.getDistributionCertificateAsync(appLookupParams);
if (!appCredentials?.credentials?.provisioningProfile && !distCredentials) {
throw new Error('There are no credentials configured for this project on Expo servers');
}
const areCredentialsComplete =
appCredentials?.credentials?.provisioningProfile &&
distCredentials?.certP12 &&
distCredentials?.certPassword;
if (!areCredentialsComplete) {
const confirm = await confirmAsync({
message:
'Credentials on Expo servers might be invalid or incomplete. Are you sure you want to continue?',
});
if (!confirm) {
log.warn('Aborting...');
return;
}
}
log(`Writing Provisioning Profile to ${profilePath}`);
await updateFileAsync(
ctx.projectDir,
profilePath,
appCredentials?.credentials?.provisioningProfile
);
const shouldWarnPProfile = await isFileUntrackedAsync(profilePath);
log(`Writing Distribution Certificate to ${distCertPath}`);
await updateFileAsync(ctx.projectDir, distCertPath, distCredentials?.certP12);
const shouldWarnDistCert = await isFileUntrackedAsync(distCertPath);
const iosCredentials: Partial<CredentialsJson['ios']> = {
...(appCredentials?.credentials?.provisioningProfile
? { provisioningProfilePath: profilePath }
: {}),
...(distCredentials?.certP12 && distCredentials?.certPassword
? {
distributionCertificate: {
path: distCertPath,
password: distCredentials?.certPassword,
},
}
: {}),
};
rawCredentialsJsonObject.ios = iosCredentials;
await fs.writeJson(credentialsJsonFilePath, rawCredentialsJsonObject, {
spaces: 2,
});
const shouldWarnCredentialsJson = await isFileUntrackedAsync('credentials.json');
const newFilePaths = [];
if (shouldWarnPProfile) {
newFilePaths.push(profilePath);
}
if (shouldWarnDistCert) {
newFilePaths.push(distCertPath);
}
if (shouldWarnCredentialsJson) {
newFilePaths.push('credentials.json');
}
displayUntrackedFilesWarning(newFilePaths);
}
async function updateFileAsync(projectDir: string, filePath: string, base64Data?: string) {
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectDir, filePath);
if (await fs.pathExists(absolutePath)) {
await fs.remove(absolutePath);
}
if (base64Data) {
await fs.mkdirp(path.dirname(filePath));
await fs.writeFile(filePath, Buffer.from(base64Data, 'base64'));
}
}
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)
);
}
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)`
);
} 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)`
);
}
}
@@ -0,0 +1,199 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource, DistributionType } from '@expo/eas-json';
import { IosDistributionType } from '../../graphql/generated';
import log from '../../log';
import { ensureAccounts, findAccountByName } from '../../user/Account';
import { runCredentialsManagerAsync } from '../CredentialsManager';
import { CredentialsProvider } from '../CredentialsProvider';
import { Context } from '../context';
import * as credentialsJsonReader from '../credentialsJson/read';
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
export interface IosCredentials {
provisioningProfile: string;
distributionCertificate: {
certP12: string;
certPassword: string;
};
}
interface PartialIosCredentials {
provisioningProfile?: string;
distributionCertificate?: {
certP12?: string;
certPassword?: string;
};
}
interface Options {
app: AppLookupParams;
distribution: DistributionType;
skipCredentialsCheck?: boolean;
}
interface AppLookupParams {
projectName: string;
accountName: string;
bundleIdentifier: string;
}
export default class IosCredentialsProvider implements CredentialsProvider {
public readonly platform = Platform.iOS;
constructor(private ctx: Context, private options: Options) {}
public async hasRemoteAsync(): Promise<boolean> {
const { distributionCertificate, provisioningProfile } = await this.fetchRemoteAsync();
return !!(
distributionCertificate?.certP12 ||
distributionCertificate?.certPassword ||
provisioningProfile
);
}
public async hasLocalAsync(): Promise<boolean> {
if (this.options.distribution === DistributionType.INTERNAL) {
// TODO: add support for using credentials.json for internal distribution
return false;
}
if (!(await credentialsJsonReader.fileExistsAsync(this.ctx.projectDir))) {
return false;
}
try {
const rawCredentialsJson = await credentialsJsonReader.readRawAsync(this.ctx.projectDir);
return !!rawCredentialsJson?.ios;
} catch (err) {
log.error(err); // malformed json
return false;
}
}
public async isLocalSyncedAsync(): Promise<boolean> {
try {
const [remote, local] = await Promise.all([this.fetchRemoteAsync(), this.getLocalAsync()]);
const r = remote;
const l = local;
return !!(
r &&
l &&
r.provisioningProfile === l.provisioningProfile &&
r.distributionCertificate?.certP12 === l.distributionCertificate.certP12 &&
r.distributionCertificate?.certPassword === l.distributionCertificate.certPassword
);
} catch (_) {
return false;
}
}
public async getCredentialsAsync(
src: CredentialsSource.LOCAL | CredentialsSource.REMOTE
): Promise<IosCredentials> {
switch (src) {
case CredentialsSource.LOCAL:
return await this.getLocalAsync();
case CredentialsSource.REMOTE:
return await this.getRemoteAsync();
}
}
private async getLocalAsync(): Promise<IosCredentials> {
if (this.options.distribution === DistributionType.INTERNAL) {
// TODO: add support for using credentials.json for internal distribution
throw new Error('Using credentials.json for internal distribution is not supported yet.');
}
return await credentialsJsonReader.readIosCredentialsAsync(this.ctx.projectDir);
}
private async getRemoteAsync(): Promise<IosCredentials> {
if (this.options.skipCredentialsCheck) {
log('Skipping credentials check');
} else {
await runCredentialsManagerAsync(
this.ctx,
new SetupBuildCredentials(this.options.app, this.options.distribution)
);
}
const { distributionCertificate, provisioningProfile } = await this.fetchRemoteAsync();
if (!distributionCertificate?.certP12 || !distributionCertificate?.certPassword) {
if (this.options.skipCredentialsCheck) {
throw new Error(
'Distribution certificate is missing and credentials check was skipped. Run without --skip-credentials-check to set it up.'
);
} else {
throw new Error('Distribution certificate is missing');
}
}
if (!provisioningProfile) {
if (this.options.skipCredentialsCheck) {
throw new Error(
'Provisioning profile is missing and credentials check was skipped. Run without --skip-credentials-check to set it up.'
);
} else {
throw new Error('Provisioning profile is missing');
}
}
return {
provisioningProfile,
distributionCertificate: {
certP12: distributionCertificate.certP12,
certPassword: distributionCertificate.certPassword,
},
};
}
private async fetchRemoteAsync(): Promise<PartialIosCredentials> {
if (this.options.distribution === DistributionType.INTERNAL) {
const { app } = this.options;
const account = findAccountByName(ensureAccounts(this.ctx.user.accounts), app.accountName);
if (!account) {
throw new Error(`You do not have access to the ${app.accountName} account`);
}
const appLookupParams = {
account,
bundleIdentifier: this.options.app.bundleIdentifier,
projectName: this.options.app.projectName,
};
// for now, let's require the user to authenticate with Apple
const { team } = await this.ctx.appStore.ensureAuthenticatedAsync();
const appleTeam = await this.ctx.newIos.createOrGetExistingAppleTeamAsync(appLookupParams, {
appleTeamIdentifier: team.id,
appleTeamName: team.name,
});
const [distCert, provisioningProfile] = await Promise.all([
this.ctx.newIos.getDistributionCertificateForAppAsync(
appLookupParams,
appleTeam,
IosDistributionType.AdHoc
),
this.ctx.newIos.getProvisioningProfileAsync(
appLookupParams,
appleTeam,
IosDistributionType.AdHoc
),
]);
return {
provisioningProfile: provisioningProfile?.provisioningProfile ?? undefined,
distributionCertificate: {
certP12: distCert?.certificateP12 ?? undefined,
certPassword: distCert?.certificatePassword ?? undefined,
},
};
} else {
const [distCert, provisioningProfile] = await Promise.all([
this.ctx.ios.getDistributionCertificateAsync(this.options.app),
this.ctx.ios.getProvisioningProfileAsync(this.options.app),
]);
return {
provisioningProfile: provisioningProfile?.provisioningProfile,
distributionCertificate: {
certP12: distCert?.certP12,
certPassword: distCert?.certPassword,
},
};
}
}
}
@@ -1,5 +1,6 @@
import assert from 'assert';
import chalk from 'chalk';
import ora from 'ora';
import log from '../../../log';
import { Action, CredentialsManager } from '../../CredentialsManager';
@@ -31,7 +32,7 @@ export class ConfigureProvisioningProfile implements Action {
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const profile = await ctx.ios.getProvisioningProfileAsync(this.app);
if (!profile) {
throw new Error('No Provisioning Profile to configure.');
throw new Error('No provisioning profile to configure');
}
if (!profile.provisioningProfileId) {
@@ -42,7 +43,7 @@ export class ConfigureProvisioningProfile implements Action {
if (ctx.appStore.authCtx) {
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
if (!distCert) {
log.warn('There is no Distribution Certificate assigned to this app.');
log.warn('There is no distribution certificate for this app.');
return;
}
const profilesFromApple = await ctx.appStore.listProvisioningProfilesAsync(
@@ -58,9 +59,9 @@ export class ConfigureProvisioningProfile implements Action {
await this.configureAndUpdateAsync(ctx, this.app, distCert, profilesWithMatchingId[0]);
} else {
log.warn(
"Without access to your Apple account we can't configure Provisioning Profile for you."
"Without access to your Apple account we can't configure provisioning profiles for you."
);
log.warn('Make sure to recreate it if you switched to new Distribution Certificate.');
log.warn('Make sure to recreate the profile if you selected a new distribution certificate.');
}
}
@@ -76,20 +77,19 @@ export class ConfigureProvisioningProfile implements Action {
profileFromApple,
distCert
);
log(
chalk.green(
`Successfully configured Provisioning Profile ${
profileFromApple.provisioningProfileId
} on Apple Servers with Distribution Certificate ${distCert.certId || ''}`
)
);
// Update profile on expo servers
await ctx.ios.updateProvisioningProfileAsync(app, updatedProfile);
log(
chalk.green(
`Successfully updated Provisioning Profile for @${app.accountName}/${app.projectName} (${app.bundleIdentifier})`
)
);
const bundleIdTag = `(${app.bundleIdentifier})`;
const slugTag = `@${app.accountName}/${app.projectName}`;
const projectTag = `${chalk.bold(slugTag)} ${chalk.dim(bundleIdTag)}`;
const spinner = ora(`Updating Expo profile for ${projectTag}`).start();
try {
// Update profile on expo servers
await ctx.ios.updateProvisioningProfileAsync(app, updatedProfile);
spinner.succeed(`Updated Expo profile for ${projectTag}`);
} catch (error) {
spinner.fail();
throw error;
}
}
}
@@ -33,13 +33,11 @@ export class CreateProvisioningProfile implements Action {
}
private async provideOrGenerateAsync(ctx: Context): Promise<ProvisioningProfile> {
if (!ctx.nonInteractive) {
const userProvided = await askForUserProvidedAsync(provisioningProfileSchema);
if (userProvided) {
// userProvided profiles don't come with ProvisioningProfileId's (only accessible from Apple Portal API)
log.warn('Provisioning profile: Unable to validate specified profile.');
return userProvided;
}
const userProvided = await askForUserProvidedAsync(provisioningProfileSchema);
if (userProvided) {
// userProvided profiles don't come with ProvisioningProfileId's (only accessible from Apple Portal API)
log.warn('Provisioning profile: Unable to validate specified profile.');
return userProvided;
}
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
assert(distCert, 'missing distribution certificate');
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import log from '../../../log';
import log, { learnMore } from '../../../log';
import { promptAsync } from '../../../prompts';
import { CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
@@ -21,7 +21,6 @@ import {
} from '../credentials';
import { findP12CertSerialNumber } from '../utils/p12Certificate';
import { validateDistributionCertificateAsync } from '../validators/validateDistributionCertificate';
import { RemoveSpecificDistributionCertificate } from './RemoveDistributionCertificate';
const APPLE_DIST_CERTS_TOO_MANY_GENERATED_ERROR = `
You can have only ${chalk.underline(
@@ -88,7 +87,7 @@ async function generateDistributionCertificateAsync(
return await ctx.appStore.createDistributionCertificateAsync();
} catch (e) {
if (e instanceof AppleTooManyCertsError) {
const certs = await ctx.appStore.listDistributionCertificatesAsync();
const distCerts = await ctx.appStore.listDistributionCertificatesAsync();
log.warn('Maximum number of Distribution Certificates generated on Apple Developer Portal.');
log.warn(APPLE_DIST_CERTS_TOO_MANY_GENERATED_ERROR);
@@ -98,53 +97,29 @@ async function generateDistributionCertificateAsync(
);
}
const credentials = await ctx.ios.getAllCredentialsAsync(accountName);
const usedByExpo = credentials.userCredentials
.filter((cert): cert is IosDistCredentials => cert.type === 'dist-cert' && !!cert.certId)
.reduce<{ [key: string]: IosDistCredentials }>(
(acc, cert) => ({ ...acc, [cert.certId || '']: cert }),
{}
);
log(
chalk.grey(
`✅ Distribution Certificates can be revoked with no side effects for App Store builds.`
)
);
log(chalk.grey(`ℹ️ Learn more here https://docs.expo.io/distribution/app-signing/#summary`));
log(learnMore('https://docs.expo.io/distribution/app-signing/#summary'));
log.newLine();
const format = (storeCertInfo: DistributionCertificateStoreInfo) => {
const distCert = usedByExpo[storeCertInfo.id];
const usedByApps = distCert
? credentials.appCredentials.filter(app => app.distCredentialsId === distCert.id)
: [];
return formatDistributionCertificateFromApple(storeCertInfo, usedByApps);
};
const { revoke } = await promptAsync({
const { distCertsToRevoke } = await promptAsync({
type: 'multiselect',
name: 'revoke',
name: 'distCertsToRevoke',
message: 'Select certificates to revoke.',
// @ts-expect-error property missing from `@types/prompts`
optionsPerPage: 20,
choices: certs.map((cert, index) => ({
value: index,
title: format(cert),
choices: distCerts.map(distCert => ({
value: distCert,
title: formatDistributionCertificateFromApple(distCert),
})),
});
for (const index of revoke) {
const certInfo = certs[index];
if (certInfo && usedByExpo[certInfo.id]) {
await manager.runActionAsync(
new RemoveSpecificDistributionCertificate(usedByExpo[certInfo.id].id, accountName, {
shouldRevoke: true,
})
);
} else {
await ctx.appStore.revokeDistributionCertificateAsync([certInfo.id]);
}
if (distCertsToRevoke.length > 0) {
const ids = distCertsToRevoke.map(({ id }: DistributionCertificateStoreInfo) => id);
await ctx.appStore.revokeDistributionCertificateAsync(ids);
}
} else {
throw e;
@@ -176,7 +151,7 @@ export async function selectDistributionCertificateAsync(
options.filterInvalid && validDistCredentials ? validDistCredentials : distCredentials;
if (distCredentials.length === 0) {
log.warn('There are no Distribution Certificates available in your EAS account.');
log.warn('There are no Distribution Certificates available in your Expo account.');
return null;
}
@@ -248,23 +223,13 @@ export function formatDistributionCertificate(
}
function formatDistributionCertificateFromApple(
appleInfo: DistributionCertificateStoreInfo,
usedByApps: IosAppCredentials[]
appleInfo: DistributionCertificateStoreInfo
): string {
const joinApps = usedByApps
.map(i => ` ${i.experienceName} (${i.bundleIdentifier})`)
.join('\n');
const usedByString = joinApps
? ` ${chalk.gray(`used by\n${joinApps}`)}`
: ` ${chalk.gray(`not used by any apps`)}`;
const { name, status, id, expires, created, ownerName, serialNumber } = appleInfo;
const expiresDate = new Date(expires * 1000).toDateString();
const createdDate = new Date(created * 1000).toDateString();
return `${name} (${status}) - Cert ID: ${id}, Serial number: ${serialNumber}, Team ID: ${appleInfo.ownerId}, Team name: ${ownerName}
expires: ${expiresDate}, created: ${createdDate}
${usedByString}`;
expires: ${expiresDate}, created: ${createdDate}`;
}
export async function getValidDistCertsAsync(
@@ -17,7 +17,7 @@ export async function selectProvisioningProfileFromAppleAsync(
const profiles = await ctx.appStore.listProvisioningProfilesAsync(bundleIdentifier);
if (profiles.length === 0) {
log.warn(
`There are no Provisioning Profiles available on Apple Developer Portal for bundle identifier ${bundleIdentifier}.`
`There are no provisioning profiles available on Apple Developer Portal for bundle identifier ${bundleIdentifier}.`
);
return null;
}
@@ -25,7 +25,7 @@ export async function selectProvisioningProfileFromAppleAsync(
const { credentialsIndex } = await promptAsync({
type: 'select',
name: 'credentialsIndex',
message: 'Select Provisioning Profile from the list.',
message: 'Select provisioning profile from the list.',
choices: profiles.map((entry, index) => ({
title: formatProvisioningProfileFromApple(entry),
value: index,
@@ -53,7 +53,7 @@ export async function selectProvisioningProfileFromExpoAsync(
({ credentials }) => !!credentials.provisioningProfile && !!credentials.provisioningProfileId
);
if (profiles.length === 0) {
log.warn('There are no Provisioning Profiles available in your EAS account.');
log.warn('There are no provisioning profiles available in your Expo account.');
return null;
}
@@ -66,7 +66,7 @@ export async function selectProvisioningProfileFromExpoAsync(
const { credentialsIndex } = await promptAsync({
type: 'select',
name: 'credentialsIndex',
message: 'Select Provisioning Profile from the list.',
message: 'Select provisioning profile from the list.',
choices: profiles.map((entry, index) => ({
title: format(entry),
value: index,
@@ -1,14 +1,27 @@
import { DistributionType } from '@expo/eas-json';
import assert from 'assert';
import chalk from 'chalk';
import { AppleDevice, IosAppBuildCredentials } from '../../../graphql/generated';
import log from '../../../log';
import { promptAsync } from '../../../prompts';
import { ensureAccounts, findAccountByName } from '../../../user/Account';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { AppLookupParams } from '../credentials';
import { readIosCredentialsAsync } from '../../credentialsJson/read';
import { AppLookupParams, IosAppCredentials, IosDistCredentials } from '../credentials';
import { displayProjectCredentials } from '../utils/printCredentials';
import { readAppleTeam } from '../utils/provisioningProfile';
import { SetupProvisioningProfile } from './SetupProvisioningProfile';
import { SetupAdhocProvisioningProfile } from './new/SetupAdhocProvisioningProfile';
type AppCredentialsAndDistCert = {
appCredentials: IosAppCredentials;
distCert: IosDistCredentials | null;
};
export class SetupBuildCredentials implements Action {
constructor(private app: AppLookupParams) {}
constructor(private app: AppLookupParams, private distribution: DistributionType) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
await ctx.bestEffortAppStoreAuthenticateAsync();
@@ -17,14 +30,161 @@ export class SetupBuildCredentials implements Action {
await ctx.appStore.ensureAppExistsAsync(this.app, { enablePushNotifications: true });
}
let iosAppBuildCredentials: IosAppBuildCredentials | null = null;
try {
await manager.runActionAsync(new SetupProvisioningProfile(this.app));
if (this.distribution === DistributionType.INTERNAL) {
const account = findAccountByName(ensureAccounts(ctx.user.accounts), this.app.accountName);
if (!account) {
throw new Error(`You do not have access to the ${this.app.accountName} account`);
}
if (ctx.nonInteractive) {
throw new Error('Internal distribution builds are not supported in non-interactive mode');
}
// for now, let's require the user to authenticate with Apple
await ctx.appStore.ensureAuthenticatedAsync();
const action = new SetupAdhocProvisioningProfile({
account,
projectName: this.app.projectName,
bundleIdentifier: this.app.bundleIdentifier,
});
await manager.runActionAsync(action);
iosAppBuildCredentials = action.iosAppBuildCredentials;
} else {
await manager.runActionAsync(new SetupProvisioningProfile(this.app));
}
} catch (error) {
log.error('Failed to prepare Provisioning Profile.');
log.error('Failed to setup credentials.');
throw error;
}
const { appCredentials, distCert } = await this.unifyCredentialsFormatAsync(
ctx,
iosAppBuildCredentials
);
const appInfo = `@${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`;
displayProjectCredentials(this.app, appCredentials, /* pushKey */ null, distCert);
log.newLine();
log(chalk.green(`All credentials are ready to build ${appInfo}`));
log.newLine();
}
async unifyCredentialsFormatAsync(
ctx: Context,
iosAppBuildCredentials: IosAppBuildCredentials | null
): Promise<AppCredentialsAndDistCert> {
if (!iosAppBuildCredentials) {
const [appCredentials, distCert] = await Promise.all([
ctx.ios.getAppCredentialsAsync(this.app),
ctx.ios.getDistributionCertificateAsync(this.app),
]);
return {
appCredentials,
distCert,
};
} else {
const { distributionCertificate, provisioningProfile } = iosAppBuildCredentials;
assert(distributionCertificate && provisioningProfile);
return {
appCredentials: {
experienceName: `${this.app.accountName}/${this.app.projectName}`,
bundleIdentifier: this.app.bundleIdentifier,
credentials: {
provisioningProfile: provisioningProfile.provisioningProfile ?? undefined,
provisioningProfileId: provisioningProfile.developerPortalIdentifier ?? undefined,
teamId: provisioningProfile.appleTeam?.appleTeamIdentifier || '',
teamName: provisioningProfile.appleTeam?.appleTeamName ?? undefined,
devices: provisioningProfile.appleDevices?.filter(device => device) as
| AppleDevice[]
| undefined,
},
},
distCert: {
// the id doesn't really matter, it's only for displaying credentials
id: null as any,
type: 'dist-cert',
certId: distributionCertificate.developerPortalIdentifier ?? undefined,
certP12: distributionCertificate.certificateP12 ?? '',
certPassword: distributionCertificate.certificatePassword ?? '',
teamId: distributionCertificate.appleTeam?.appleTeamIdentifier || '',
teamName: distributionCertificate.appleTeam?.appleTeamName ?? undefined,
},
};
}
}
}
export class SetupBuildCredentialsFromCredentialsJson implements Action {
constructor(private app: AppLookupParams) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
let localCredentials;
try {
localCredentials = await readIosCredentialsAsync(ctx.projectDir);
} catch (error) {
log.error(
'Reading credentials from credentials.json failed. Make sure this file is correct and all credentials are present there.'
);
throw error;
}
const team = readAppleTeam(localCredentials.provisioningProfile);
await ctx.ios.updateProvisioningProfileAsync(this.app, {
...team,
provisioningProfile: localCredentials.provisioningProfile,
});
const credentials = await ctx.ios.getAllCredentialsAsync(this.app.accountName);
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
const appsUsingCert = distCert?.id
? (credentials.appCredentials || []).filter(
(cred: IosAppCredentials) => cred.distCredentialsId === distCert.id
)
: [];
const appInfo = `@${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`;
const newDistCert = {
...team,
certP12: localCredentials.distributionCertificate.certP12,
certPassword: localCredentials.distributionCertificate.certPassword,
};
if (appsUsingCert.length > 1 && distCert?.id) {
const { update } = await promptAsync({
type: 'select',
name: 'update',
message:
'Current distribution certificate is used by multiple apps. Do you want to update all of them?',
choices: [
{ title: 'Update all apps', value: 'all' },
{ title: `Update only ${appInfo}`, value: 'app' },
],
});
if (update === 'all') {
await ctx.ios.updateDistributionCertificateAsync(
distCert.id,
this.app.accountName,
newDistCert
);
} else {
const createdDistCert = await ctx.ios.createDistributionCertificateAsync(
this.app.accountName,
newDistCert
);
await ctx.ios.useDistributionCertificateAsync(this.app, createdDistCert.id);
}
} else if (distCert?.id) {
await ctx.ios.updateDistributionCertificateAsync(
distCert.id,
this.app.accountName,
newDistCert
);
} else {
const createdDistCert = await ctx.ios.createDistributionCertificateAsync(
this.app.accountName,
newDistCert
);
await ctx.ios.useDistributionCertificateAsync(this.app, createdDistCert.id);
}
displayProjectCredentials(
this.app,
await ctx.ios.getAppCredentialsAsync(this.app),
@@ -22,6 +22,12 @@ export class SetupDistributionCertificateForApp implements Action {
return;
}
if (ctx.nonInteractive) {
throw new Error(
'Distribution Certificate is not configured correctly. Please run this command again in interactive mode.'
);
}
const existingCertificates = await getValidDistCertsAsync(ctx, this.app.accountName);
if (existingCertificates.length === 0) {
@@ -29,25 +35,23 @@ export class SetupDistributionCertificateForApp implements Action {
return;
}
// autoselect creds if we find valid certs
// autoselect credentials if we find valid certs
const autoselectedCertificate = existingCertificates[0];
if (!ctx.nonInteractive) {
const credentials = await ctx.ios.getAllCredentialsAsync(this.app.accountName);
const usedByApps = credentials.appCredentials.filter(
cred => cred.distCredentialsId === autoselectedCertificate.id
);
const confirm = await confirmAsync({
message: `${formatDistributionCertificate(
autoselectedCertificate,
usedByApps,
ctx.appStore.authCtx ? 'VALID' : 'UNKNOWN'
)} \n Would you like to use this certificate?`,
});
if (!confirm) {
await this.createOrReuseAsync(manager, ctx);
return;
}
const credentials = await ctx.ios.getAllCredentialsAsync(this.app.accountName);
const usedByApps = credentials.appCredentials.filter(
cred => cred.distCredentialsId === autoselectedCertificate.id
);
const confirm = await confirmAsync({
message: `${formatDistributionCertificate(
autoselectedCertificate,
usedByApps,
ctx.appStore.authCtx ? 'VALID' : 'UNKNOWN'
)} \n Would you like to use this certificate?`,
});
if (!confirm) {
await this.createOrReuseAsync(manager, ctx);
return;
}
log(`Using Distribution Certificate: ${autoselectedCertificate.certId || '-----'}`);
@@ -28,6 +28,12 @@ export class SetupProvisioningProfile implements Action {
return;
}
if (ctx.nonInteractive) {
throw new Error(
'Provisioning profile is not configured correctly. Please run this command again in interactive mode.'
);
}
if (!ctx.appStore.authCtx) {
manager.pushNextAction(new CreateProvisioningProfile(this.app));
return;
@@ -43,24 +49,25 @@ export class SetupProvisioningProfile implements Action {
}
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
assert(distCert, 'missing Distribution Certificate');
assert(distCert, 'missing distribution certificate');
const autoselectedProfile = this.choosePreferred(existingProfiles, distCert);
// autoselect creds if we find valid certs
// autoselect credentials if we find valid certs
if (!ctx.nonInteractive) {
const confirm = await confirmAsync({
message: `${formatProvisioningProfileFromApple(
autoselectedProfile
)} \n Would you like to use this profile?`,
)} \n Would you like to use this profile?`,
});
if (!confirm) {
await this.createOrReuseAsync(manager, ctx);
return;
}
} else {
log(`Using provisioning profile: ${autoselectedProfile.provisioningProfileId}`);
}
log(`Using Provisioning Profile: ${autoselectedProfile.provisioningProfileId}`);
await ctx.ios.updateProvisioningProfileAsync(
this.app,
pick(autoselectedProfile, [
@@ -126,7 +133,7 @@ export class SetupProvisioningProfile implements Action {
const { action } = await promptAsync({
type: 'select',
name: 'action',
message: 'Select a Provisioning Profile:',
message: 'Select a provisioning profile:',
choices: [
{
title: '[Choose existing provisioning profile] (Recommended)',
@@ -0,0 +1,24 @@
import { Platform } from '@expo/eas-build-job';
import chalk from 'chalk';
import log from '../../../log';
import { ensureAppIdentifierIsDefinedAsync } from '../../../project/projectUtils';
import { Action, CredentialsManager } from '../../CredentialsManager';
import { Context } from '../../context';
import { updateIosCredentialsAsync } from '../../credentialsJson/update';
import { AppLookupParams } from '../credentials';
export class UpdateCredentialsJson implements Action {
constructor(private app: AppLookupParams) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const bundleIdentifer = await ensureAppIdentifierIsDefinedAsync(ctx.projectDir, Platform.iOS);
log('Updating iOS credentials in credentials.json');
await updateIosCredentialsAsync(ctx, bundleIdentifer);
log(
chalk.green(
'iOS part of your local credentials.json is synced with values stored on Expo servers.'
)
);
}
}
@@ -0,0 +1,36 @@
import assert from 'assert';
import chalk from 'chalk';
import { AppleDistributionCertificate } from '../../../../graphql/generated';
import log from '../../../../log';
import { Action, CredentialsManager } from '../../../CredentialsManager';
import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
import { provideOrGenerateDistributionCertificateAsync } from '../DistributionCertificateUtils';
export class CreateDistributionCertificate implements Action {
private _distributionCertificate?: AppleDistributionCertificate;
constructor(private app: AppLookupParams) {}
public get distributionCertificate(): AppleDistributionCertificate {
assert(
this._distributionCertificate,
'distributionCertificate can be accessed only after calling .runAsync()'
);
return this._distributionCertificate;
}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const distCert = await provideOrGenerateDistributionCertificateAsync(
manager,
ctx,
this.app.account.name
);
this._distributionCertificate = await ctx.newIos.createDistributionCertificateAsync(
this.app,
distCert
);
log(chalk.green('Successfully created Distribution Certificate.'));
}
}
@@ -0,0 +1,43 @@
import { AppleDevice } from '../../../../graphql/generated';
import { APPLE_DEVICE_CLASS_LABELS } from '../../../../graphql/types/credentials/AppleDevice';
import { promptAsync } from '../../.././../prompts';
export async function chooseDevices(
allDevices: AppleDevice[],
preselectedDeviceIdentifiers: string[] = []
): Promise<AppleDevice[]> {
const preselectedDeviceIdentifierSet = new Set(preselectedDeviceIdentifiers);
const isSelected = (device: AppleDevice) =>
preselectedDeviceIdentifierSet.size === 0 ||
preselectedDeviceIdentifierSet.has(device.identifier);
const { devices } = await promptAsync({
type: 'multiselect',
name: 'devices',
message: 'Select devices for the adhoc build:',
hint: '- Space to select. Return to submit',
choices: allDevices.map(device => ({
value: device,
title: formatDeviceLabel(device),
selected: isSelected(device),
})),
instructions: false,
min: 1,
});
return devices;
}
function formatDeviceLabel(device: AppleDevice): string {
const deviceDetails = formatDeviceDetails(device);
return `${device.name ?? device.identifier}${deviceDetails !== '' ? ` ${deviceDetails}` : ''}`;
}
function formatDeviceDetails(device: AppleDevice): string {
let details = '';
if (device.deviceClass) {
details += APPLE_DEVICE_CLASS_LABELS[device.deviceClass];
}
if (device.model) {
details += details === '' ? device.model : ` ${device.model}`;
}
return details === '' ? details : `(${details})`;
}
@@ -0,0 +1,22 @@
import { AppleDistributionCertificate, AppleTeam } from '../../../../graphql/generated';
export function formatDistributionCertificate({
developerPortalIdentifier,
serialNumber,
appleTeam,
validityNotBefore,
validityNotAfter,
}: AppleDistributionCertificate): string {
let line: string = '';
if (developerPortalIdentifier) {
line += `Cert ID: ${developerPortalIdentifier}`;
}
line += `${line === '' ? '' : ', '}Serial number: ${serialNumber}${
appleTeam ? `, ${formatAppleTeam(appleTeam)}` : ''
}, Created: ${validityNotBefore}, Expires: ${validityNotAfter}`;
return line;
}
function formatAppleTeam({ appleTeamIdentifier, appleTeamName }: AppleTeam): string {
return `Team ID: ${appleTeamIdentifier}${appleTeamName ? `, Team name: ${appleTeamName}` : ''}`;
}
@@ -0,0 +1,44 @@
import isEqual from 'lodash/isEqual';
import { AppleDevice, AppleDistributionCertificate } from '../../../../graphql/generated';
import log from '../../../../log';
import { ProvisioningProfileStoreInfo } from '../../appstore/Credentials.types';
export function isDevPortalAdhocProfileValid(
profileFromDevPortal: ProvisioningProfileStoreInfo | null,
distCert: AppleDistributionCertificate,
expectedDevices: AppleDevice[]
): boolean {
if (!profileFromDevPortal) {
return false;
}
if (profileFromDevPortal.status === 'Invalid' || profileFromDevPortal.certificates.length === 0) {
log('Provisioning Profile is invalid');
return false;
}
const currentDistCertId = profileFromDevPortal.certificates[0].id;
if (
distCert.developerPortalIdentifier &&
distCert.developerPortalIdentifier !== currentDistCertId
) {
log(
`Provisioning Profile was generated for Distribution Certificate with ID "${currentDistCertId}", expected ID "${distCert.developerPortalIdentifier}"`
);
return false;
}
const profileDeviceUdids = (profileFromDevPortal.devices ?? []).map(({ udid }) => udid);
const expectedDeviceUdids = expectedDevices.map(({ identifier }) => identifier);
if (!doUDIDsMatch(profileDeviceUdids, expectedDeviceUdids)) {
log('Provisioning Profile was generated for another set of devices');
return false;
}
return true;
}
export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
return isEqual(new Set(udidsA), new Set(udidsB));
}
@@ -0,0 +1,153 @@
import assert from 'assert';
import nullthrows from 'nullthrows';
import {
AppleDevice,
IosAppBuildCredentials,
IosDistributionType,
} from '../../../../graphql/generated';
import { confirmAsync } from '../../../../prompts';
import { Action, CredentialsManager } from '../../../CredentialsManager';
import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
import { ProvisioningProfileStoreInfo } from '../../appstore/Credentials.types';
import { ProfileClass } from '../../appstore/provisioningProfile';
import { chooseDevices } from './DeviceUtils';
import { doUDIDsMatch, isDevPortalAdhocProfileValid } from './ProvisioningProfileUtils';
import { SetupDistributionCertificate } from './SetupDistributionCertificate';
export class SetupAdhocProvisioningProfile implements Action {
private _iosAppBuildCredentials?: IosAppBuildCredentials;
constructor(private app: AppLookupParams) {}
public get iosAppBuildCredentials(): IosAppBuildCredentials {
assert(
this._iosAppBuildCredentials,
'iosAppBuildCredentials can be accessed only after calling .runAsync()'
);
return this._iosAppBuildCredentials;
}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const authCtx = await ctx.appStore.ensureAuthenticatedAsync();
// 0. Fetch apple team object
const appleTeam = await ctx.newIos.createOrGetExistingAppleTeamAsync(this.app, {
appleTeamIdentifier: authCtx.team.id,
appleTeamName: authCtx.team.name,
});
// 1. Fetch devices registered on Expo servers.
const registeredAppleDevices = await ctx.newIos.getDevicesForAppleTeamAsync(
this.app,
appleTeam
);
const registeredAppleDeviceIdentifiers = registeredAppleDevices.map(
({ identifier }) => identifier
);
if (registeredAppleDeviceIdentifiers.length === 0) {
throw new Error(`Run 'eas device:create' to register your devices first`);
}
// 2. Setup Distribution Certificate
const distCertAction = new SetupDistributionCertificate(this.app);
await manager.runActionAsync(distCertAction);
const distCert = distCertAction.distributionCertificate;
// 3. Fetch profile from Expo servers
let currentProfileFromExpoServers = await ctx.newIos.getProvisioningProfileAsync(
this.app,
appleTeam,
IosDistributionType.AdHoc
);
// 4. Choose devices for internal distribution
let chosenDevices: AppleDevice[];
if (currentProfileFromExpoServers) {
const appleDeviceIdentifiersFromProfile = ((currentProfileFromExpoServers.appleDevices ??
[]) as AppleDevice[]).map(({ identifier }) => identifier);
let shouldAskToChooseDevices = true;
if (doUDIDsMatch(appleDeviceIdentifiersFromProfile, registeredAppleDeviceIdentifiers)) {
shouldAskToChooseDevices = await confirmAsync({
message: `All your registered devices are present in the Provisioning Profile. Would you like to exclude some devices?`,
initial: false,
});
}
chosenDevices = shouldAskToChooseDevices
? await chooseDevices(registeredAppleDevices, appleDeviceIdentifiersFromProfile)
: registeredAppleDevices;
} else {
chosenDevices = await chooseDevices(registeredAppleDevices);
}
// 5. Try to find the profile on Apple Developer Portal
const currentProfileFromDevPortal = await this.getProfileFromDevPortalAsync(ctx);
// 6. Validate the profile and possibly recreate it
let profileToUse: ProvisioningProfileStoreInfo;
let profileWasRecreated = false;
if (isDevPortalAdhocProfileValid(currentProfileFromDevPortal, distCert, chosenDevices)) {
profileToUse = nullthrows(currentProfileFromDevPortal);
} else {
const chosenDeviceUDIDs = chosenDevices.map(({ identifier }) => identifier);
await ctx.appStore.createOrReuseAdhocProvisioningProfileAsync(
chosenDeviceUDIDs,
this.app.bundleIdentifier,
distCert.serialNumber
);
profileToUse = nullthrows(await this.getProfileFromDevPortalAsync(ctx));
profileWasRecreated = true;
}
// 7. Send profile to www if new has been created
const appleAppIdentifier = await ctx.newIos.createOrGetExistingAppleAppIdentifierAsync(
this.app,
appleTeam
);
if (currentProfileFromExpoServers) {
if (profileWasRecreated) {
currentProfileFromExpoServers = await ctx.newIos.updateProvisioningProfileAsync(
currentProfileFromExpoServers.id,
{
appleProvisioningProfile: profileToUse.provisioningProfile,
developerPortalIdentifier: profileToUse.provisioningProfileId,
}
);
}
} else {
currentProfileFromExpoServers = await ctx.newIos.createProvisioningProfileAsync(
this.app,
appleAppIdentifier,
{
appleProvisioningProfile: profileToUse.provisioningProfile,
developerPortalIdentifier: profileToUse.provisioningProfileId,
}
);
}
// 8. Create (or update) app build credentials
this._iosAppBuildCredentials = await ctx.newIos.createOrUpdateIosAppBuildCredentialsAsync(
this.app,
{
appleTeam,
appleAppIdentifierId: appleAppIdentifier.id,
appleDistributionCertificateId: distCert.id,
appleProvisioningProfileId: currentProfileFromExpoServers.id,
iosDistributionType: IosDistributionType.AdHoc,
}
);
}
private async getProfileFromDevPortalAsync(
ctx: Context
): Promise<ProvisioningProfileStoreInfo | null> {
const profilesFromDevPortal = await ctx.appStore.listProvisioningProfilesAsync(
this.app.bundleIdentifier,
ProfileClass.Adhoc
);
return profilesFromDevPortal.length >= 1 ? profilesFromDevPortal[0] : null;
}
}
@@ -0,0 +1,160 @@
import assert from 'assert';
import sortBy from 'lodash/sortBy';
import { AppleDistributionCertificate, IosDistributionType } from '../../../../graphql/generated';
import log from '../../../../log';
import { confirmAsync, promptAsync } from '../../../../prompts';
import { Action, CredentialsManager } from '../../../CredentialsManager';
import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
import { getValidCertSerialNumbers } from '../../appstore/CredentialsUtils';
import { CreateDistributionCertificate } from './CreateDistributionCertificate';
import { formatDistributionCertificate } from './DistributionCertificateUtils';
export class SetupDistributionCertificate implements Action {
private validDistCerts?: AppleDistributionCertificate[];
private _distributionCertificate?: AppleDistributionCertificate;
constructor(private app: AppLookupParams) {}
public get distributionCertificate(): AppleDistributionCertificate {
assert(
this._distributionCertificate,
'distributionCertificate can be accessed only after calling .runAsync()'
);
return this._distributionCertificate;
}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
assert(
ctx.appStore.authCtx,
'authCtx is defined in this context - enforced by ensureAuthenticatedAsync call in SetupBuildCredentials'
);
const appleTeam = await ctx.newIos.createOrGetExistingAppleTeamAsync(this.app, {
appleTeamIdentifier: ctx.appStore.authCtx.team.id,
appleTeamName: ctx.appStore.authCtx.team.name,
});
const currentCertificate = await ctx.newIos.getDistributionCertificateForAppAsync(
this.app,
appleTeam,
IosDistributionType.AdHoc
);
if (await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) {
assert(currentCertificate, 'currentCertificate is defined here');
this._distributionCertificate = currentCertificate;
return;
}
const validDistCertsOnFile = await this.getValidDistCertsAsync(ctx);
this._distributionCertificate =
validDistCertsOnFile.length === 0
? await this.createNewDistCertAsync(manager)
: await this.createOrReuseDistCert(manager, ctx);
}
private async isCurrentCertificateValidAsync(
ctx: Context,
currentCertificate: AppleDistributionCertificate | null
): Promise<boolean> {
if (!currentCertificate) {
return false;
}
const validCertSerialNumbers = (await this.getValidDistCertsAsync(ctx)).map(
i => i.serialNumber
);
const isValid = validCertSerialNumbers.includes(currentCertificate.serialNumber);
if (!isValid) {
log.warn('Current distribution certificate is no longer valid in Apple Dev Portal');
}
return isValid;
}
private async createOrReuseDistCert(
manager: CredentialsManager,
ctx: Context
): Promise<AppleDistributionCertificate> {
const validDistCerts = await this.getValidDistCertsAsync(ctx);
const autoselectedDistCert = validDistCerts[0];
const useAutoselected = await confirmAsync({
message: `Reuse this distribution certificate?\n${formatDistributionCertificate(
autoselectedDistCert
)}`,
});
if (useAutoselected) {
log(`Using distribution certificate with serial number ${autoselectedDistCert.serialNumber}`);
return autoselectedDistCert;
}
const { action } = await promptAsync({
type: 'select',
name: 'action',
message: 'Select the iOS distribution certificate to use for code signing:',
choices: [
{
title: '[Choose another existing certificate] (Recommended)',
value: 'CHOOSE_EXISTING',
},
{ title: '[Add a new certificate]', value: 'GENERATE' },
],
});
if (action === 'GENERATE') {
return await this.createNewDistCertAsync(manager);
} else {
return await this.reuseDistCertAsync(ctx);
}
}
private async createNewDistCertAsync(
manager: CredentialsManager
): Promise<AppleDistributionCertificate> {
const action = new CreateDistributionCertificate(this.app);
await manager.runActionAsync(action);
return action.distributionCertificate;
}
private async reuseDistCertAsync(ctx: Context): Promise<AppleDistributionCertificate> {
const validDistCerts = await this.getValidDistCertsAsync(ctx);
const { distCert } = await promptAsync({
type: 'select',
name: 'distCert',
message: 'Select the iOS Distribution Certificate from the list:',
choices: validDistCerts.map(distCert => ({
title: formatDistributionCertificate(distCert),
value: distCert,
})),
});
return distCert;
}
private async getValidDistCertsAsync(ctx: Context): Promise<AppleDistributionCertificate[]> {
if (this.validDistCerts) {
return this.validDistCerts;
}
const validDistCertSerialNumbers = getValidCertSerialNumbers(
await ctx.appStore.listDistributionCertificatesAsync()
);
const validDistCertSerialNumberSet = new Set(validDistCertSerialNumbers);
const distCertsForAccount = await ctx.newIos.getDistributionCertificatesForAccountAsync(
this.app
);
const distCertsForAppleTeam = distCertsForAccount.filter(distCert => {
return (
!distCert.appleTeam ||
distCert.appleTeam.appleTeamIdentifier === ctx.appStore.authCtx?.team.id
);
});
const validDistCerts = distCertsForAppleTeam.filter(distCert => {
return validDistCertSerialNumberSet.has(distCert.serialNumber);
});
this.validDistCerts = sortBy(validDistCerts, 'validityNotAfter', 'desc');
return this.validDistCerts;
}
}
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff. Show more