Compare commits

...
Author SHA1 Message Date
evanbacon 2d1bf434ec feat(eas-cli): Add support for VisionOS in provisioning profiles and tests 2025-01-29 13:30:41 -08:00
Evan Bacon ab507d0bdd feat(eas-cli): Use a fallback generated name during EAS Submit (#2842) 2025-01-28 16:49:35 -08:00
Radosław Krzemień 7505b8d40a [eas-cli] [ENG-14633] Start autoIncremented builds at 1 (#2828)
* [eas-cli] Don't bump default version for Android

When the versionCode is initialized with 1, don't bump it even if autoIncrement=true

See: https://linear.app/expo/issue/ENG-14633/start-autoincrement-counter-at-0-so-first-build-has-buildnumber-==-1

* [eas-cli] Add tests for Android version

Testing the version resolving behavior for Android

See: https://linear.app/expo/issue/ENG-14633/start-autoincrement-counter-at-0-so-first-build-has-buildnumber-==-1

* [eas-cli] Don't bump default version for Ios

When the buildNumber is 1, don't bump it even if autoIncrement=true

See: https://linear.app/expo/issue/ENG-14633/start-autoincrement-counter-at-0-so-first-build-has-buildnumber-==-1

* [eas-cli] Add tests for Ios version

Testing the version resolving behavior for Ios

See: https://linear.app/expo/issue/ENG-14633/start-autoincrement-counter-at-0-so-first-build-has-buildnumber-==-1

* [eas-cli] Lint

Lint

See: https://linear.app/expo/issue/ENG-14633/start-autoincrement-counter-at-0-so-first-build-has-buildnumber-==-1

* [eas-cli] Add tests

Added tests for remote version autoIncrement=true

See: https://linear.app/expo/issue/ENG-14633/start-autoincrement-counter-at-0-so-first-build-has-buildnumber-==-1

* [eas-cli] Add changelog

Updated CHANGELOG.md

See: https://linear.app/expo/issue/ENG-14633/start-autoincrement-counter-at-0-so-first-build-has-buildnumber-==-1
2025-01-28 15:56:40 +01:00
Expo Bot b31ddee6e7 Upgrade eas-build dependencies (#2840) 2025-01-27 13:58:25 +01:00
Expo CI 01ce802b73 update CHANGELOG.md 2025-01-22 13:31:34 +00:00
Radoslaw Krzemien f75ef22154 v14.5.0 2025-01-22 14:24:59 +01:00
Radosław Krzemień 7d3f4e8fd4 [eas-json] [ENG-14636] Allow longer submit profile extension chains (#2831)
* [eas-json] Extend submit profile expansion chain

Extended the limit for expansion chain for submit profiles to equal that of build profiles - 5

See: https://linear.app/expo/issue/ENG-14636/allow-longer-profile-extend-chains-in-easjson

* [eas-json] Add tests for submit profiles

Added tests for submit profiles extension chain

See: https://linear.app/expo/issue/ENG-14636/allow-longer-profile-extend-chains-in-easjson

* [eas-json] Add tests for build profiles

Added tests for build profiles extension chain

See: https://linear.app/expo/issue/ENG-14636/allow-longer-profile-extend-chains-in-easjson

* [eas-json] Lint

Lint

See: https://linear.app/expo/issue/ENG-14636/allow-longer-profile-extend-chains-in-easjson

* [eas-json] Add changelog

Updated CHANGELOG.md

See: https://linear.app/expo/issue/ENG-14636/allow-longer-profile-extend-chains-in-easjson
2025-01-22 14:23:07 +01:00
Radosław Krzemień 004caf4bb7 [eas-cli] [ENG-14629] Improve AppleID validation (#2830)
* [eas-cli] Remove invalid unprintable characters from AppleID

Clears the AppleID received from the prompt

See: https://linear.app/expo/issue/ENG-14629/improve-apple-id-input-validation

* [eas-cli] Add tests

Adds tests for `resolveUserCredentialsAsync` function, including reading malformed AppleID from the prompt

See: https://linear.app/expo/issue/ENG-14629/improve-apple-id-input-validation

* [eas-cli] Remove line

Removed redundant expect()

See: https://linear.app/expo/issue/ENG-14629/improve-apple-id-input-validation

* [eas-cli] Add context

Added a comment with context for the change

See: https://linear.app/expo/issue/ENG-14629/improve-apple-id-input-validation

* [eas-cli] Add changelog

Updated CHANGELOG.md

See: https://linear.app/expo/issue/ENG-14629/improve-apple-id-input-validation
2025-01-22 14:17:14 +01:00
Kadi Kraman 5a16f3d6d8 [eas-cli] Detect valid JSON files during hosting upload (#2829)
* Use application/json as content type for valid JSON without an extension

* Add changelog

* Pull getContentTypeAsync to a separate function
2025-01-21 10:51:32 +00:00
Expo Bot d2e4cb5fc6 Upgrade eas-build dependencies (#2826) 2025-01-18 16:29:59 +01:00
Kadi Kraman 2c1c9b91cb [eas-cli] Use deploy as top level hosting command (#2824)
* Replace preview with help

* Remove redundant alias

* Update description

* Rename top level command from worker -> deploy

* Add changelog

* End sentence with a .

* Add worker:deploy as an alias to deploy
2025-01-17 12:16:02 +00:00
Will Schurman b447194c85 [eas-cli] Make variable naming more explicit, remove deprecated runtimeFingerprintSource uses (#2816) 2025-01-16 08:51:32 -08:00
Kadi Kraman 9ab095597a [eas-cli] fix deploy command typos (#2822)
* EAS hosting is in preview (not beta)

* Remove errant curly

* Add changelog
2025-01-16 13:09:44 +00:00
Szymon Dziedzic b1edff7242 [eas-cli] allow filtering by fingerprint-hash in eas build:list command (#2818)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Allow filtering by `--fingerprint-hash` in the `eas build:list` command

# How

Update GQL schema to add a new filter field, and add a new flag to use it.

# Test Plan

Test locally
2025-01-16 00:38:54 +01:00
Expo CI 4d9e37269c update CHANGELOG.md 2025-01-15 09:48:15 +00:00
Szymon Dziedzic e7b48949db v14.4.1 2025-01-15 10:43:15 +01:00
Mubin Ansari 1c3edbe492 Bump eas-cli-local-build-plugin to latest version to support new bun lock file (#2817)
* Bump eas-cli-local-build-plugin to latest version to support new bun lock file

* Update changelog
2025-01-15 10:42:38 +01:00
Will Schurman deaa3a9105 [eas-cli] Enhance eas env:exec command by enabling shell execution for commands (#2811) 2025-01-13 09:03:06 -08:00
Quinlan Jung cbfcdaec5d [eas-cli] upload fingerprint from local project state (#2808)
* [eas-cli] upload fingerprint from local project state

* Temporary Commit at 1/10/2025, 8:37:46 AM
2025-01-10 11:38:24 -08:00
Expo Bot 37a32c93ee Upgrade eas-build dependencies (#2810) 2025-01-10 15:46:42 +01:00
Szymon Dziedzic 6461a78fa2 [ENG-14644][eas-cli] make "No remote versions are configured" message green instead of yellow (#2805)
* [eas-cli] make "No remote versions are configured" message green instead of yellow

* add changelog
2025-01-10 11:09:18 +01:00
Szymon Dziedzic 86c8ac093f [ENG-14643][eas-cli] make automatic env resolution message shorter (#2806)
* [eas-cli] make automatic env resolution message shorter

* add changelog entry
2025-01-10 11:03:52 +01:00
Expo CI 608cdff981 update CHANGELOG.md 2025-01-09 16:53:02 +00:00
Phil Pluckthun 4dc4ef85fa v14.4.0 2025-01-09 16:47:28 +00:00
Phil Pluckthun eaf15acb76 feat(eas-cli): Unhide deploy and deploy:alias commands (#2807)
* Unhide deploy command

* Add CHANGELOG entry
2025-01-09 12:47:46 +00:00
Will Schurman ae4ca91658 [eas-cli] Silence all non-command output in non-interactive mode of eas env:exec (#2800) 2025-01-08 09:09:23 -08:00
Phil PluckthunandKadi Kraman 23ebb77952 [eas-cli] Combine .env and EAS environment variables for deploy command (#2783)
* Allow env vars to be loaded from .env and EAS env simultaneously

* Add CHANGELOG entry

* Apply lints

* Switch to new @expo/[email protected] API

* Update packages/eas-cli/src/commands/worker/deploy.ts

Co-authored-by: Kadi Kraman <[email protected]>

---------

Co-authored-by: Kadi Kraman <[email protected]>
2025-01-08 17:02:27 +00:00
Expo CI ed2352f289 update CHANGELOG.md 2025-01-08 13:53:33 +00:00
Szymon Dziedzic 38ae8b68f1 v14.3.1 2025-01-08 14:48:06 +01:00
Kudo Chien 5e02807ac9 [eas-cli] bump package-manager version (#2801)
# Why

fixes https://github.com/expo/eas-cli/issues/2782

# How

`@expo/[email protected]` supports bun text-based lock file. this pr bumps the version
2025-01-08 21:44:38 +08:00
Piotr Szeremeta f05d6ca3a2 ENG-14518 Add keystoreName field to build profile (#2775)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-14518: Add `keystoreName` field to build profile to allow selecting/creating named keystore to use for a build](https://linear.app/expo/issue/ENG-14518/add-keystorename-field-to-build-profile-to-allow-selectingcreating)

Allow specifying keystore name of credentials to be used with a build.

# How

* Added `keystoreName` to eas.json schema
* Pass `name` option to `AndroidCredentialsProvider`

# Test Plan

Added test, tested manually.

1. Create a new android credentials with `eas credentials`
2. Specify `keystoreName` in build profile in `eas.json`
3. Ensure that it works only for android builds
4. Ensure it works only for `remote` `credentialsSource`
5. Ensure that the correct credentials are used

<img width="326" alt="image" src="https://github.com/user-attachments/assets/1245c459-5fa1-4253-b5fc-bdf7a68b814a" />

<img width="628" alt="image" src="https://github.com/user-attachments/assets/5bca4cf7-c895-462c-b527-ef37ce659aca" />
2025-01-07 13:07:47 +01:00
Expo CI b57082647d update CHANGELOG.md 2025-01-07 10:50:10 +00:00
Stanisław Chmiela 9d4fdc3c75 v14.3.0 2025-01-07 11:44:47 +01:00
Stanisław Chmiela d1af9e385f [eas-cli] Upload package.json alongside eas.json when creating a workflow run (#2786)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Thanks to @szdziedzic's changes to `www`, if we send a `package.json` along with `eas.json` when setting up a workflow run, EAS will pick the right images based on SDK and RN versions as `eas build` would.

# How

Added uploading `package.json` after uploading `eas.json`, added the new argument to the mutation.

# Test Plan

Tested manually.

<img width="874" alt="Zrzut ekranu 2024-12-20 o 22 27 20" src="https://github.com/user-attachments/assets/383c4a19-17a1-4f46-8113-bc6dc8aaba2e" />
<img width="833" alt="Zrzut ekranu 2024-12-20 o 22 27 58" src="https://github.com/user-attachments/assets/be9e2396-0779-4043-925f-d784b5168764" />
2025-01-07 11:05:46 +01:00
Quinlan Jung 59bbe19dde [eas-cli][eas-json] use node18 as tsconfig base (#2739)
* [eas-cli][eas-json] use node18 as tsconfig base

* Temporary Commit at 1/6/2025, 9:06:36 AM

* Temporary Commit at 1/6/2025, 9:17:06 AM
2025-01-06 09:44:24 -08:00
Bartosz Kaszubowski 5e5b55f9e4 [eas-cli] wording tweaks for messages (#2790) 2025-01-02 15:40:00 +01:00
Kadi Kraman a1d086ffc6 [eas-cli] parse request body for error only when needed (#2784)
* Parse error only when needed

* Add changelog

* Fix linting
2024-12-20 15:36:39 +00:00
Phil Pluckthun 2a96d04be3 [eas-cli] Update deploy/alias log output copy (#2780)
* Update product name

* Highlight `Project export` text

* Update export copy

* Update product name for alias

* Add changelog entry
2024-12-19 13:04:33 +00:00
Phil Pluckthun d126ee988a [eas-cli] Prevent eas-cli dependencies check from running repeatedly (#2781)
* Only check for dependencies once

* Add CHANGELOG entry

* Apply lints
2024-12-19 13:03:58 +00:00
Expo Bot 4bb550e3bb Upgrade eas-build dependencies (#2776) 2024-12-18 13:12:27 +01:00
Kadi Kraman 6a55c65908 [eas-cli] show deployment errors (#2771)
* Show upload errors

* Add changelog

* Fix changelog

* Show error from request body when applicable
2024-12-13 17:08:53 +00:00
Expo CI ab3495da5e update CHANGELOG.md 2024-12-13 15:25:41 +00:00
Szymon Dziedzic 63b67635e0 v14.2.0 2024-12-13 16:20:07 +01:00
Szymon Dziedzic 4b5cda3bbc [eas-cli] fix request for switching providers (#2769)
* [eas-cli] fix request for switching providers

* add changelog entry
2024-12-13 16:19:04 +01:00
Szymon Dziedzic 63e3c1f2ab [eas-cli] improvements to eas env:exec command (#2762)
* [eas-cli] improvements to eas env:exec command

* add changelog
2024-12-13 16:05:02 +01:00
Expo Bot 3f1543989f Upgrade eas-build dependencies (#2768) 2024-12-13 16:04:25 +01:00
Piotr Szeremeta 1c964d2ab1 [ENG-14328] Pass environment to PublishMutation.publishUpdateGroupAsync (#2757)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-14328: store `environment` info on update entity](https://linear.app/expo/issue/ENG-14328/store-environment-info-on-update-entity)

Pass `environment` to `PublishMutation.publishUpdateGroupAsync` to store in the db.

Waits for changes on the `www`. https://github.com/expo/universe/pull/17921
2024-12-13 10:41:08 +01:00
Kadi Kraman bc282b6262 [eas-cli] Add environment flag to deploy script (#2763)
* Ensure worker env flag is passed through

* Add changelog
2024-12-11 16:43:43 +00:00
Phil Pluckthun 17661d1cf0 feat(eas-cli): Add --dry-run flag to deploy command (#2761)
* Add dry-run option to deploy command

* Add `--dry-run` flag to deploy command

* Update CHANGELOG
2024-12-11 16:28:12 +00:00
Will Schurman 8d35745124 [eas-cli] Upgrade @expo/multipart-body-parser (#2751) 2024-12-10 10:10:25 -08:00
Will Schurman 3740ee4ef9 [eas-cli] Remove random branch name generation for --auto branch name non-vcs fallback (#2747) 2024-12-10 08:56:01 -08:00
Expo CI 751d3d1287 update CHANGELOG.md 2024-12-10 10:30:23 +00:00
Szymon Dziedzic 1fd14c07e1 v14.1.0 2024-12-10 11:24:35 +01:00
Szymon Dziedzic 39ea1705d8 [eas-cli] add support for broadcast push notifications option for push notifications capability (#2748)
* [eas-cli] add support for broadcast push notifications option for push notifications capability

* Apply suggestions from code review

* bump apple utils

* simplify logic

* add changelog

* add tests
2024-12-10 11:23:21 +01:00
dependabot[bot] 2c96b9929d Bump nanoid from 3.3.4 to 3.3.8 in /packages/eas-cli (#2754)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.4 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.4...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-10 10:01:17 +01:00
Szymon Dziedzic 0dae24ae47 [eas-cli] bump @expo/apple-utils to add fyi link for apple 2fa sms failure (#2752)
* [eas-cli] bump @expo/apple-utils to add fyi link for apple 2fa sms failure

* add changelog
2024-12-10 09:51:05 +01:00
Expo CI a63efe01a1 update CHANGELOG.md 2024-12-09 20:37:15 +00:00
Szymon Dziedzic 803457b37a v14.0.3 2024-12-09 20:57:35 +01:00
Evan Bacon dcb1305501 fix SMS TFA for Apple authentication (#2750)
* fix SMS TFA for Apple authentication

* Update CHANGELOG.md
2024-12-09 20:53:25 +01:00
Szymon Dziedzic 97fe2d62d2 add changelog entry 2024-12-06 15:41:15 +01:00
JonLuca De Caro ca0850b82b Change package.json update message to allow faster copy and paste (#2661) 2024-12-06 15:38:51 +01:00
Szymon Dziedzic 039a7b408b add changelog entry 2024-12-06 15:10:55 +01:00
Saiichi Shayan Hashimoto 68660ae845 Allow $schema in eas.json (#2624)
* Allow $schema in eas.json

* Add $schema to eas.schema.json
2024-12-06 15:08:11 +01:00
Expo CI 34bb2c8a75 update CHANGELOG.md 2024-12-06 12:48:28 +00:00
Szymon Dziedzic ac5cf3d7dd v14.0.2 2024-12-06 13:41:10 +01:00
Szymon Dziedzic 8d686b855b [eas-cli] make version/CFBundleShortVersionString validation logic show warn instead of throwing an error (#2741)
* [eas-cli] make version/CFBundleShortVersionString validation logic show warn instead of throwing an error

* fix tests

* add changelog entry
2024-12-06 13:39:52 +01:00
Expo CI 19973bb86f update CHANGELOG.md 2024-12-06 12:24:10 +00:00
Szymon Dziedzic d132ac09de v14.0.1 2024-12-06 13:18:24 +01:00
Szymon Dziedzic 4c5e5d4ded upgrade eas-cli-local-build-plugin to 1.0.152 2024-12-06 13:17:22 +01:00
Szymon Dziedzic 44af16efe1 bump @expo/apple-utils to improve error handling and input validation and change auth headers (#2745)
* bump @expo/apple-utils to improve error handling and input validation and change auth headers

* add changelog entry
2024-12-06 12:28:12 +01:00
Expo CI f34487cc8e update CHANGELOG.md 2024-12-06 09:10:27 +00:00
Szymon Dziedzic 7ea1c5de17 v14.0.0 2024-12-06 10:04:57 +01:00
Szymon Dziedzic 261f611770 [ENG-14410] update @expo/config and @expo/config-plugins to fix eas build command for bare iOS projects on Windows (#2744)
* [ENG-14410] update @expo/config and @expo/config-plugins to fix eas build command for bare iOS projects on Windows

* add changelog entry
2024-12-06 10:04:00 +01:00
Bartosz Kaszubowski f9337f419a [eas-cli] tweak message related to channel creation (#2735) 2024-12-05 12:01:57 +01:00
Szymon Dziedzic 4280e10836 [codemention] make codemention.yml up to date (#2740)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

It seems like this file includes redundant entries + is not up to date

# How

Remove redundant entries, add @radoslawkrzemien to global codeowners, make @wschurman and @quinlanj codeowners of everything EAS Update related (including channel commands), remove @byCedric and @EvanBacon from EAS Update codeowners

# Test Plan

Review
2024-12-05 10:16:58 +01:00
Quinlan Jung a2fe99c97e [eas-cli] fingerprint:compare (#2736)
* [eas-cli] fingerprint:compare

* Temporary Commit at 12/2/2024, 1:43:01 PM

* brent feedback

* Temporary Commit at 12/3/2024, 4:46:13 PM
2024-12-03 17:06:16 -08:00
Szymon Dziedzic 868fd399a4 [eas-cli] fix warning for gitignored google services file (#2730)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://discord.com/channels/695411232856997968/1309202098591633458/1311040361040183429

# How

Make warning up to date after recent env vars changes
2024-12-02 20:04:00 +01:00
Expo Bot 7d60ba2cea Upgrade eas-build dependencies (#2734)
⬆️ Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.
2024-12-02 13:23:37 +01:00
Will Schurman db7461a569 [eas-cli][eas-update] Change behavior of roll-back-to-embedded to not use current project state (#2722) 2024-11-25 10:56:10 -08:00
Expo CI facffbdcd7 update CHANGELOG.md 2024-11-25 11:57:06 +00:00
Szymon Dziedzic dbdefa0e3d v13.4.2 2024-11-25 12:51:08 +01:00
Szymon Dziedzic 66daeade84 [eas-cli] update expo packages to SDK 52 versions (#2706)
# Why

Update `@expo` packages to SDK 52 versions

# How

Update `@expo` packages to SDK 52 versions

Fixes #2696

It seems like the `privacy` field was removed from the expo config in the SDK 52 update: https://expo.dev/changelog/2024/11-12-sdk-52. 

I used `AppPrivay.Unlisted` for the `createApp` mutation input for which this is still required. It was an option selected for an app when `exp.privacy` was undefined.

It also seems that `findWorkspaceRoot` function in `@expo/package-manager` was changed to `resolveWorkspaceRoot`.

# Test Plan

Tests, test commands manually
2024-11-25 12:44:30 +01:00
Expo CI 48ebd9bb78 update CHANGELOG.md 2024-11-22 15:17:53 +00:00
Szymon Dziedzic 3d3574d351 v13.4.1 2024-11-22 15:59:13 +01:00
Szymon Dziedzic 8da6adf4da [eas-cli] improve eas env:pull logging (#2720)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://exponent-internal.slack.com/archives/C06FK950085/p1732258126230829

# How

Improve logs

# Test Plan

Tests
2024-11-22 15:58:28 +01:00
Expo CI bd2d39f26b update CHANGELOG.md 2024-11-22 13:12:02 +00:00
Szymon Dziedzic e16e1f3871 v13.4.0 2024-11-22 14:05:01 +01:00
Szymon Dziedzic d8bd6a1548 [eas-cli] add environment to eas.schema.json (#2719)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Add environment to `eas.schema.json`

# How

Add environment to `eas.schema.json`
2024-11-22 14:03:07 +01:00
Szymon Dziedzic 0f2fad3a65 [ENG-13252][eas-cli] resolve default environment automatically based on build configuration (#2644)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://exponent-internal.slack.com/archives/C06FK950085/p1729212611408479?thread_ts=1729186741.835629&cid=C06FK950085

# How

Resolve the default (if not set in the build profile) `environment` to use for a build based on its configuration.

Store build -> `production`
Dev client -> `development`
Other (`internal`) -> `preview`

# Test Plan

Test manually
2024-11-22 11:33:11 +01:00
Szymon Dziedzic 78e05c48c8 [eas-cli] make eas env flags visibile (#2717)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Make `eas env` commands visible

# How

Make `eas env` commands visible

# Test Plan

Tests
2024-11-22 10:41:17 +01:00
Szymon Dziedzic fa10404843 [ENG-14249][eas-cli] deprecate eas secret commands (#2705)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Deprecate old secret commands

# How

Mark as hidden + print warn when used

# Test Plan

Tests
2024-11-22 10:27:42 +01:00
Piotr Szeremeta 5bf2c3a65b All account-wide envvars should be global (for now) (#2713) 2024-11-21 09:49:57 +01:00
Quinlan Jung ec71e1168f [eas-cli] pass on fingerprint info on republish (#2708)
# Why

This PR adds support for fingerprint information in update republishing workflows. If the republished update has fingerprint info, we include it. 

# Before landing

- [ ] merge https://github.com/expo/universe/pull/17575
- [ ] rerun graphql generation against staging

# How

- Added fingerprint fields to the GraphQL schema and related types
- Updated the Update fragment to include fingerprint information (id, hash, and source details)
- Modified the republish functionality to include fingerprint information when republishing updates
- Added support for fingerprint source types and bucket key information

# Test Plan

- [x] republished update with fingerprint info https://staging.expo.dev/accounts/quintest110/projects/staging-sdk-52/updates/dcf49334-3330-4d37-8ef5-50c2b7bea414
- [x] republished update without fingerprint info https://staging.expo.dev/accounts/quintest110/projects/staging-sdk-52/updates/fd7f062b-cc2e-4c54-b306-36587dc841f6
2024-11-20 13:44:17 -08:00
Quinlan Jung 8bf28029dc [eas-cli] create fingerprint on each update (#2687)
# Why

This PR computes a fingerprint for each update. 
- If the update does not use the fingerprint policy, it calculates the fingerprint using the exported cli in the `expo` package for projects SDK 52+. 
- If the update uses the fingerprint policy, it just uses the fingerprint that was previously calculated using `expo-updates`.  

# How

- Modified fingerprint computation to handle platform-specific cases
- Updated the fingerprint data structure to use `fingerprintHash` instead of the full fingerprint object
- Added support for computing fingerprints for projects without expo-updates
- Introduced new types and interfaces for handling fingerprint information groups
- Created utility functions for async map operations and fingerprint calculations
- Updated GraphQL schema to support new fingerprint-related fields

# Test Plan

- [x] on build: verify fingerprints till work 
- [x] on update: verify fingerprint is calculated for new project with SDK 52
- [x] on update: verify no fingerprint is calculated for old project (< SDK 52)
- [x] on update: verify fingerprint is reused for project with fingerprint runtime policy
2024-11-20 13:43:06 -08:00
Szymon Dziedzic fc3c1e73a8 [ENG-14223][eas-cli] improve class names and descriptions of env commands (#2711)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Make class names more consistent with other commands

Improve descriptions

# How

Make class names more consistent with other commands

Improve descriptions

# Test Plan

Tests
2024-11-20 17:51:15 +01:00
Szymon Dziedzic bb3915dc7e [ENG-14185][eas-cli] use account-wide vs shared in env commands (#2710)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Stick to account-wide everywhere for now

# How

Rename shared to account-wide, fix some bugs and type issues

# Test Plan

Tests
2024-11-20 17:34:26 +01:00
Piotr Szeremeta 753df2c428 Environment Variables: Rename encrypted to secret (#2709) 2024-11-20 14:50:14 +01:00
Expo Bot 3111492f83 Upgrade eas-build dependencies (#2703)
⬆️ Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.
2024-11-19 12:43:29 +01:00
Expo CI 9c95755a46 update CHANGELOG.md 2024-11-18 21:42:12 +00:00
Stanisław Chmiela da727ba170 v13.3.0 2024-11-18 22:35:12 +01:00
4571910c4d Adds workflow:create command (#2678)
# Why

Adds `eas workflow:create` to make it faster to create workflow files in a project. This command creates a file like **.eas/workflows/workflow.yml**. The user can choose the file name or can provide it as an arugment.

# Screenshots

<img width="552" alt="Screenshot 2024-11-08 at 8 50 58 AM" src="https://github.com/user-attachments/assets/5cf82e7e-cce6-4098-b291-fccdac25f103">
<img width="584" alt="Screenshot 2024-11-08 at 8 51 27 AM" src="https://github.com/user-attachments/assets/d6f6e7cb-18c1-420e-b52a-5bdd42446715">
<img width="617" alt="Screenshot 2024-11-08 at 8 52 04 AM" src="https://github.com/user-attachments/assets/81cc79cf-afb7-4855-bfce-0679f98655fa">

# Test Plan

Make sure that you can run the command as pictured above and that it correctly creates the workflow file. Also make sure that the unit tests pass.

# Future

Once we have docs for workflows that are public, I'd like to put a link to the docs in the default file this command creates. For now, I am just putting Hello World as a reasonable placeholder.

Co-authored-by: Szymon Dziedzic <[email protected]>
Co-authored-by: Stanisław Chmiela <[email protected]>
2024-11-18 22:34:39 +01:00
Stanisław Chmiela c7cfcafff8 Fix changelog (#2701) 2024-11-18 22:29:41 +01:00
Stanisław Chmiela 6f913f7320 Update CHANGELOG.md 2024-11-18 19:33:54 +01:00
Stanisław Chmiela 0a49296231 [eas-cli] Unhide workflow commands (#2683)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Going to release soon.

# How

Removed `hidden = true` overrides.

# Test Plan

`easd --help` printed out information about workflow commands.

```
~/Developer/test-local (main [$!]) easd --help
EAS command line tool

VERSION
  eas-cli/13.1.1 darwin-arm64 node-v22.4.0

USAGE
  $ eas [COMMAND]

TOPICS
...
  workflow     manage workflows
```

```
~/Developer/test-local (main [$!]) easd workflow --help                       
manage workflows

USAGE
  $ eas workflow:COMMAND

COMMANDS
  workflow:run       Run an EAS workflow
  workflow:validate  validate a workflow configuration yaml file
```
2024-11-18 19:28:49 +01:00
Stanisław Chmiela 7fc09b2376 [eas-cli] Improve workflow:run and workflow:validate (#2700)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

- running workflows requires GitHub connection
- workflow config may be a valid YAML, but invalid config

We want to check both of these:
- in `workflow:validate`
- before we upload project sources to GCS in `workflow:run`.

# How

I added a `validateWorkflowRevisionYamlConfig` mutation I'm using here to validate the config _and_ GitHub connection. The GitHub requirement is checked dynamically in `www` so we can later remove it without having users upgrade to a newer `eas-cli`.

Brent also wished `eas workflow:run test.yml` resolved `test.yml` to `.eas/workflows/test.yml` automatically, so added this too.

# Test Plan

I tried validating and running invalid YAMLs, workflows and valid configs. Things seemed to work ok.
2024-11-18 19:26:45 +01:00
Expo CI fed28a87be update CHANGELOG.md 2024-11-15 11:03:36 +00:00
Szymon Dziedzic 073bdcd575 v13.2.3 2024-11-15 10:57:48 +00:00
Szymon Dziedzic 6979c8d67b [eas-cli] make logs anouncing loading env variables less noisy (#2694) 2024-11-15 11:51:41 +01:00
Szymon Dziedzic 0869bc511f [website] rename encrypted visibility type to secret (#2693) 2024-11-15 11:50:24 +01:00
Expo CI 693562d742 update CHANGELOG.md 2024-11-15 10:35:32 +00:00
Szymon Dziedzic 734a071345 v13.2.2 2024-11-15 10:30:04 +00:00
Szymon Dziedzic 26fd7a7daa [ENG-14180][eas-cli] rename with-as-environment-variables-set eas update flag to environment (#2692)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

During dogfooding it came out that this flag is too long and confusing

# How

Just use `environment`

# Test Plan

Tests, types
2024-11-15 11:28:50 +01:00
Expo Bot df13e0433e Upgrade eas-build dependencies (#2691)
⬆️ Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.
2024-11-14 22:14:45 +01:00
Expo CI fa72248d45 update CHANGELOG.md 2024-11-14 15:54:43 +00:00
Stanisław Chmiela 2b51db72db v13.2.1 2024-11-14 16:46:12 +01:00
Stanisław Chmiela 79696394be [eas-cli] Default update message to undefined in no-VCS client (#2690)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/workflows/01932b13-21af-780b-b531-1ed07e3d8228

`eas update --auto` fails in GCS update job because this flag is set to true.

# How

The comment says it can be `false`, so changed it to `false`.

https://github.com/expo/eas-cli/pull/2148/files#diff-a594e233350755033d45aa7b21b78781e4b36322e9312f60e71e7edc79c33a7f

# Test Plan

Update message should be changed to `undefined`.
2024-11-14 16:40:59 +01:00
Expo CI 86c99efdcb update CHANGELOG.md 2024-11-13 15:08:46 +00:00
Stanisław Chmiela ee500109f6 v13.2.0 2024-11-13 16:02:31 +01:00
Stanisław Chmiela 7eff738efe [eas-cli] Fix VCS client used for submit:internal (#2682)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Counterpart of https://github.com/expo/eas-cli/pull/2677, applied to `submit:internal`.

# How

Copied the solution from `build:internal`.

# Test Plan

None.
2024-11-13 16:01:38 +01:00
Will Schurman fdb167df34 [build] Default build-logger-level to debug when EXPO_DEBUG is set (#2676) 2024-11-11 10:46:08 -08:00
Quinlan JungandWill Schurman 027d3629a6 [eas-cli] improve fingerprint UX (#2675)
* [eas-cli] improve UX and slow fingerprint logs

* [eas-cli] env var to skip fingerprint computation

* pr feedback

* Update packages/eas-cli/src/utils/fingerprintCli.ts

Co-authored-by: Will Schurman <[email protected]>

* Temporary Commit at 11/8/2024, 3:17:40 PM

---------

Co-authored-by: Will Schurman <[email protected]>
2024-11-09 13:09:08 -08:00
Expo CI c30c1cd5cf update CHANGELOG.md 2024-11-08 11:23:26 +00:00
Stanisław Chmiela a5d3ea8aba v13.1.1 2024-11-08 12:17:00 +01:00
Stanisław Chmiela 37f53e0976 [eas-cli] Honor EAS_NO_VCS in build:internal (#2677)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

We need to use `build:internal` to resolve build configuration in GCS workflow jobs which have no Git repository.

# How

I'm going to be adding `EAS_NO_VCS=1` to GCS workflow jobs. This should prevent `build:internal` from requiring Git repository.

# Test Plan

I have verified `EAS_BUILD_ID=d7fb9f5c-dce0-40e2-b459-cbc05ba1ed77 EAS_NO_VCS=1 easd build:internal -p android` prints the JSON output on my computer.
2024-11-08 12:15:34 +01:00
Expo CI 97a26dce40 update CHANGELOG.md 2024-11-07 21:35:43 +00:00
Stanisław Chmiela b0b14788dd v13.1.0 2024-11-07 22:29:56 +01:00
Stanisław ChmielaandSzymon Dziedzic b81c60e8a4 [eas-cli] Add workflow:run (#2669)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

We want to allow users to start workflows manually.

# How

I added a `createRunMutation` to GraphQL and used it here. We upload project archive, `eas.json` and use to start a new workflow run.

# Test Plan

```
Workflows are in beta and subject to breaking changes.

Compressing project files and uploading to EAS. Learn more: https://expo.fyi/eas-build-archive
✔ Uploaded eas.json to EAS 
✔ Uploaded project archive to EAS 1s

✔ Workflow started successfully. https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/workflows/019301f1-b1b4-700d-9e3b-82dc97d76a32
```

Co-authored-by: Szymon Dziedzic <[email protected]>
2024-11-07 22:26:52 +01:00
Jon Samp d47b80a125 Adds workflow:validate command (#2650)
* adds validate command

* use yaml

* incorp feedback
2024-11-07 08:53:51 -06:00
Quinlan Jung 3f2dcc7e09 [eas-cli] compute fingerprint on each build (#2663)
* Temporary Commit at 11/4/2024, 4:33:33 PM

* Temporary Commit at 11/5/2024, 4:17:31 PM

* Temporary Commit at 11/5/2024, 4:22:18 PM
2024-11-05 19:20:44 -08:00
Expo CI b039da04e2 update CHANGELOG.md 2024-11-05 22:20:04 +00:00
Cedric van Putten 9e55fcbe7c v13.0.1 2024-11-05 23:02:06 +01:00
Cedric van Putten 281ae7bad6 fix(eas-cli): update EAS Metadata with new props for App Store 3.6.0 (#2671)
* refactor(eas-cli): upgrade eas metadata to support app store 3.6.0 advisory props

* chore(eas-cli): bump `@expo/apple-utils` to `2.1.0`

* fix(eas-cli): update eas metadata json schema

* docs(eas-cli): add changelog entry

* fix(eas-cli): fix typo in config version

* fix(eas-cli): resolve outdated fixtures

* chore(eas-cli): resolve linting issues
2024-11-05 23:01:14 +01:00
Expo CI 433c47b221 update CHANGELOG.md 2024-11-05 18:52:32 +00:00
Szymon Dziedzic 5be97c50ae v13.0.0 2024-11-05 19:46:57 +01:00
Szymon Dziedzic 2bc0e968cf upgrade eas-cli-local-build-plugin to 1.0.144 2024-11-05 19:46:44 +01:00
Szymon Dziedzic a426164e85 [eas-cli] update expo packages (#2530)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

update expo packages to SDK 51 versions

revert https://github.com/expo/eas-cli/pull/2524

# How

update expo packages to SDK 51 versions

# Test Plan

tests
2024-11-05 19:36:30 +01:00
Szymon Dziedzic ac799bbf76 [eas-cli] use npx expo config command to resolve expo config if available (#2529)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://exponent-internal.slack.com/archives/C02123T524U/p1724970663050089?thread_ts=1724924614.773089&cid=C02123T524U
https://exponent-internal.slack.com/archives/C5ERY0TAR/p1725002816077089

Generally `@expo/config`, `@expo/prebuild-config`, and `@expo/config-plugins` are per SDK concept. Currently, in EAS CLI, we rely on one version of the `@expo/...` package to work well globally for all SDKs. It seems to sometimes bite us like in the case of https://exponent-internal.slack.com/archives/C02123T524U/p1724909908574979, because SDK 51 config wasn't compatible with SDK 50.

It's better to switch to using config-related functions shipped with `@expo/config`, `@expo/prebuild-config`, and `@expo/config-plugins`.  I believe we can assume that `getConfig`, `getPrebuildConfig`, and `compileModsAsync` shipped with these packages have stable API https://exponent-internal.slack.com/archives/C5ERY0TAR/p1725293002128849?thread_ts=1725002816.077089&cid=C5ERY0TAR.

# How

Use `getConfig`, `getPrebuildConfig`, and `compileModsAsync` from `@expo/config`, `@expo/prebuild-config`, and `@expo/config-plugins` shipped with Expo SDK installed in the user's project. If not available, fallback to versions installed per SDK. If we can assume that these guarantee stable API we can use types of these functions from packages installed for EAS CLI as well.

# Test Plan

Run builds for repro case of https://exponent-internal.slack.com/archives/C02123T524U/p1724909908574979 with SDK 51 config packages installed for EAS CLI (that previously broke it), see that it works and capabilities are correctly synced.

Initiate a new bare RN project using community CLI, and check that it works as well using fallback config loading.
2024-11-05 19:33:24 +01:00
Szymon Dziedzic cf8e715cd5 [eas-cli] add support for with-eas-environment-variables-set flag in eas update:configure command (#2668)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Add support for `with-eas-environment-variables-set` flag in `eas update:configure` command

# How

Use the flag the same way we do for `eas update`.

# Test Plan

Run command.
2024-11-05 12:44:27 +01:00
Szymon Dziedzic 71ca181895 [eas-cli] improve with-eas-environment-variables-set flag handling in eas update command (#2667)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Make the `with-eas-environment-variables-set` flag reusable and improve type safety and description.

# How

Extract `with-eas-environment-variables-set` to separate file. Improve validation and type safety.

# Test Plan

Run command locally.
2024-11-05 12:41:43 +01:00
Szymon Dziedzic 7c9970d279 [eas-json] add new Xcode 16.1 image and tag to (#2666)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Add new Xcode 16.1 image and `sdk-52` tag to `eas.schema.json`

# How

Add new Xcode 16.1 image and `sdk-52` tag to `eas.schema.json`

# Test Plan

Tests
2024-10-31 13:57:35 +01:00
Piotr Szeremeta f40484ec4d env:* commands accept environment as an argument and a flag (#2654)
# Why

[ENG-13930: Allow passing environment without the flag](https://linear.app/expo/issue/ENG-13930/allow-passing-environment-without-the-flag)

Added capability to pass `environment` without `--environment` flag to `eas env:*` commands. This is often more intuitive.

This works only for a single environments. In case user wants to pass more than one, `--environment` flag should still be used.

# Changed commands

## `env:create`
* was only `env:create --name thisisvar --value thisinew --visibility plaintext --environment production`
* now also `env:create production --name thisisvar --value thisinew --visibility plaintext`

## `env:delete`
* was only `env:delete --variable-environment production --variable-name NEW_VAR`
* now also `env:delete production --variable-name NEW_VAR`

## `env:exec`
* was only `env:exec --environment development 'echo lol'`
* now also `eas env:exec development 'echo lol'`

## `env:get`
* was only `env:get --variable-environment development --variable-name HELLO`
* now also `env:get development --variable-name HELLO`

## `eas env:link`
* was only `eas env:link --environment preview --variable-name NAME`
* now also `eas env:link preview --variable-name NAME`

## `eas env:list`
* was only `eas env:list --environment preview`
* now also `eas env:list preview`

## `eas env:pull`
* was only `eas env:pull --environment production`
* now also `eas env:pull production`

## `eas env:push`
* was only `eas env:push --environment production`
* now also `eas env:push production`

## `eas env:unlink`
* was only `eas env:unlink --environment production --variable-name NAME`
* now also `eas env:unlink production --variable-name NAME`

## `eas env:update`
* was only `eas env:update --variable-environment production --variable-name HELLO --environment development`
* now also `eas env:update production --variable-name HELLO --environment production`

# Test Plan

Updated tests
2024-10-31 13:20:54 +01:00
Expo Bot 746d64ac30 Upgrade eas-build dependencies (#2665)
⬆️ Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.
2024-10-30 22:14:23 +01:00
Expo Bot 3782767f9a Upgrade eas-build dependencies (#2662)
<img width="1336" alt="Zrzut ekranu 2024-10-29 o 19 57 03" src="https://github.com/user-attachments/assets/0fe1585c-bdaa-4ad5-bd5b-e64d624d4932">

https://github.com/expo/eas-build/pull/456 + https://github.com/expo/eas-build/pull/453
2024-10-29 19:58:30 +01:00
Expo CI efb1c87fe3 update CHANGELOG.md 2024-10-29 10:52:35 +00:00
Szymon Dziedzic c4075b9fd0 v12.6.2 2024-10-29 11:46:54 +01:00
Szymon Dziedzic 5bd3760b57 upgrade eas-cli-local-build-plugin to 1.0.142 2024-10-29 11:46:46 +01:00
Cedric van Putten dd190869aa fix(eas-cli): make email case insensitive during apple auth (#2659)
* fix(eas-cli): make email case insensitive during apple auth

* docs(eas-cli): fix changelog entry
2024-10-29 00:20:24 +01:00
Expo BotandStanisław Chmiela c7b2b8388c Upgrade eas-build dependencies (#2660)
* Upgrade eas-build dependencies

* Fixup

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2024-10-28 20:50:26 +01:00
Piotr SzeremetaandSzymon Dziedzic 3e6cf78760 Improve wording in env:pull command (#2655)
* Improve wording in env:pull command

* Update packages/eas-cli/src/commands/env/pull.ts

Co-authored-by: Szymon Dziedzic <[email protected]>

---------

Co-authored-by: Szymon Dziedzic <[email protected]>
2024-10-25 19:10:19 +02:00
Piotr SzeremetaandSzymon Dziedzic 5478814cf6 env:pull uses local values of encrypted variables (#2656)
* env:pull uses local values of encrypted variables

* Update packages/eas-cli/src/commands/env/pull.ts

Co-authored-by: Szymon Dziedzic <[email protected]>

---------

Co-authored-by: Szymon Dziedzic <[email protected]>
2024-10-25 17:04:56 +02:00
Expo CI 6449ea7c16 update CHANGELOG.md 2024-10-25 12:36:51 +00:00
Szymon Dziedzic 88740682cd v12.6.1 2024-10-25 14:31:41 +02:00
Szymon Dziedzic 8c05a711fc [eas-cli] add simplified projectId-focused context (#2653)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

As suggested in the base PR we need a simple context focused on only getting the project ID, without specifying the server-side environment and resolving Expo config with it.

# How

Add such context. Make the `withServerSideEnvironment` argument required for standard project config context.

# Test Plan

Tests, test manually
2024-10-25 14:26:45 +02:00
Szymon Dziedzic d0f01cb58f [eas-cli] add --with-eas-environment-variables-set flag to eas update command (#2628)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://exponent-internal.slack.com/archives/C013ZK4SA12/p1725644220583309

We are planning to introduce enhancements to our env vars system soon. Currently, we only allow people to keep secrets on EAS servers. Secrets are not readable outside of EAS servers so of course EAS Update doesn't work with them. In a new system, people can also store readable env vars on our servers, which opens new possibilities about how EAS Update can interact with env vars!

In this PR I'm adding support for the `--with-eas-environment-variables-set production|preview|development` flag. If used, we use the flag's value to fetch readable env vars from our servers and use it in the `expo export` command to produce an update bundle based on these env vars. If the flag is used I'm using the `EXPO_NO_DOTENV` flag to disable Expo CLI from loading env vars from `.env` files and potentially unwillingly overwriting env vars fetched from our servers for a given environment.

Users seem to face big issues with using env vars with EAS Build & Update at the moment, originating from the fact that these 2 handle different, non-compatible ways of using env vars. Env vars in the build profile and server-side secrets vs local env vars + `.env`. With this flag it will be easy to avoid envs mismatch by doing `eas update ----with-eas-environment-variables-set environment` and `eas build` with `{ ..., environment: environment, ...} in the build profile!

# How

Add a new hidden (for now) flag `--with-eas-environment-variables-set` that indicates which environment to use. If used fetch env vars from server. Merge and overwrite `process.env` with newly fetched env vars. Use them to resolve the expo config. Don't use `.env` env var loading from Expo CLI if server-side env vars are used.

# Test Plan

Test manually. Run `eas update` with and without a new flag. Check that the expo config is resolved correctly.
2024-10-25 14:19:11 +02:00
Will Schurman a212662003 [eas-cli] Create dynamic logged in context field and clean up erroneous SessionManager context field uses (#2648) 2024-10-23 08:52:41 -07:00
Will Schurman 3139124daf [ci] Upgrade codemention and update comment configuration (#2647) 2024-10-23 08:48:35 -07:00
Piotr Szeremeta 8c4be1c556 Make eas env:pull pull file env vars (#2636)
# Why

File variables were not handled by the `env:pull` command. They should be downloaded just like the other values.

# How

* `env:pull` saves file type variables to `.eas/.env` directory and sets its value in `.env.local` file to its file path
* `env:push` skips file type variables

# Test Plan

Tested manually
2024-10-22 18:25:09 +02:00
Piotr Szeremeta 3a7dfb0001 Upload fileName with EnvVar create/update commands (#2646)
# Why

[ENG-13652: Show filename when value is a file](https://linear.app/expo/issue/ENG-13652/show-filename-when-value-is-a-file)

`eas env:create` and `eas:env:update` should upload fileName, just like website.

# How

* Added `fileName` parameter

# Test Plan

Tested manually
2024-10-22 17:31:19 +02:00
Szymon Dziedzic 214cff043c [eas-cli] improve logs announcing loading of env variables (#2643)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Improve logs announcing loading env vars.

https://exponent-internal.slack.com/archives/C06FK950085/p1729215938823339?thread_ts=1729186741.835629&cid=C06FK950085

# How

Improve logs

# Test Plan

Test manually.
2024-10-22 11:58:00 +02:00
Expo CI 0515c9a6fa update CHANGELOG.md 2024-10-21 21:33:03 +00:00
Will Schurman 367b763adf v12.6.0 2024-10-21 14:26:49 -07:00
Will Schurman 49dba20200 [eas-cli] Add interactivity to eas update:edit command (#2638) 2024-10-21 14:19:18 -07:00
Juwan Wheatley f0b14bf6a1 [eas-cli] add pause/resume commands for Update Channels (#2614)
# Why

We want to allow developers to prevent updates from being served to certain builds by "pausing" update channels.

# How

- I added two new CLI commands to toggle the channel pausing state
- I added the pause status to the channel details output in the `view` and `list` commands

# Test Plan

Build the CLI locally under the `neas` alias, and run `neas channel:pause development && neas channel:view development && neas channel:resume development && neas channel:view development`:

<img width="1364" alt="Screenshot 2024-10-17 at 05 33 36" src="https://github.com/user-attachments/assets/fd557e57-bbf7-463c-8cec-1febcb2fdde8">
2024-10-21 15:08:45 -04:00
Piotr Szeremeta 161acbb160 Allow user to select environments manually in env:push (#2637)
# Why

[ENG-13853: Prompt user to enter the environment when not provided vs fail and rerun when using `eas env:push`](https://linear.app/expo/issue/ENG-13853/prompt-user-to-enter-the-environment-when-not-provided-vs-fail-and)

If the environment is not provided, we ask the user about it in other commands, I think we can do the same here for consistency vs throwing an error and having the user rerun it

# Test Plan

Tested manually
2024-10-21 15:35:17 +02:00
Expo CI 5aad520c23 update CHANGELOG.md 2024-10-19 18:28:59 +00:00
Cedric van Putten d6ec3ae106 v12.5.4 2024-10-19 20:23:43 +02:00
Cedric van Putten feb8d2d8f4 fix(eas-cli): resolve Apple authentication error (#2641)
* fix(eas-cli): resolve Apple authentication error

* docs(eas-cli): fix changelog entry

* chore(eas-cli): bump `@expo/apple-utils` again
2024-10-19 20:22:39 +02:00
Piotr Szeremeta c07fd222d9 Ask for file path in env:* commands (#2635)
# Why

[ENG-13855: When creating `file` type env var in `env:create` ask about file path vs variable value](https://linear.app/expo/issue/ENG-13855/when-creating-file-type-env-var-in-envcreate-ask-about-file-path-vs)
2024-10-18 16:50:59 +02:00
Piotr Szeremeta 1873804c8b Display message when env:list returns an empty result (#2630)
# Why

[ENG-13847: Show `no env vars found for this environment` if 0 env vars exist for given environment in `eas env:list`](https://linear.app/expo/issue/ENG-13847/show-no-env-vars-found-for-this-environment-if-0-env-vars-exist-for)

<img width="455" alt="image" src="https://github.com/user-attachments/assets/d4286fcb-0adf-4806-a876-4cbbcf3d6157">
2024-10-18 13:16:20 +02:00
Piotr Szeremeta 1cc4fd8880 Use lower case environment across all eas env commands (#2632)
# Why

[ENG-13846: Use lower-case environment names across all `eas env:xyz` commands for consistency](https://linear.app/expo/issue/ENG-13846/use-lower-case-environment-names-across-all-eas-envxyz-commands-for)
2024-10-18 13:14:50 +02:00
Piotr Szeremeta f83d7e0be7 Always ask for environment in env:get (#2631)
# Why

[ENG-13848: Always ask for `environment` in `eas env:get` command](https://linear.app/expo/issue/ENG-13848/always-ask-for-environment-in-eas-envget-command)
2024-10-18 13:14:13 +02:00
Piotr Szeremeta b225ba4bf3 Use the same names for visibility options in eas cli as on (#2629)
# Why

[ENG-13849: When no `--visibility` input is provided `eas env:create` should ask about it by default](https://linear.app/expo/issue/ENG-13849/when-no-visibility-input-is-provided-eas-envcreate-should-ask-about-it)

[ENG-13850: Use the same names for visibility options in EAS CLI as on website](https://linear.app/expo/issue/ENG-13850/use-the-same-names-for-visibility-options-in-eas-cli-as-on-website)

`eas env:*` commands were using obsolete names for visibility options: `public` and `secret`, instead of `plaintext` and `encrypted`. It should be updated.
Also, user is prompted for the variable visibility if not provided.

# Test Plan

Updated tests
2024-10-18 10:29:52 +02:00
Piotr Szeremeta 4fb5972f18 Support multiple environments in env:push command (#2620) 2024-10-17 16:59:57 +02:00
Piotr Szeremeta 463f73eec2 Fix in env:link command: fail in non-interactive mode if variable name is ambigous (#2627) 2024-10-17 16:59:31 +02:00
Szymon Dziedzic 2dbaed5b2a [eas-cli] remove @expo/config-types dependency (#2528)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Rely on ExpoConfig type from the `@expo/config` package in all places for consistency

# How

Rely on ExpoConfig type from the `@expo/config` package in all places for consistency

# Test Plan

Tests
2024-10-17 14:56:49 +02:00
Expo CI 20b76f72b0 update CHANGELOG.md 2024-10-16 15:30:02 +00:00
Szymon Dziedzic 56444432b9 v12.5.3 2024-10-16 17:24:52 +02:00
Piotr Szeremeta 02022d8d17 Support File type environment variables (#2512)
# Why

User should be able to upload files to environment variables and read content of those files

# How

* When an envvar is created with flag `--type FILE`, selected file is encoded to base64 and sent to www.
* EnvVarQueries return file contents when `includeFileContent` file is passed
* `env:get` automatically include file content, `env:list` requires `--include-file-content` flag
* Updated environment display - added `environments`, `type` and `visibility`

# Test Plan

tested manually, updated tests
2024-10-15 10:57:07 +02:00
Expo CI a7c77e8ab3 update CHANGELOG.md 2024-10-11 18:52:03 +00:00
Brent Vatne 6489ddc6ad v12.5.2 2024-10-11 11:46:46 -07:00
Brent VatneandKeith Kurak 496e68935a [eas-cli] Add message about concurrency add-on (#2611)
Co-authored-by: Keith Kurak <[email protected]>
2024-10-11 10:23:40 -07:00
Will Schurman a346af2869 [eas-cli] Unhide rollout-percentage flag on update publish command (#2608) 2024-10-07 11:03:32 -07:00
Szymon Dziedzic 7699573100 [eas-json] update eas.schema.json (#2606)
# Why

add new image to eas.schema.json

# How

add new image to eas.schema.json
2024-10-03 17:41:16 +02:00
Piotr Szeremeta 3f832765c2 Refactor env:unlink, add tests (#2591)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-13525: Update EAS-CLI for new EnvVar features](https://linear.app/expo/issue/ENG-13525/update-eas-cli-for-new-envvar-features)

Update command to use multiple environments, polish the interface.

# How

* use `variable-name` and `variable-environment` to select the variable
* Use `multiselect` and multiple `environment` parameters to allow unlinking multiple envs

All commands that were using name parameter to identify a single variable will use variable-name and variable-environment instead. This change will enable user to distinguish between name and environment that is identifying variable that is acted upon, and name and environment that is a parameter for command.

For example, when the user wants to update variable TEST that has environment production and change its name and change its environment, they can use:

eas env:update --variable-name TEST --variable-environment production --name TEST-update --environment production --environment preview

# Test Plan

Added tests
2024-10-01 11:03:30 +02:00
Piotr Szeremeta af16cbaaea Refactor env:list and add tests (#2588)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-13525: Update EAS-CLI for new EnvVar features](https://linear.app/expo/issue/ENG-13525/update-eas-cli-for-new-envvar-features)

Allow selecting multiple environments to display variables from.

# How

* Use `multiselect`, allow specifying multiple `environment` parameters
* List variables from all environments

# Test Plan

Added tests
2024-10-01 11:00:32 +02:00
Piotr Szeremeta 0d384d1722 Refactor env:link, add tests (#2587)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-13525: Update EAS-CLI for new EnvVar features](https://linear.app/expo/issue/ENG-13525/update-eas-cli-for-new-envvar-features)

Add tests, polish the interface

# How

* Use `variable-name` and `variable-environment` to select the variable
* More robust selector of variable to remove ambiguity 
* Use multiselect to clearly indicate which environments are linked and which are not
* Automatic `link` and `unlink` action

All commands that were using name parameter to identify a single variable will use variable-name and variable-environment instead. This change will enable user to distinguish between name and environment that is identifying variable that is acted upon, and name and environment that is a parameter for command.

For example, when the user wants to update variable TEST that has environment production and change its name and change its environment, they can use:

eas env:update --variable-name TEST --variable-environment production --name TEST-update --environment production --environment preview

# Test Plan

Added tests
2024-10-01 10:57:27 +02:00
Piotr Szeremeta 58d2088bf7 Refactor env:update, add tests (#2586)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-13525: Update EAS-CLI for new EnvVar features](https://linear.app/expo/issue/ENG-13525/update-eas-cli-for-new-envvar-features)

Rework the EnvironmentVariableUpdate function - user is able to alter name, and environments.

# How

* Added `--variable-name` and `--variable-environment` parameters to identify the variable to update
* Using parameters from `--name` and `--environment` to update the variable using the new `updateEnvironmentVariable` method.

All commands that were using name parameter to identify a single variable will use variable-name and variable-environment instead. This change will enable user to distinguish between name and environment that is identifying variable that is acted upon, and name and environment that is a parameter for command.

For example, when the user wants to update variable TEST that has environment production and change its name and change its environment, they can use:

eas env:update --variable-name TEST --variable-environment production --name TEST-update --environment production --environment preview

# Test Plan

Added tests
2024-10-01 10:54:28 +02:00
Piotr Szeremeta c622014b64 Add tests for EnvironmentVariableDelete (#2585)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-13525: Update EAS-CLI for new EnvVar features](https://linear.app/expo/issue/ENG-13525/update-eas-cli-for-new-envvar-features)

Improve the selector for the variable to delete, rename parameter `name` to `variable-name`. 

All commands that were using name parameter to identify a single variable will use variable-name and variable-environment instead. This change will enable user to distinguish between name and environment that is identifying variable that is acted upon, and name and environment that is a parameter for command.

For example, when the user wants to update variable TEST that has environment production and change its name and change its environment, they can use:

eas env:update --variable-name TEST --variable-environment production --name TEST-update --environment production --environment preview

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/KfcKByW8uA9rvjzBy0RA/e96c9c31-be76-401a-be0e-715a77b399d1.png)

# Test Plan

Added tests
2024-10-01 10:51:28 +02:00
Piotr Szeremeta 81feffa0c5 Add tests to EnvironmentVariableGet (#2584)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-13525: Update EAS-CLI for new EnvVar features](https://linear.app/expo/issue/ENG-13525/update-eas-cli-for-new-envvar-features)

Polished the interface:

 - user is prompted for environment only when the name is ambiguous.
- changed `name` parameter to `variable-name`. 

All commands that were using `name` parameter to identify a single variable will use `variable-name` and `variable-environment` instead. This change will enable user to distinguish between `name` and `environment` that is identifying variable that is acted upon, and `name` and `environment` that is a parameter for command.

For example, when the user wants to update variable `TEST` that has environment `production` and change its name and change its environment, they can use:

`eas env:update --variable-name TEST --variable-environment production --name TEST-update --environment production --environment preview`

# Test Plan

Added tests
2024-10-01 10:48:28 +02:00
Piotr Szeremeta 9521cc45da Update EnvironmentVariableCreate for multiple environments, add tests (#2583)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-13525: Update EAS-CLI for new EnvVar features](https://linear.app/expo/issue/ENG-13525/update-eas-cli-for-new-envvar-features)

Update `eas env:create` to accept multiple `--environment` parameters.

# How

* Use `EASMultiEnvironmentFlag` in `EnvironmentVariableCreate`
* Update variable display with `environment` and `updatedAt` fields

# Test Plan

Added tests
2024-10-01 10:45:31 +02:00
Piotr Szeremeta 1dfe24d48a Allow prompting for multiple environments (#2582)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-13525: Update EAS-CLI for new EnvVar features](https://linear.app/expo/issue/ENG-13525/update-eas-cli-for-new-envvar-features)

EnvVar will support having multiple environments, CLI should support this.

# How

* Add an optional parameter `multiple` to `promptVariableEnvironmentAsync`
* Update commands using `promptVariableEnvironmentAsync`
* Add a new flag `EASMultiEnvironmentFlag`

# Test Plan

Tested manually, tests will be added in the following PRs
2024-10-01 10:42:27 +02:00
Piotr Szeremeta 99765c1802 Update EnvironmentVariableMutation and associated commands (#2581)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-13525 Update EAS-CLI for new EnvVar features](https://linear.app/expo/issue/ENG-13525/update-eas-cli-for-new-envvar-features)

Bring up to date `create` and `update` EnvVar mutations.

# How

* Use `updateEnvironmentVariable` operation instead of `createEnvironmentVariable` with an 'overwrite' option.
* Add `type` parameter
* Add `environment` parameter for shared variables

# Test Plan

Tested manually, tests will be added in the following PRs
2024-10-01 10:39:28 +02:00
Piotr Szeremeta de388cfd27 Update Environment Variables queries: (#2580)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[ENG-13525 Update EAS-CLI for new EnvVar features
](https://linear.app/expo/issue/ENG-13525/update-eas-cli-for-new-envvar-features) 

Update EnvVar queries - allow quering using environments.

# How

* Added optional `environment` parameter to queries
* Fix typing of the `--environment` parameter

# Test Plan

Tested manually
2024-10-01 10:36:06 +02:00
Will Schurman 37a68c5237 [ci] Migrate from CODEOWNERS to codemention (#2603) 2024-09-27 09:06:46 -07:00
Will Schurman 6823e4f66c [eas-cli] Disallow republishing an update that is being rolled-out (#2602) 2024-09-27 09:05:24 -07:00
Phil Pluckthun ffa2f034f9 refactor(eas-cli): Implement updated deployment upload flow (#2601)
* Update API functions for new upload endpoint and deploy result

* Fix listWorkerFilesAsync file type

* Add call to finalize deployment

* Update CHANGELOG

* Add retries to /deploy/finalize call

* Apply lints
2024-09-27 10:36:24 +01:00
Expo CI 8243f5f0cb update CHANGELOG.md 2024-09-27 05:22:24 +00:00
220 changed files with 16953 additions and 2829 deletions

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
commentConfiguration:
preamble: 'Subscribed to pull request'
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
rules:
- patterns: ['**/*']
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela', 'radoslawkrzemien']
- patterns: ['packages/eas-cli/src/update/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/src/commands/update/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/src/channel/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/src/commands/channel/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/schema/**']
mentions: ['bycedric']
- patterns: ['packages/eas-cli/src/metadata/**']
mentions: ['bycedric']
- patterns: ['packages/eas-cli/src/commands/metadata/**']
mentions: ['bycedric']
+14
View File
@@ -0,0 +1,14 @@
name: codemention
on:
pull_request_target:
types: [opened, synchronize, ready_for_review]
jobs:
codemention:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: tobyhs/[email protected]
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
+250
View File
@@ -8,6 +8,256 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Sanitize and generate names for EAS Submit to prevent failures due to invalid characters or taken names. ([#2842](https://github.com/expo/eas-cli/pull/2842) by [@evanbacon](https://github.com/evanbacon))
### 🐛 Bug fixes
### 🧹 Chores
- Fix tests for VisionOS type.
- Make new autoIncremented builds start at nr 1 by default ([#2828](https://github.com/expo/eas-cli/pull/2828) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
## [14.5.0](https://github.com/expo/eas-cli/releases/tag/v14.5.0) - 2025-01-22
### 🎉 New features
- Allow filtering by `--fingerprint_hash` in `eas build:list` command. ([#2818](https://github.com/expo/eas-cli/pull/2818) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- Ensure that the AASA file is served with content type application/json ([#2829](https://github.com/expo/eas-cli/pull/2829) by [@kadikraman](https://github.com/kadikraman))
- Ensure that the AppleID provided in prompt or saved to cache does not contain invalid unprintable characters ([#2830](https://github.com/expo/eas-cli/pull/2830) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
### 🧹 Chores
- Fix logs typos in the `eas deploy` command. ([#2822](https://github.com/expo/eas-cli/pull/2822) by [@kadikraman](https://github.com/kadikraman))
- Make `deploy` the top level command for hosting. ([#2824](https://github.com/expo/eas-cli/pull/2824) by [@kadikraman](https://github.com/kadikraman))
- Allow longer submit profile extension chain (up to 5, same as build profile) ([#2831](https://github.com/expo/eas-cli/pull/2831) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
- Make variable naming more explicit, remove deprecated runtimeFingerprintSource uses. ([#2816](https://github.com/expo/eas-cli/pull/2816) by [@wschurman](https://github.com/wschurman))
## [14.4.1](https://github.com/expo/eas-cli/releases/tag/v14.4.1) - 2025-01-15
### 🐛 Bug fixes
- Enable shell execution for env:exec commands. ([#2788](https://github.com/expo/eas-cli/pull/2788) by [@tharakadesilva](https://github.com/tharakadesilva))
### 🧹 Chores
- Make automatic env resolution message shorter. ([#2806](https://github.com/expo/eas-cli/pull/2806) by [@szdziedzic](https://github.com/szdziedzic))
- Make "No remote versions are configured" message green instead of yellow. ([#2805](https://github.com/expo/eas-cli/pull/2805) by [@szdziedzic](https://github.com/szdziedzic))
- Upload local fingerprint on `eas fingerprint:compare`. ([#2808](https://github.com/expo/eas-cli/pull/2808) by [@quinlanj](https://github.com/quinlanj))
- Upgrade `eas-cli-local-build-plugin` to `1.0.163` to support Bun's new text-based lock file in local builds. ([#2817](https://github.com/expo/eas-cli/pull/2817) by [@shiroyasha9](https://github.com/shiroyasha9))
## [14.4.0](https://github.com/expo/eas-cli/releases/tag/v14.4.0) - 2025-01-09
### 🎉 New features
- Load `.env` variables even when `--environment` is specified for `deploy` command. Conflicts will be highlighted by a warning message. ([#2783](https://github.com/expo/eas-cli/pull/2783) by [@kitten](https://github.com/kitten))
- Silence all non-command output in non-interactive mode of eas env:exec. ([#2800](https://github.com/expo/eas-cli/pull/2800) by [@wschurman](https://github.com/wschurman))
- Unhide `deploy` and `deploy:alias` commands ([#2807](https://github.com/expo/eas-cli/pull/2807) by [@kitten](https://github.com/kitten))
## [14.3.1](https://github.com/expo/eas-cli/releases/tag/v14.3.1) - 2025-01-08
### 🧹 Chores
- Bump `@expo/[email protected]` to support Bun text-based lock files. ([#2801](https://github.com/expo/eas-cli/pull/2801) by [@kudo](https://github.com/kudo))
## [14.3.0](https://github.com/expo/eas-cli/releases/tag/v14.3.0) - 2025-01-07
### 🎉 New features
- Upload `package.json` when uploading workflow sources. ([#2786](https://github.com/expo/eas-cli/pull/2786) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Show `eas deploy` upload error messages. ([#2771](https://github.com/expo/eas-cli/pull/2771) by [@kadikraman](https://github.com/kadikraman))
- Prevent EAS CLI dependencies check from running repeatedly. ([#2781](https://github.com/expo/eas-cli/pull/2781) by [@kitten](https://github.com/kitten))
- Prevent optimistic request body parsing for `eas deploy`. ([#2784](https://github.com/expo/eas-cli/pull/2784) by [@kadikraman](https://github.com/kadikraman))
### 🧹 Chores
- Update log output for `worker` deploy and alias commands. ([#2780](https://github.com/expo/eas-cli/pull/2780) by [@kitten](https://github.com/kitten))
- Update various messages wording. ([#2790](https://github.com/expo/eas-cli/pull/2790) by [@simek](https://github.com/simek))
- Use node18 as tsconfig base. ([#2739](https://github.com/expo/eas-cli/pull/2739) by [@quinlanj](https://github.com/quinlanj))
## [14.2.0](https://github.com/expo/eas-cli/releases/tag/v14.2.0) - 2024-12-13
### 🎉 New features
- Add `eas deploy --dry-run` flag to output tarball. ([#2761](https://github.com/expo/eas-cli/pull/2761) by [@kitten](https://github.com/kitten))
- Allow specifying credentials for android builds. ([#2775](https://github.com/expo/eas-cli/pull/2775) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
- Remove random branch name generation for --auto branch name non-vcs fallback. ([#2747](https://github.com/expo/eas-cli/pull/2747) by [@wschurman](https://github.com/wschurman))
- Upgrade @expo/multipart-body-parser. ([#2751](https://github.com/expo/eas-cli/pull/2751) by [@wschurman](https://github.com/wschurman))
- Pass env var flag to worker deployments. ([#2763](https://github.com/expo/eas-cli/pull/2763) by [@kadikraman](https://github.com/kadikraman))
- Fix request for switching providers when doing Apple auth. ([#2769](https://github.com/expo/eas-cli/pull/2769) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Improve logging and validation in `eas env:exec` command. ([#2762](https://github.com/expo/eas-cli/pull/2762) by [@szdziedzic](https://github.com/szdziedzic))
## [14.1.0](https://github.com/expo/eas-cli/releases/tag/v14.1.0) - 2024-12-10
### 🎉 New features
- Add support for enabeling broadcast Push Notifications capability option, by setting `ios.usesBroadcastPushNotifications` to `true` in app config. ([#2748](https://github.com/expo/eas-cli/pull/2748) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Add link to FYI page mentioning the workaround for Apple SMS 2FA issue. ([#2752](https://github.com/expo/eas-cli/pull/2752) by [@szdziedzic](https://github.com/szdziedzic))
## [14.0.3](https://github.com/expo/eas-cli/releases/tag/v14.0.3) - 2024-12-09
### 🐛 Bug fixes
- Bump `@expo/apple-utils` to fix sending two-factor authentication codes via SMS. ([#2750](https://github.com/expo/eas-cli/pull/2750) by [@EvanBacon](https://github.com/EvanBacon))
### 🧹 Chores
- Change update message to allow faster copy and paste. ([#2661](https://github.com/expo/eas-cli/pull/2661) by [@jonluca](https://github.com/jonluca))
- Allow using `$schema` field in `eas.json`. ([#2624](https://github.com/expo/eas-cli/pull/2624) by [@saiichihashimoto](https://github.com/saiichihashimoto))
## [14.0.2](https://github.com/expo/eas-cli/releases/tag/v14.0.2) - 2024-12-06
### 🧹 Chores
- Print warning instead of throwing an error when validating `CFBundleShortVersionString` against App Store requirements. ([#2741](https://github.com/expo/eas-cli/pull/2741) by [@szdziedzic](https://github.com/szdziedzic))
## [14.0.1](https://github.com/expo/eas-cli/releases/tag/v14.0.1) - 2024-12-06
### 🧹 Chores
- Bump `@expo/apple-utils` to improve error handling and input validation and change auth headers. ([#2745](https://github.com/expo/eas-cli/pull/2745) by [@szdziedzic](https://github.com/szdziedzic))
## [14.0.0](https://github.com/expo/eas-cli/releases/tag/v14.0.0) - 2024-12-06
### 🛠 Breaking changes
- Change behavior of roll-back-to-embedded to not use current project state. ([#2722](https://github.com/expo/eas-cli/pull/2722) by [@wschurman](https://github.com/wschurman))
### 🎉 New features
- Add hidden fingerprint:compare feature. ([#2736](https://github.com/expo/eas-cli/pull/2736) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- Update `@expo/config` and `@expo/config-plugins` to fix `eas build` command for bare iOS projects on Windows. ([#2744](https://github.com/expo/eas-cli/pull/2744) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Fix outdated gitignored Google services file warning. ([#2730](https://github.com/expo/eas-cli/pull/2730) by [@szdziedzic](https://github.com/szdziedzic))
## [13.4.2](https://github.com/expo/eas-cli/releases/tag/v13.4.2) - 2024-11-25
### 🧹 Chores
- Upgrade `@expo` packages to SDK 52 versions. ([#2706](https://github.com/expo/eas-cli/pull/2706) by [@szdziedzic](https://github.com/szdziedzic))
## [13.4.1](https://github.com/expo/eas-cli/releases/tag/v13.4.1) - 2024-11-22
### 🧹 Chores
- Improve logging in `eas env:pull`. ([#2720](https://github.com/expo/eas-cli/pull/2720) by [@szdziedzic](https://github.com/szdziedzic))
## [13.4.0](https://github.com/expo/eas-cli/releases/tag/v13.4.0) - 2024-11-22
### 🎉 New features
- Calculate fingerprint on each update. ([#2687](https://github.com/expo/eas-cli/pull/2687) by [@quinlanj](https://github.com/quinlanj))
- Calculate fingerprint on each update republish. ([#2708](https://github.com/expo/eas-cli/pull/2708) by [@quinlanj](https://github.com/quinlanj))
- Add `eas env` commands. ([#2711](https://github.com/expo/eas-cli/pull/2711) by [@szdziedzic](https://github.com/szdziedzic))
- Add `--environment` flag to `eas update` command. ([#2711](https://github.com/expo/eas-cli/pull/2711) by [@szdziedzic](https://github.com/szdziedzic))
- Load readable environment variables from EAS servers on every build. ([#2644](https://github.com/expo/eas-cli/pull/2644) by [@szdziedzic](https://github.com/szdziedzic))
- Add environment field to `eas.schema.json`. ([#2719](https://github.com/expo/eas-cli/pull/2719) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Deprecate `eas secret` commands. ([#2705](https://github.com/expo/eas-cli/pull/2705) by [@szdziedzic](https://github.com/szdziedzic))
## [13.3.0](https://github.com/expo/eas-cli/releases/tag/v13.3.0) - 2024-11-18
### 🎉 New features
- Added `eas workflow` commands. [#2650](https://github.com/expo/eas-cli/pull/2650), [#2669](https://github.com/expo/eas-cli/pull/2669), [#2678](https://github.com/expo/eas-cli/pull/2678) by [@jonsamp](https://github.com/jonsamp) and [@sjchmiela](https://github.com/sjchmiela) ([#2683](https://github.com/expo/eas-cli/pull/2683) by [@sjchmiela](https://github.com/sjchmiela))
## [13.2.3](https://github.com/expo/eas-cli/releases/tag/v13.2.3) - 2024-11-15
## [13.2.2](https://github.com/expo/eas-cli/releases/tag/v13.2.2) - 2024-11-15
## [13.2.1](https://github.com/expo/eas-cli/releases/tag/v13.2.1) - 2024-11-14
## [13.2.0](https://github.com/expo/eas-cli/releases/tag/v13.2.0) - 2024-11-13
### 🎉 New features
- Add EAS_SKIP_AUTO_FINGERPRINT to skip fingerprint computation on build ([#2675](https://github.com/expo/eas-cli/pull/2675) by [@quinlanj](https://github.com/quinlanj))
- Default build-logger-level to debug when EXPO_DEBUG is set. ([#2676](https://github.com/expo/eas-cli/pull/2676) by [@wschurman](https://github.com/wschurman))
## [13.1.1](https://github.com/expo/eas-cli/releases/tag/v13.1.1) - 2024-11-08
## [13.1.0](https://github.com/expo/eas-cli/releases/tag/v13.1.0) - 2024-11-07
### 🎉 New features
- Compute fingerprint for builds with SDK 52 and higher ([#2663](https://github.com/expo/eas-cli/pull/2663) by [@quinlanj](https://github.com/quinlanj))
## [13.0.1](https://github.com/expo/eas-cli/releases/tag/v13.0.1) - 2024-11-05
### 🧹 Chores
- Upgrade EAS Metadata with new properties from App Store. ([#2671](https://github.com/expo/eas-cli/pull/2671) by [@byCedric](https://github.com/byCedric))
## [13.0.0](https://github.com/expo/eas-cli/releases/tag/v13.0.0) - 2024-11-05
### 🛠 Breaking changes
- Resolve versioned expo config using `npx expo config` command instead of using fixed `@expo/config` version shipped with EAS CLI, if available. ([#2529](https://github.com/expo/eas-cli/pull/2529) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Add `macos-sonoma-14.6-xcode-16.1` image and `sdk-52` image tag to `eas.schema.json`. ([#2666](https://github.com/expo/eas-cli/pull/2666) by [@szdziedzic](https://github.com/szdziedzic))
- Update `@expo` packages versions. ([#2530](https://github.com/expo/eas-cli/pull/2530) by [@szdziedzic](https://github.com/szdziedzic))
## [12.6.2](https://github.com/expo/eas-cli/releases/tag/v12.6.2) - 2024-10-29
### 🐛 Bug fixes
- Make email case insensitive during Apple authentication. ([#2659](https://github.com/expo/eas-cli/pull/2659) by [@byCedric](https://github.com/byCedric))
## [12.6.1](https://github.com/expo/eas-cli/releases/tag/v12.6.1) - 2024-10-25
### 🧹 Chores
- Create dynamic logged in context field and clean up erroneous SessionManager context field uses. ([#2648](https://github.com/expo/eas-cli/pull/2648) by [@wschurman](https://github.com/wschurman))
## [12.6.0](https://github.com/expo/eas-cli/releases/tag/v12.6.0) - 2024-10-21
### 🎉 New features
- Add `eas channel:pause` and `eas channel:resume` commands to pause/resume update delivery to builds using specific update channels and display paused status in channel details output. ([#2614](https://github.com/expo/eas-cli/pull/2614) by [@fiberjw](https://github.com/fiberjw))
- Add interactivity to eas update:edit command. ([#2638](https://github.com/expo/eas-cli/pull/2638) by [@wschurman](https://github.com/wschurman))
## [12.5.4](https://github.com/expo/eas-cli/releases/tag/v12.5.4) - 2024-10-19
## [12.5.3](https://github.com/expo/eas-cli/releases/tag/v12.5.3) - 2024-10-16
## [12.5.2](https://github.com/expo/eas-cli/releases/tag/v12.5.2) - 2024-10-11
### 🐛 Bug fixes
- Disallow republishing an update that is being rolled-out. ([#2602](https://github.com/expo/eas-cli/pull/2602) by [@wschurman](https://github.com/wschurman))
- Bump `@expo/apple-utils` to `2.0.2` resolving the Apple authentication error. ([#2641](https://github.com/expo/eas-cli/pull/2641) by [@byCedric](https://github.com/byCedric))
### 🧹 Chores
- Implement new `worker` deploy API flow. ([#2601](https://github.com/expo/eas-cli/pull/2601) by [@kitten](https://github.com/kitten)))
- Unhide rollout-percentage flag on update publish command. ([#2608](https://github.com/expo/eas-cli/pull/2608) by [@wschurman](https://github.com/wschurman))
- Let folks know about the new concurrency add-on ([#2611](https://github.com/expo/eas-cli/pull/2611) by [@brentvatne](https://github.com/brentvatne))
## [12.5.1](https://github.com/expo/eas-cli/releases/tag/v12.5.1) - 2024-09-27
### 🐛 Bug fixes
- Support assigning dev domain names in non interactive mode. ([#2595](https://github.com/expo/eas-cli/pull/2595) by [@byCedric](https://github.com/byCedric))
-16
View File
@@ -1,16 +0,0 @@
* @szdziedzic @khamilowicz @sjchmiela
packages/eas-cli/src/build @szdziedzic @khamilowicz @sjchmiela
packages/eas-cli/src/commands/build @szdziedzic @khamilowicz @sjchmiela
packages/eas-cli/src/submit @szdziedzic @khamilowicz @sjchmiela
packages/eas-cli/src/commands/submit.ts @szdziedzic @khamilowicz @sjchmiela
packages/eas-cli/src/update @EvanBacon @byCedric @wschurman
packages/eas-cli/src/commands/update @EvanBacon @byCedric @wschurman
packages/eas-cli/schema @bycedric
packages/eas-cli/src/metadata @bycedric
packages/eas-cli/src/commands/metadata @bycedric
packages/eas-json @szdziedzic @khamilowicz @sjchmiela
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "12.5.1",
"version": "14.5.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+565 -161
View File
File diff suppressed because it is too large. Load diff
+6250 -573
View File
File diff suppressed because it is too large. Load diff
+28 -19
View File
@@ -1,35 +1,34 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "12.5.1",
"version": "14.5.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "1.8.0",
"@expo/apple-utils": "2.1.5",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "8.5.4",
"@expo/config-plugins": "7.8.4",
"@expo/config-types": "50.0.0",
"@expo/eas-build-job": "1.0.136",
"@expo/eas-json": "12.5.0",
"@expo/env": "^0.3.0",
"@expo/json-file": "8.2.37",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "1.0.165",
"@expo/eas-json": "14.5.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "1.0.117",
"@expo/multipart-body-parser": "1.1.0",
"@expo/osascript": "2.0.33",
"@expo/package-manager": "1.1.2",
"@expo/pkcs12": "0.0.8",
"@expo/plist": "0.0.20",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.7.0",
"@expo/pkcs12": "0.1.3",
"@expo/plist": "0.2.0",
"@expo/plugin-help": "5.1.23",
"@expo/plugin-warn-if-update-available": "2.5.1",
"@expo/prebuild-config": "6.7.3",
"@expo/prebuild-config": "8.0.17",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.0",
"@expo/steps": "1.0.136",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "1.0.166",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^1.26.2",
"@oclif/plugin-autocomplete": "^2.3.10",
@@ -42,6 +41,7 @@
"chalk": "4.1.2",
"cli-progress": "3.12.0",
"dateformat": "4.6.3",
"diff": "7.0.0",
"dotenv": "16.3.1",
"env-paths": "2.2.0",
"envinfo": "7.11.0",
@@ -65,7 +65,7 @@
"mime": "3.0.0",
"minimatch": "5.1.2",
"minizlib": "3.0.1",
"nanoid": "3.3.4",
"nanoid": "3.3.8",
"node-fetch": "2.6.7",
"node-forge": "1.3.1",
"nullthrows": "1.1.1",
@@ -87,6 +87,7 @@
"untildify": "4.0.0",
"uuid": "9.0.1",
"wrap-ansi": "7.0.0",
"yaml": "2.6.0",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -94,8 +95,10 @@
"@graphql-codegen/introspection": "4.0.0",
"@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-operations": "4.0.1",
"@tsconfig/node18": "18.2.4",
"@types/cli-progress": "3.11.5",
"@types/dateformat": "3.0.1",
"@types/diff": "6.0.0",
"@types/envinfo": "7.8.3",
"@types/express": "4.17.15",
"@types/fs-extra": "11.0.4",
@@ -177,16 +180,22 @@
"secret": {
"description": "manage project and account secrets"
},
"env": {
"description": "manage project and account environment variables"
},
"update": {
"description": "manage individual updates"
},
"webhook": {
"description": "manage webhooks"
},
"workflow": {
"description": "manage workflows"
}
},
"warn-if-update-available": {
"timeoutInDays": 0.5,
"message": "<%= chalk('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available.') %>\n<%= chalk.dim('To upgrade, run ') %><%= chalk.dim(chalk.bold('npm install -g eas-cli')) %><%= chalk.dim('.') %>\n<%= chalk.dim('Proceeding with outdated version.') %>\n"
"message": "<%= chalk('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available.') %>\n<%= chalk.dim('To upgrade, run:') %>\n<%= chalk.dim(chalk.bold('npm install -g eas-cli')) %>\n<%= chalk.dim('Proceeding with outdated version.') %>\n"
},
"update": {
"node": {
+53 -18
View File
@@ -1,6 +1,20 @@
{
"$schema": "http://json-schema.org/draft-06/schema",
"type": "object",
"properties": {
"configVersion": {
"enum": [0],
"description": "The EAS metadata store configuration version"
},
"apple": {
"$ref": "#/definitions/AppleConfig"
}
},
"additionalProperties": false,
"required": [
"configVersion",
"apple"
],
"definitions": {
"apple": {
"AppleKidsAge": {
@@ -323,7 +337,8 @@
"gambling",
"unrestrictedWebAccess",
"kidsAgeBand",
"seventeenPlus"
"ageRatingOverride",
"koreaAgeRatingOverride"
],
"defaultSnippets": [
{
@@ -345,7 +360,9 @@
"gambling": false,
"unrestrictedWebAccess": false,
"kidsAgeBand": null,
"seventeenPlus": false
"ageRatingOverride": "NONE",
"koreaAgeRatingOverride": "NONE",
"lootBox": false
}
},
{
@@ -367,7 +384,9 @@
"unrestrictedWebAccess": false,
"gambling": false,
"kidsAgeBand": null,
"seventeenPlus": false
"ageRatingOverride": "${13|NONE,SEVENTEEN_PLUS,UNRATED|}",
"koreaAgeRatingOverride": "${14|NONE,FIFTEEN_PLUS,NINETEEN_PLUS|}",
"lootBox": false
}
}
],
@@ -442,8 +461,24 @@
},
"seventeenPlus": {
"type": "boolean",
"deprecated": true,
"deprecationMessage": "This property is deprecated, use `ageRatingOverride` instead.",
"description": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n@see https://help.apple.com/app-store-connect/#/dev599d50efb",
"markdownDescription": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n\n[Learn more](https://help.apple.com/app-store-connect/#/dev599d50efb)"
},
"ageRatingOverride": {
"$ref": "#/definitions/apple/AppleAgeRatingOverride",
"description": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n\nIf your app will be distributed through alternative app marketplaces or websites in the European Union and you believe your app is [Unrated](https://developer.apple.com/help/app-store-connect/reference/age-ratings), set this to `UNRATED`.\n\n@see https://help.apple.com/app-store-connect/#/dev599d50efb",
"markdownDescription": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n\nIf your app will be distributed through alternative app marketplaces or websites in the European Union and you believe your app is [Unrated](https://developer.apple.com/help/app-store-connect/reference/age-ratings), set this to `UNRATED`.\n\n[Learn more](https://help.apple.com/app-store-connect/#/dev599d50efb)"
},
"koreaAgeRatingOverride": {
"$ref": "#/definitions/apple/AppleKoreaAgeRatingOverride",
"description": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 15 or 19, you can manually set the age rating to 15+ or 19+.\n\nIf your app will be distributed through alternative app marketplaces or websites in the European Union and you believe your app is [Unrated](https://developer.apple.com/help/app-store-connect/reference/age-ratings), set this to `UNRATED`.\n\n@see https://help.apple.com/app-store-connect/#/dev599d50efb",
"markdownDescription": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 15 or 19, you can manually set the age rating to 15+ or 19+.\n\nIf your app will be distributed through alternative app marketplaces or websites in the European Union and you believe your app is [Unrated](https://developer.apple.com/help/app-store-connect/reference/age-ratings), set this to `UNRATED`.\n\n[Learn more](https://help.apple.com/app-store-connect/#/dev599d50efb)"
},
"lootBox": {
"type": "boolean",
"description": "Does your app contain purchasable loot boxes? These virtual containers provide players with in-game items, including functional cards or cosmetic modifications, based on random chance."
}
}
},
@@ -565,6 +600,20 @@
"maxLength": 4000
}
}
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
"SEVENTEEN_PLUS",
"UNRATED"
]
},
"AppleKoreaAgeRatingOverride": {
"enum": [
"NONE",
"FIFTEEN_PLUS",
"NINETEEN_PLUS"
]
}
},
"AppleConfig": {
@@ -775,19 +824,5 @@
}
}
}
},
"properties": {
"configVersion": {
"enum": [0],
"description": "The EAS metadata store configuration version"
},
"apple": {
"$ref": "#/definitions/AppleConfig"
}
},
"additionalProperties": false,
"required": [
"configVersion",
"apple"
]
}
}
@@ -1,4 +1,4 @@
import { ExpoConfig } from '@expo/config-types';
import { ExpoConfig } from '@expo/config';
import { AppVersionSource, EasJson } from '@expo/eas-json';
import { Command, Config } from '@oclif/core';
import { vol } from 'memfs';
+11 -8
View File
@@ -1,4 +1,5 @@
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { BranchNotFoundError } from './utils';
@@ -8,10 +9,11 @@ import { withErrorHandlingAsync } from '../graphql/client';
import {
CreateUpdateBranchForAppMutation,
CreateUpdateBranchForAppMutationVariables,
UpdateBranch,
UpdateBranchBasicInfoFragment,
UpdateBranchFragment,
} from '../graphql/generated';
import { BranchQuery } from '../graphql/queries/BranchQuery';
import { UpdateBranchBasicInfoFragmentNode } from '../graphql/types/UpdateBranchBasicInfo';
import Log from '../log';
import { formatBranch, getBranchDescription } from '../update/utils';
import { printJsonOnlyOutput } from '../utils/json';
@@ -126,7 +128,7 @@ function renderPageOfBranches(
export async function createUpdateBranchOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name }: CreateUpdateBranchForAppMutationVariables
): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
): Promise<UpdateBranchBasicInfoFragment> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
@@ -135,15 +137,17 @@ export async function createUpdateBranchOnAppAsync(
updateBranch {
createUpdateBranchForApp(appId: $appId, name: $name) {
id
name
...UpdateBranchBasicInfoFragment
}
}
}
${print(UpdateBranchBasicInfoFragmentNode)}
`,
{
appId,
name,
}
},
{ additionalTypenames: ['UpdateBranch'] }
)
.toPromise()
);
@@ -163,22 +167,21 @@ export async function ensureBranchExistsAsync(
appId: string;
branchName: string;
}
): Promise<{ branchId: string; createdBranch: boolean }> {
): Promise<{ branch: UpdateBranchBasicInfoFragment; createdBranch: boolean }> {
try {
const updateBranch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId,
name: branchName,
});
const { id } = updateBranch;
return { branchId: id, createdBranch: false };
return { branch: updateBranch, createdBranch: false };
} catch (error) {
if (error instanceof BranchNotFoundError) {
const newUpdateBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
appId,
name: branchName,
});
return { branchId: newUpdateBranch.id, createdBranch: true };
return { branch: newUpdateBranch, createdBranch: true };
} else {
throw error;
}
+2 -4
View File
@@ -1,9 +1,7 @@
import { Client } from '../vcs/vcs';
export async function getDefaultBranchNameAsync(vcsClient: Client): Promise<string> {
return (
(await vcsClient.getBranchNameAsync()) || `branch-${Math.random().toString(36).substring(2, 4)}`
);
export async function getDefaultBranchNameAsync(vcsClient: Client): Promise<string | null> {
return await vcsClient.getBranchNameAsync();
}
export class BranchNotFoundError extends Error {
@@ -1,23 +1,43 @@
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import { AndroidBuildProfile } from '@expo/eas-json/build/build/types';
import assert from 'assert';
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import path from 'path';
import { instance, mock } from 'ts-mockito';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppVersionMutation } from '../../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { getAppBuildGradleAsync, resolveConfigValue } from '../../../project/android/gradleUtils';
import { resolveVcsClient } from '../../../vcs';
import { Client } from '../../../vcs/vcs';
import {
BumpStrategy,
bumpVersionAsync,
bumpVersionInAppJsonAsync,
maybeResolveVersionsAsync,
resolveRemoteVersionCodeAsync,
} from '../version';
const fsReal = jest.requireActual('fs').promises as typeof fs;
jest.mock('fs');
jest.mock('../../../commandUtils/context/contextUtils/createGraphqlClient');
jest.mock('../../../vcs/vcs');
jest.mock('../../../graphql/queries/AppVersionQuery');
jest.mock('../../../graphql/mutations/AppVersionMutation');
jest.mock('../../../ora', () => ({
ora: jest.fn().mockImplementation(() => ({
start: jest.fn().mockImplementation(() => ({
succeed: jest.fn(),
stop: jest.fn(),
fail: jest.fn(),
})),
})),
}));
const vcsClient = resolveVcsClient();
@@ -216,12 +236,160 @@ describe(maybeResolveVersionsAsync, () => {
});
});
describe(resolveRemoteVersionCodeAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('uses current remote versionCode when remote version set, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue({
buildVersion: '11',
storeVersion: '1.2.3',
});
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject();
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: {} as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('11');
expect(createAppVersionAsyncSpy).not.toHaveBeenCalled();
});
it('initializes versionCode from local files when remote version not set, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ versionCode: 22, versionName: '2.3.4' });
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: {} as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('22');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('2.3.4');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('22');
});
it('initializes versionCode starting with 1 when remote version not set and not set in local files, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ versionCode: null, versionName: null });
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: {} as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('1');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('1.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('1');
});
it('increments current remote versionCode when remote version set, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue({
buildVersion: '11',
storeVersion: '1.2.3',
});
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject();
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: { autoIncrement: true } as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('12');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('3.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('12');
});
it('increments versionCode from local files when remote version not set, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ versionCode: 22, versionName: '2.3.4' });
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: { autoIncrement: true } as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('23');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('2.3.4');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('23');
});
it('initializes versionCode starting with 1 when remote version not set and not set in local files, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ versionCode: null, versionName: null });
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: { autoIncrement: true } as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('1');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('1.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('1');
});
});
function initBareWorkflowProject({
versionCode = 123,
versionName = '3.0.0',
}: {
versionCode?: number;
versionName?: string;
versionCode?: number | null;
versionName?: string | null;
} = {}): ExpoConfig {
const fakeExp: ExpoConfig = {
name: 'myproject',
@@ -239,8 +407,8 @@ function initBareWorkflowProject({
'./android/app/build.gradle': `android {
defaultConfig {
applicationId "com.expo.testapp"
versionCode ${versionCode}
versionName "${versionName}"
${versionCode ? `versionCode "${versionCode}"` : ''}
${versionName ? `versionName "${versionName}"` : ''}
}
}`,
'./android/app/src/main/AndroidManifest.xml': 'fake',
@@ -151,6 +151,7 @@ async function ensureAndroidCredentialsAsync(
ctx.android.gradleContext
);
const provider = new AndroidCredentialsProvider(ctx.credentialsCtx, {
name: ctx.buildProfile.keystoreName,
app: {
account: nullthrows(
ctx.user.accounts.find(a => a.name === ctx.accountName),
@@ -115,6 +115,10 @@ export async function prepareJobAsync(
},
}),
loggerLevel: ctx.loggerLevel,
// Technically, these are unused, but let's include them here for type consistency.
// See: https://github.com/expo/eas-build/pull/454
appId: ctx.projectId,
initiatingUserId: ctx.user.id,
};
return sanitizeBuildJob(job) as Android.Job;
+16 -4
View File
@@ -99,7 +99,7 @@ export async function maybeResolveVersionsAsync(
exp: ExpoConfig,
buildProfile: BuildProfile<Platform.ANDROID>,
vcsClient: Client
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
): Promise<{ appVersion?: string; appBuildVersion?: string; isVersionInitialized?: boolean }> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID, vcsClient);
if (workflow === Workflow.GENERIC) {
const buildGradle = await getAppBuildGradleAsync(projectDir);
@@ -113,6 +113,11 @@ export async function maybeResolveVersionsAsync(
resolveConfigValue(buildGradle, 'versionName', parsedGradleCommand?.flavor) ?? '1.0.0',
appBuildVersion:
resolveConfigValue(buildGradle, 'versionCode', parsedGradleCommand?.flavor) ?? '1',
isVersionInitialized: !resolveConfigValue(
buildGradle,
'versionCode',
parsedGradleCommand?.flavor
),
};
} catch {
return {};
@@ -204,14 +209,18 @@ export async function resolveRemoteVersionCodeAsync(
const localVersions = await maybeResolveVersionsAsync(projectDir, exp, buildProfile, vcsClient);
let currentBuildVersion: string;
let shouldInitializeVersionCode = false;
if (remoteVersions?.buildVersion) {
currentBuildVersion = remoteVersions.buildVersion;
} else {
if (localVersions.appBuildVersion) {
Log.warn(
'No remote versions are configured for this project, versionCode will be initialized based on the value from the local project.'
Log.log(
chalk.green(
'No remote versions are configured for this project, versionCode will be initialized based on the value from the local project.'
)
);
currentBuildVersion = localVersions.appBuildVersion;
shouldInitializeVersionCode = localVersions.isVersionInitialized ?? false;
} else {
Log.error(
`Remote versions are not configured and EAS CLI was not able to read the current version from your project. Use "eas build:version:set" to initialize remote versions.`
@@ -221,7 +230,10 @@ export async function resolveRemoteVersionCodeAsync(
}
if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) {
return currentBuildVersion;
} else if (!buildProfile.autoIncrement && !remoteVersions?.buildVersion) {
} else if (
(!buildProfile.autoIncrement && !remoteVersions?.buildVersion) ||
shouldInitializeVersionCode
) {
const spinner = ora(
`Initializing versionCode with ${chalk.bold(currentBuildVersion)}.`
).start();
+76 -15
View File
@@ -66,6 +66,7 @@ import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAs
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createFingerprintAsync } from '../utils/fingerprintCli';
import { printJsonOnlyOutput } from '../utils/json';
import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise';
@@ -174,8 +175,9 @@ export async function prepareBuildRequestForPlatformAsync<
}
assert(projectArchive);
const runtimeMetadata = await createAndMaybeUploadFingerprintAsync(ctx);
const metadata = await collectMetadataAsync(ctx, runtimeMetadata);
const runtimeAndFingerprintMetadata =
await computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync(ctx);
const metadata = await collectMetadataAsync(ctx, runtimeAndFingerprintMetadata);
const buildParams = resolveBuildParamsInput(ctx, metadata);
const job = await builder.prepareJobAsync(ctx, {
projectArchive,
@@ -486,8 +488,8 @@ async function handleSingleBuildProgressAsync(
spinner.succeed('Build finished');
return { refetch: false };
case BuildStatus.New:
spinner.text = `Build is waiting to enter the queue. Check your concurrency limit at ${link(
formatAccountSubscriptionsUrl(accountName)
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`;
break;
case BuildStatus.InQueue: {
@@ -509,7 +511,7 @@ async function handleSingleBuildProgressAsync(
Log.log('Start builds sooner in the priority queue.');
Log.log(
`Sign up for EAS Production or Enterprise at ${link(
formatAccountSubscriptionsUrl(accountName)
formatAccountBillingUrl(accountName)
)}`
);
}
@@ -656,18 +658,39 @@ function formatEstimatedWaitTime(estimatedWaitTimeLeftSeconds: number): string {
}
}
function formatAccountSubscriptionsUrl(accountName: string): string {
return new URL(
`/accounts/${accountName}/settings/subscriptions`,
getExpoWebsiteBaseUrl()
).toString();
function formatAccountBillingUrl(accountName: string): string {
return new URL(`/accounts/${accountName}/settings/billing`, getExpoWebsiteBaseUrl()).toString();
}
async function createAndMaybeUploadFingerprintAsync<T extends Platform>(
async function computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<{
runtimeVersion?: string | undefined;
fingerprintHash?: string | undefined;
fingerprintSource?: FingerprintSource | undefined;
}> {
const runtimeAndFingerprintMetadata =
await computeAndMaybeUploadFingerprintFromExpoUpdatesAsync(ctx);
if (!runtimeAndFingerprintMetadata?.fingerprintHash) {
const fingerprint = await computeAndMaybeUploadFingerprintWithoutExpoUpdatesAsync(ctx);
return {
...runtimeAndFingerprintMetadata,
...fingerprint,
};
} else {
return {
...runtimeAndFingerprintMetadata,
fingerprintHash: runtimeAndFingerprintMetadata.runtimeVersion,
};
}
}
async function computeAndMaybeUploadFingerprintFromExpoUpdatesAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<{
runtimeVersion?: string;
fingerprintSource?: FingerprintSource;
fingerprintHash?: string;
}> {
const resolvedRuntimeVersion = await resolveRuntimeVersionAsync({
exp: ctx.exp,
@@ -686,16 +709,54 @@ async function createAndMaybeUploadFingerprintAsync<T extends Platform>(
* It's ok for fingerprintSources or runtimeVersion to be empty
* fingerprintSources only exist if the project is using runtimeVersion.policy: fingerprint
*/
if (!resolvedRuntimeVersion.fingerprint || !resolvedRuntimeVersion.runtimeVersion) {
if (
!resolvedRuntimeVersion.expoUpdatesRuntimeFingerprint ||
!resolvedRuntimeVersion.runtimeVersion
) {
return {
runtimeVersion: resolvedRuntimeVersion.runtimeVersion ?? undefined,
};
}
return await maybeUploadFingerprintAsync({
runtimeVersion: resolvedRuntimeVersion.runtimeVersion,
fingerprint: resolvedRuntimeVersion.fingerprint,
const uploadedFingerprint = await maybeUploadFingerprintAsync({
hash: resolvedRuntimeVersion.runtimeVersion,
fingerprint: resolvedRuntimeVersion.expoUpdatesRuntimeFingerprint,
graphqlClient: ctx.graphqlClient,
localBuildMode: ctx.localBuildOptions.localBuildMode,
});
return {
runtimeVersion: uploadedFingerprint.hash,
fingerprintSource: uploadedFingerprint.fingerprintSource,
fingerprintHash: resolvedRuntimeVersion.expoUpdatesRuntimeFingerprintHash ?? undefined,
};
}
async function computeAndMaybeUploadFingerprintWithoutExpoUpdatesAsync<T extends Platform>(
ctx: BuildContext<T>,
{ debug }: { debug?: boolean } = {}
): Promise<{
fingerprintHash?: string;
fingerprintSource?: FingerprintSource;
}> {
const fingerprint = await createFingerprintAsync(ctx.projectDir, {
workflow: ctx.workflow,
platforms: [ctx.platform],
env: ctx.env,
});
if (!fingerprint) {
return {};
}
const uploadedFingerprint = await maybeUploadFingerprintAsync({
hash: fingerprint.hash,
fingerprint: {
fingerprintSources: fingerprint.sources,
isDebugFingerprintSource: debug ?? false,
},
graphqlClient: ctx.graphqlClient,
localBuildMode: ctx.localBuildOptions.localBuildMode,
});
return {
fingerprintHash: uploadedFingerprint.hash,
fingerprintSource: uploadedFingerprint.fingerprintSource,
};
}
@@ -54,9 +54,10 @@ async function resolveEnvVarsAsync({
projectId: string;
}): Promise<Env> {
const environment =
buildProfile.environment?.toUpperCase() ?? process.env.EAS_CURRENT_ENVIRONMENT;
buildProfile.environment?.toUpperCase() ??
resolveSuggestedEnvironmentForBuildProfileConfiguration(buildProfile);
if (!environment || !isEnvironment(environment)) {
if (!isEnvironment(environment)) {
Log.log(
`Loaded "env" configuration for the "${buildProfileName}" profile: ${
buildProfile.env && Object.keys(buildProfile.env).length > 0
@@ -81,21 +82,44 @@ async function resolveEnvVarsAsync({
.map(({ name, value }) => [name, value])
) as Record<string, string>;
const envVarsWithSource: Record<string, 'build profile' | 'EAS server'> = {
...Object.fromEntries(Object.keys(serverEnvVars).map(key => [key, 'EAS server'])),
...(buildProfile.env
? Object.fromEntries(Object.keys(buildProfile.env).map(key => [key, 'build profile']))
: null),
};
Log.log(
`Loaded "env" configuration for the "${buildProfileName}" profile and "${environment.toLowerCase()}" environment: ${
Object.keys(envVarsWithSource).length > 0
? Object.keys(envVarsWithSource)
.map(key => `${key} (source: ${envVarsWithSource[key]})`)
.join('\n')
: 'no environment variables specified'
}\n${learnMore('https://docs.expo.dev/build-reference/variables/')}`
);
if (Object.keys(serverEnvVars).length > 0) {
Log.log(
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment.toLowerCase()}" environment on EAS: ${Object.keys(
serverEnvVars
).join(', ')}.`
);
} else {
Log.log(
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment.toLowerCase()}" environment on EAS.`
);
}
if (buildProfile.env && Object.keys(buildProfile.env).length > 0) {
Log.log(
`Environment variables loaded from the "${buildProfileName}" build profile "env" configuration: ${
buildProfile.env && Object.keys(buildProfile.env).join(', ')
}.`
);
}
if (
buildProfile.env &&
Object.keys(buildProfile.env).length > 0 &&
Object.keys(serverEnvVars).length > 0
) {
const overlappingKeys = Object.keys(serverEnvVars).filter(
key => buildProfile.env && Object.keys(buildProfile.env).includes(key)
);
if (overlappingKeys.length > 0) {
Log.warn(
`The following environment variables are defined in both the "${buildProfileName}" build profile "env" configuration and the "${environment.toLowerCase()}" environment on EAS: ${overlappingKeys.join(
', '
)}. The values from the build profile configuration will be used.`
);
}
}
Log.newLine();
return { ...serverEnvVars, ...buildProfile.env };
} catch (e) {
@@ -107,3 +131,22 @@ async function resolveEnvVarsAsync({
return { ...buildProfile.env };
}
}
function resolveSuggestedEnvironmentForBuildProfileConfiguration(
buildProfile: BuildProfile
): EnvironmentVariableEnvironment {
const environment =
buildProfile.distribution === 'store'
? EnvironmentVariableEnvironment.Production
: buildProfile.developmentClient
? EnvironmentVariableEnvironment.Development
: EnvironmentVariableEnvironment.Preview;
Log.log(
`Resolved "${environment.toLowerCase()}" environment for the build. ${learnMore(
'https://docs.expo.dev/eas/environment-variables/#setting-the-environment-for-your-builds'
)}`
);
return environment;
}
@@ -1,13 +1,19 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import { IosBuildProfile } from '@expo/eas-json/build/build/types';
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import { instance, mock } from 'ts-mockito';
import type { XCBuildConfiguration } from 'xcode';
import { learnMore } from '../../../log';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { Target } from '../../../credentials/ios/types';
import { AppVersionMutation } from '../../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { readPlistAsync } from '../../../utils/plist';
import { resolveVcsClient } from '../../../vcs';
import { Client } from '../../../vcs/vcs';
import {
BumpStrategy,
bumpVersionAsync,
@@ -16,9 +22,23 @@ import {
getInfoPlistPath,
readBuildNumberAsync,
readShortVersionAsync,
resolveRemoteBuildNumberAsync,
} from '../version';
jest.mock('fs');
jest.mock('../../../commandUtils/context/contextUtils/createGraphqlClient');
jest.mock('../../../vcs/vcs');
jest.mock('../../../graphql/queries/AppVersionQuery');
jest.mock('../../../graphql/mutations/AppVersionMutation');
jest.mock('../../../ora', () => ({
ora: jest.fn().mockImplementation(() => ({
start: jest.fn().mockImplementation(() => ({
succeed: jest.fn(),
stop: jest.fn(),
fail: jest.fn(),
})),
})),
}));
const vcsClient = resolveVcsClient();
@@ -301,27 +321,6 @@ describe(readShortVersionAsync, () => {
);
expect(buildNumber).toBe('1.0.0');
});
it('fails when build number is invalid', async () => {
const exp = initBareWorkflowProject({
appVersion: '$(CURRENT_PROJECT_VERSION)',
});
await expect(
readShortVersionAsync(
'/app',
exp,
{
CURRENT_PROJECT_VERSION: '0.0.7.1.028',
},
vcsClient
)
).rejects.toThrowError(
`The required format for "CFBundleShortVersionString" in Info.plist is one to three period-separated integers, such as 10.14.1. The string can only contain numeric characters (0-9) and periods. Current value: 0.0.7.1.028. Edit the "CFBundleShortVersionString" in your Info.plist to match the required format. ${learnMore(
'https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring'
)}`
);
});
});
describe('managed project', () => {
@@ -381,6 +380,154 @@ describe(getInfoPlistPath, () => {
});
});
describe(resolveRemoteBuildNumberAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('uses current remote buildNumber when remote version set, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue({
buildVersion: '11',
storeVersion: '1.2.3',
});
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject();
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: {} as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('11');
expect(createAppVersionAsyncSpy).not.toHaveBeenCalled();
});
it('initializes buildNumber from local files when remote version not set, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ buildNumber: '22', appVersion: '2.3.4' });
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: {} as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('22');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('2.3.4');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('22');
});
it('initializes buildNumber starting with 1 when remote version not set and local version is 1, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ buildNumber: '1', appVersion: '1.0.0' });
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: {} as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('1');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('1.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('1');
});
it('increments current remote buildNumber when remote version set, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue({
buildVersion: '11',
storeVersion: '1.2.3',
});
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject();
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: { autoIncrement: true } as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('12');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('1.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('12');
});
it('increments buildNumber from local files when remote version not set, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ buildNumber: '22', appVersion: '2.3.4' });
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: { autoIncrement: true } as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('23');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('2.3.4');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('23');
});
it('initializes buildNumber starting with 1 when remote version not set and local version is 1, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ buildNumber: '1', appVersion: '1.0.0' });
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: { autoIncrement: true } as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('1');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('1.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('1');
});
});
function initBareWorkflowProject({
appVersion = '1.0.0',
buildNumber = '1',
@@ -110,6 +110,10 @@ export async function prepareJobAsync(
},
}),
loggerLevel: ctx.loggerLevel,
// Technically, these are unused, but let's include them here for type consistency.
// See: https://github.com/expo/eas-build/pull/454
appId: ctx.projectId,
initiatingUserId: ctx.user.id,
};
return sanitizeBuildJob(job) as Ios.Job;
}
+14 -7
View File
@@ -109,14 +109,14 @@ function validateShortVersion({
}): void {
if (shortVersion && !SHORT_VERSION_REGEX.test(shortVersion)) {
if (workflow === Workflow.MANAGED) {
throw new Error(
`The required format for "version" field from app.json/app.config.ts is one to three period-separated integers, such as 10.14.1. The string can only contain numeric characters (0-9) and periods. Current value: ${shortVersion}. Edit the "version" field in your app.json/app.config.ts to match the required format. ${learnMore(
Log.warn(
`The required format for "version" field from app.json/app.config.ts for App Store iOS builds is one to three period-separated integers, such as 10.14.1. The string can only contain numeric characters (0-9) and periods. Current value: ${shortVersion}. Edit the "version" field in your app.json/app.config.ts to match the format required by Apple. ${learnMore(
'https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring'
)}`
);
} else {
throw new Error(
`The required format for "CFBundleShortVersionString" in Info.plist is one to three period-separated integers, such as 10.14.1. The string can only contain numeric characters (0-9) and periods. Current value: ${shortVersion}. Edit the "CFBundleShortVersionString" in your Info.plist to match the required format. ${learnMore(
Log.warn(
`The required format for "CFBundleShortVersionString" in Info.plist for App Store iOS builds is one to three period-separated integers, such as 10.14.1. The string can only contain numeric characters (0-9) and periods. Current value: ${shortVersion}. Edit the "CFBundleShortVersionString" in your Info.plist to match the format required by Apple. ${learnMore(
'https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring'
)}`
);
@@ -330,14 +330,18 @@ export async function resolveRemoteBuildNumberAsync(
vcsClient
);
let currentBuildVersion: string;
let shouldInitializeBuildNumber = false;
if (remoteVersions?.buildVersion) {
currentBuildVersion = remoteVersions.buildVersion;
} else {
if (localBuildNumber) {
Log.warn(
'No remote versions are configured for this project, buildNumber will be initialized based on the value from the local project.'
Log.log(
chalk.green(
'No remote versions are configured for this project, buildNumber will be initialized based on the value from the local project.'
)
);
currentBuildVersion = localBuildNumber;
shouldInitializeBuildNumber = localBuildNumber === '1';
} else {
Log.error(
`Remote versions are not configured and EAS CLI was not able to read the current version from your project. Use "eas build:version:set" to initialize remote versions.`
@@ -347,7 +351,10 @@ export async function resolveRemoteBuildNumberAsync(
}
if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) {
return currentBuildVersion;
} else if (!buildProfile.autoIncrement && !remoteVersions?.buildVersion) {
} else if (
(!buildProfile.autoIncrement && !remoteVersions?.buildVersion) ||
shouldInitializeBuildNumber
) {
const spinner = ora(
`Initializing buildNumber with ${chalk.bold(currentBuildVersion)}.`
).start();
+1 -1
View File
@@ -7,7 +7,7 @@ import Log from '../log';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '1.0.135';
const PLUGIN_PACKAGE_VERSION = '1.0.163';
export enum LocalBuildMode {
/**
+5 -3
View File
@@ -16,8 +16,9 @@ import { easCliVersion } from '../utils/easCli';
export async function collectMetadataAsync<T extends Platform>(
ctx: BuildContext<T>,
runtimeMetadata: {
runtimeAndFingerprintMetadata: {
runtimeVersion?: string | undefined;
fingerprintHash?: string | undefined;
fingerprintSource?: FingerprintSource | undefined;
}
): Promise<Metadata> {
@@ -30,8 +31,9 @@ export async function collectMetadataAsync<T extends Platform>(
workflow: ctx.workflow,
credentialsSource: ctx.buildProfile.credentialsSource,
sdkVersion: ctx.exp.sdkVersion,
runtimeVersion: runtimeMetadata?.runtimeVersion,
fingerprintSource: runtimeMetadata?.fingerprintSource,
runtimeVersion: runtimeAndFingerprintMetadata?.runtimeVersion,
fingerprintHash: runtimeAndFingerprintMetadata?.fingerprintHash,
fingerprintSource: runtimeAndFingerprintMetadata?.fingerprintSource,
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
...channelObject,
distribution,
@@ -1,4 +1,4 @@
import { ExpoConfig } from '@expo/config-types';
import { ExpoConfig } from '@expo/config';
import { Env, Platform, Workflow } from '@expo/eas-build-job';
import {
AppVersionSource,
@@ -381,7 +381,7 @@ async function prepareAndStartBuildAsync({
vcsClient,
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata,
buildLoggerLevel: flags.buildLoggerLevel,
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
freezeCredentials: flags.freezeCredentials,
repack: flags.repack,
env,
+22
View File
@@ -50,3 +50,25 @@ export function getUpdateGroupUrl(
getExpoWebsiteBaseUrl()
).toString();
}
export function getWorkflowRunUrl(
accountName: string,
projectName: string,
workflowRunId: string
): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/workflows/${workflowRunId}`,
getExpoWebsiteBaseUrl()
).toString();
}
export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/github`,
getExpoWebsiteBaseUrl()
).toString();
}
+10 -6
View File
@@ -27,24 +27,28 @@ export async function checkGoogleServicesFileAsync<T extends Platform>(
if (!googleServicesFilePath) {
return;
}
const googleServicesEnvVar =
ctx.platform === Platform.ANDROID
? ctx.env.GOOGLE_SERVICES_JSON
: ctx.env.GOOGLE_SERVICES_INFO_PLIST;
const rootDir = path.normalize(await ctx.vcsClient.getRootPathAsync());
const absGoogleServicesFilePath = path.resolve(ctx.projectDir, googleServicesFilePath);
if (
(await fs.pathExists(absGoogleServicesFilePath)) &&
(!isInsideDirectory(absGoogleServicesFilePath, rootDir) ||
(await ctx.vcsClient.isFileIgnoredAsync(path.relative(rootDir, absGoogleServicesFilePath))))
(await ctx.vcsClient.isFileIgnoredAsync(
path.relative(rootDir, absGoogleServicesFilePath)
))) &&
!googleServicesEnvVar
) {
Log.warn(
`File specified via "${ctx.platform}.googleServicesFile" field in your app.json is not checked in to your repository and won't be uploaded to the builder.`
);
Log.warn(
`Use EAS Secret to pass all values that you don't want to include in your version control. ${learnMore(
'https://docs.expo.dev/build-reference/variables/#using-secrets-in-environment-variables'
`Use EAS file environment variables with secret or sensitive visibility to pass all values that you don't want to include in your version control to build process. ${learnMore(
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
)}`
);
Log.warn(
`If you are using that file for compatibility with the classic build service (expo build) you can silence this warning by setting your build profile's env.GOOGLE_SERVICES_FILE in eas.json to any non-empty string.`
);
Log.newLine();
}
}
@@ -128,6 +128,7 @@ export const testChannelObject: UpdateChannelObject = {
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
updateBranches: [testUpdateBranch1, testUpdateBranch2],
isPaused: false,
__typename: 'UpdateChannel',
};
+34 -26
View File
@@ -19,7 +19,7 @@ import {
import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
import Log from '../log';
import Log, { learnMore } from '../log';
import { getDisplayNameForProjectIdAsync } from '../project/projectUtils';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
@@ -110,7 +110,11 @@ export async function listAndRenderBranchesAndUpdatesOnChannelAsync(
}
): Promise<void> {
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, { appId, channelName });
renderChannelHeaderContent({ channelName: channel.name, channelId: channel.id });
renderChannelHeaderContent({
channelName: channel.name,
channelId: channel.id,
isPaused: channel.isPaused,
});
if (paginatedQueryOptions.nonInteractive) {
const branches = await queryBranchesAndUpdateGroupsOnChannelAsync(graphqlClient, {
@@ -167,7 +171,11 @@ function renderPageOfChannels(
printJsonOnlyOutput({ currentPage });
} else {
for (const channel of currentPage) {
renderChannelHeaderContent({ channelName: channel.name, channelId: channel.id });
renderChannelHeaderContent({
channelName: channel.name,
channelId: channel.id,
isPaused: channel.isPaused,
});
Log.addNewLineIfNone();
logChannelDetails(channel);
@@ -196,9 +204,11 @@ function renderPageOfBranchesOnChannel(
function renderChannelHeaderContent({
channelName,
channelId,
isPaused,
}: {
channelName: string;
channelId: string;
isPaused: boolean;
}): void {
Log.addNewLineIfNone();
Log.log(chalk.bold('Channel:'));
@@ -206,6 +216,7 @@ function renderChannelHeaderContent({
formatFields([
{ label: 'Name', value: channelName },
{ label: 'ID', value: channelId },
{ label: 'Status', value: isPaused ? 'Paused' : 'Active' },
])
);
Log.addNewLineIfNone();
@@ -292,7 +303,6 @@ export async function doesChannelExistAsync(
}
/**
*
* Creates a channel and links it to a branch with the same name.
*
* @param appId the app ID, also known as the project ID
@@ -308,7 +318,7 @@ export async function createAndLinkChannelAsync(
}: { appId: string; channelName: string; shouldPrintJson?: boolean }
): Promise<ChannelBasicInfo> {
let branchId: string;
let branchMessage: string;
let hasCreatedBranch = false;
try {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
@@ -316,7 +326,6 @@ export async function createAndLinkChannelAsync(
name: channelName,
});
branchId = branch.id;
branchMessage = `We found a branch with the same name`;
} catch (error) {
if (error instanceof BranchNotFoundError) {
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
@@ -324,7 +333,7 @@ export async function createAndLinkChannelAsync(
name: channelName,
});
branchId = newBranch.id;
branchMessage = `We also went ahead and made a branch with the same name`;
hasCreatedBranch = true;
} else {
throw error;
}
@@ -347,26 +356,25 @@ export async function createAndLinkChannelAsync(
if (shouldPrintJson) {
printJsonOnlyOutput(newChannel);
} else {
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, appId);
const learnMoreLink = learnMore('https://docs.expo.dev/eas-update/eas-cli/');
Log.addNewLineIfNone();
Log.withTick(
`Created a new channel on project ${chalk.bold(
await getDisplayNameForProjectIdAsync(graphqlClient, appId)
)}`
);
Log.log(
formatFields([
{ label: 'Name', value: newChannel.name },
{ label: 'ID', value: newChannel.id },
])
);
Log.addNewLineIfNone();
Log.withTick(`${branchMessage} and have pointed the channel at it.`);
Log.log(
formatFields([
{ label: 'Name', value: newChannel.name },
{ label: 'ID', value: branchId },
])
);
if (hasCreatedBranch) {
Log.withTick(
`Created update channel ${chalk.bold(`"${newChannel.name}"`)} and branch ${chalk.bold(
`"${newChannel.name}"`
)} on ${chalk.bold(projectDisplayName)} project. ${learnMoreLink}`
);
} else {
Log.withTick(
`Created update channel ${chalk.bold(`"${newChannel.name}"`)} on ${chalk.bold(
projectDisplayName
)} project and connected it with existing ${chalk.bold(
`"${newChannel.name}"`
)} branch. ${learnMoreLink}`
);
}
}
return newChannel;
}
@@ -6,6 +6,7 @@ import nullthrows from 'nullthrows';
import AnalyticsContextField from './context/AnalyticsContextField';
import ContextField from './context/ContextField';
import DynamicLoggedInContextField from './context/DynamicLoggedInContextField';
import {
DynamicPrivateProjectConfigContextField,
DynamicPublicProjectConfigContextField,
@@ -15,6 +16,8 @@ import MaybeLoggedInContextField from './context/MaybeLoggedInContextField';
import { OptionalPrivateProjectConfigContextField } from './context/OptionalPrivateProjectConfigContextField';
import { PrivateProjectConfigContextField } from './context/PrivateProjectConfigContextField';
import ProjectDirContextField from './context/ProjectDirContextField';
import { ProjectIdContextField } from './context/ProjectIdContextField';
import { ServerSideEnvironmentVariablesContextField } from './context/ServerSideEnvironmentVariablesContextField';
import SessionManagementContextField from './context/SessionManagementContextField';
import VcsClientContextField from './context/VcsClientContextField';
import { EasCommandError } from './errors';
@@ -23,6 +26,7 @@ import {
CommandEvent,
createAnalyticsAsync,
} from '../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../graphql/generated';
import Log from '../log';
import SessionManager from '../user/SessionManager';
import { Client } from '../vcs/vcs';
@@ -43,8 +47,26 @@ export type ContextOutput<
[P in keyof T]: T[P];
};
type GetContextType<Type> = {
[Property in keyof Type]: any;
};
const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.';
interface BaseGetContextAsyncArgs {
nonInteractive: boolean;
vcsClientOverride?: Client;
}
interface GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument
extends BaseGetContextAsyncArgs {
withServerSideEnvironment: EnvironmentVariableEnvironment | null;
}
interface GetContextAsyncArgsWithoutServerSideEnvironmentArgument extends BaseGetContextAsyncArgs {
withServerSideEnvironment?: never;
}
export default abstract class EasCommand extends Command {
protected static readonly ContextOptions = {
/**
@@ -61,8 +83,16 @@ export default abstract class EasCommand extends Command {
MaybeLoggedIn: {
maybeLoggedIn: new MaybeLoggedInContextField(),
},
/**
* Specify this context if the logged-in requirement is only necessary in a particular execution of the command.
*/
DynamicLoggedIn: {
// eslint-disable-next-line async-protect/async-suffix
getDynamicLoggedInAsync: new DynamicLoggedInContextField(),
},
/**
* Specify this context requirement if the command needs to mutate the user session.
* @deprecated Should not be used outside of session management commands, which currently only includes `login` and `logout`.
*/
SessionManagment: {
sessionManager: new SessionManagementContextField(),
@@ -72,7 +102,7 @@ export default abstract class EasCommand extends Command {
* run within a project directory, null otherwise.
*/
OptionalProjectConfig: {
privateProjectConfig: new OptionalPrivateProjectConfigContextField(),
optionalPrivateProjectConfig: new OptionalPrivateProjectConfigContextField(),
},
/**
* Require this command to be run in a project directory. Return the project directory in the context.
@@ -107,6 +137,16 @@ export default abstract class EasCommand extends Command {
Vcs: {
vcsClient: new VcsClientContextField(),
},
ServerSideEnvironmentVariables: {
// eslint-disable-next-line async-protect/async-suffix
getServerSideEnvironmentVariablesAsync: new ServerSideEnvironmentVariablesContextField(),
},
/**
* Require the project to be identified and registered on server. Returns the project ID evaluated from the app config.
*/
ProjectId: {
projectId: new ProjectIdContextField(),
},
};
/**
@@ -141,7 +181,19 @@ export default abstract class EasCommand extends Command {
} = object,
>(
commandClass: { contextDefinition: ContextInput<C> },
{ nonInteractive, vcsClientOverride }: { nonInteractive: boolean; vcsClientOverride?: Client }
{
nonInteractive,
vcsClientOverride,
// if specified and not null, the env vars from the selected environment will be fetched from the server
// to resolve dynamic config (if dynamic config context is used) and enable getServerSideEnvironmentVariablesAsync function (if server side environment variables context is used)
withServerSideEnvironment,
}: C extends
| GetContextType<typeof EasCommand.ContextOptions.ProjectConfig>
| GetContextType<typeof EasCommand.ContextOptions.DynamicProjectConfig>
| GetContextType<typeof EasCommand.ContextOptions.OptionalProjectConfig>
| GetContextType<typeof EasCommand.ContextOptions.ServerSideEnvironmentVariables>
? GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument
: GetContextAsyncArgsWithoutServerSideEnvironmentArgument
): Promise<ContextOutput<C>> {
const contextDefinition = commandClass.contextDefinition;
@@ -155,6 +207,7 @@ export default abstract class EasCommand extends Command {
sessionManager: this.sessionManager,
analytics: this.analytics,
vcsClientOverride,
withServerSideEnvironment,
}),
]);
}
+9 -1
View File
@@ -38,7 +38,12 @@ export async function fetchBuildsAsync({
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
filters?: { statuses?: BuildStatus[]; platform?: RequestedPlatform; profile?: string };
filters?: {
statuses?: BuildStatus[];
platform?: RequestedPlatform;
profile?: string;
hasFingerprint?: boolean;
};
}): Promise<BuildFragment[]> {
let builds: BuildFragment[];
const queryFilters: InputMaybe<BuildFilter> = {};
@@ -48,6 +53,9 @@ export async function fetchBuildsAsync({
if (filters?.profile) {
queryFilters['buildProfile'] = filters.profile;
}
if (filters?.hasFingerprint) {
queryFilters['hasFingerprint'] = filters.hasFingerprint;
}
if (!filters?.statuses) {
builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
@@ -1,4 +1,5 @@
import { Analytics } from '../../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import SessionManager from '../../user/SessionManager';
import { Client } from '../../vcs/vcs';
@@ -7,6 +8,10 @@ export interface ContextOptions {
analytics: Analytics;
nonInteractive: boolean;
vcsClientOverride?: Client;
/**
* If specified, env variables from the selected environment will be fetched from the server and used to evaluate the dynamic config.
*/
withServerSideEnvironment?: EnvironmentVariableEnvironment | null;
}
export default abstract class ContextField<T> {
@@ -0,0 +1,36 @@
import ContextField, { ContextOptions } from './ContextField';
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
import { LoggedInAuthenticationInfo } from '../../user/SessionManager';
import { Actor } from '../../user/User';
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
import FeatureGating from '../gating/FeatureGating';
export type DynamicLoggedInContextFn = () => Promise<{
actor: Actor;
featureGating: FeatureGating;
graphqlClient: ExpoGraphqlClient;
authenticationInfo: LoggedInAuthenticationInfo;
}>;
export default class DynamicLoggedInContextField extends ContextField<DynamicLoggedInContextFn> {
async getValueAsync({
nonInteractive,
sessionManager,
}: ContextOptions): Promise<DynamicLoggedInContextFn> {
return async () => {
const { actor, authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
const graphqlClient = createGraphqlClient(authenticationInfo);
return {
actor,
featureGating: new FeatureGating(featureGateServerValues, new FeatureGateEnvOverrides()),
graphqlClient,
authenticationInfo,
};
};
}
}
@@ -1,12 +1,14 @@
import { ExpoConfig } from '@expo/config-types';
import { ExpoConfig } from '@expo/config';
import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import {
ExpoConfigOptions,
getPrivateExpoConfig,
getPublicExpoConfig,
getPrivateExpoConfigAsync,
getPublicExpoConfigAsync,
} from '../../project/expoConfig';
export type DynamicConfigContextFn = (options?: ExpoConfigOptions) => Promise<{
@@ -19,15 +21,34 @@ export class DynamicPublicProjectConfigContextField extends ContextField<Dynamic
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<DynamicConfigContextFn> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
return async (options?: ExpoConfigOptions) => {
const expBefore = getPublicExpoConfig(projectDir, options);
const expBefore = await getPublicExpoConfigAsync(projectDir, options);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
env: options?.env,
});
const exp = getPublicExpoConfig(projectDir, options);
if (withServerSideEnvironment) {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
options = {
...options,
env: {
...options?.env,
...serverSideEnvironmentVariables,
},
};
}
const exp = await getPublicExpoConfigAsync(projectDir, options);
return {
exp,
projectDir,
@@ -41,15 +62,34 @@ export class DynamicPrivateProjectConfigContextField extends ContextField<Dynami
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<DynamicConfigContextFn> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
return async (options?: ExpoConfigOptions) => {
const expBefore = getPrivateExpoConfig(projectDir, options);
const expBefore = await getPrivateExpoConfigAsync(projectDir, options);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
env: options?.env,
});
const exp = getPrivateExpoConfig(projectDir, options);
if (withServerSideEnvironment) {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
options = {
...options,
env: {
...options?.env,
...serverSideEnvironmentVariables,
},
};
}
const exp = await getPrivateExpoConfigAsync(projectDir, options);
return {
exp,
projectDir,
@@ -1,19 +1,22 @@
import ContextField, { ContextOptions } from './ContextField';
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
import { LoggedInAuthenticationInfo } from '../../user/SessionManager';
import { Actor } from '../../user/User';
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
import FeatureGating from '../gating/FeatureGating';
export default class LoggedInContextField extends ContextField<{
type LoggedInContextType = {
actor: Actor;
featureGating: FeatureGating;
graphqlClient: ExpoGraphqlClient;
}> {
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<{
actor: Actor;
featureGating: FeatureGating;
graphqlClient: ExpoGraphqlClient;
}> {
authenticationInfo: LoggedInAuthenticationInfo;
};
export default class LoggedInContextField extends ContextField<LoggedInContextType> {
async getValueAsync({
nonInteractive,
sessionManager,
}: ContextOptions): Promise<LoggedInContextType> {
const { actor, authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
@@ -25,6 +28,7 @@ export default class LoggedInContextField extends ContextField<{
actor,
featureGating: new FeatureGating(featureGateServerValues, new FeatureGateEnvOverrides()),
graphqlClient,
authenticationInfo,
};
}
}
@@ -4,16 +4,18 @@ import { Actor } from '../../user/User';
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
import FeatureGating from '../gating/FeatureGating';
export default class MaybeLoggedInContextField extends ContextField<{
type MaybeLoggedInContextType = {
actor: Actor | null;
featureGating: FeatureGating;
graphqlClient: ExpoGraphqlClient;
}> {
async getValueAsync({ sessionManager }: ContextOptions): Promise<{
actor: Actor | null;
featureGating: FeatureGating;
graphqlClient: ExpoGraphqlClient;
}> {
authenticationInfo: {
accessToken: string | null;
sessionSecret: string | null;
};
};
export default class MaybeLoggedInContextField extends ContextField<MaybeLoggedInContextType> {
async getValueAsync({ sessionManager }: ContextOptions): Promise<MaybeLoggedInContextType> {
const authenticationInfo = {
accessToken: sessionManager.getAccessToken(),
sessionSecret: sessionManager.getSessionSecret(),
@@ -27,6 +29,7 @@ export default class MaybeLoggedInContextField extends ContextField<{
actor: actor ?? null,
featureGating: new FeatureGating(featureGateServerValues, new FeatureGateEnvOverrides()),
graphqlClient,
authenticationInfo,
};
}
}
@@ -1,10 +1,12 @@
import { ExpoConfig } from '@expo/config-types';
import { ExpoConfig } from '@expo/config';
import { InvalidEasJsonError } from '@expo/eas-json/build/errors';
import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { getPrivateExpoConfig } from '../../project/expoConfig';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
export class OptionalPrivateProjectConfigContextField extends ContextField<
| {
@@ -14,7 +16,11 @@ export class OptionalPrivateProjectConfigContextField extends ContextField<
}
| undefined
> {
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<
| {
projectId: string;
exp: ExpoConfig;
@@ -35,11 +41,24 @@ export class OptionalPrivateProjectConfigContextField extends ContextField<
return undefined;
}
const expBefore = getPrivateExpoConfig(projectDir);
const expBefore = await getPrivateExpoConfigAsync(projectDir);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
});
const exp = getPrivateExpoConfig(projectDir);
let serverSideEnvVars: Record<string, string> | undefined;
if (withServerSideEnvironment) {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
serverSideEnvVars = serverSideEnvironmentVariables;
}
const exp = await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars });
return {
exp,
projectDir,
@@ -1,26 +1,45 @@
import { ExpoConfig } from '@expo/config-types';
import { ExpoConfig } from '@expo/config';
import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { getPrivateExpoConfig } from '../../project/expoConfig';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
export class PrivateProjectConfigContextField extends ContextField<{
projectId: string;
exp: ExpoConfig;
projectDir: string;
}> {
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<{
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<{
projectId: string;
exp: ExpoConfig;
projectDir: string;
}> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
const expBefore = getPrivateExpoConfig(projectDir);
const expBefore = await getPrivateExpoConfigAsync(projectDir);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
});
const exp = getPrivateExpoConfig(projectDir);
let serverSideEnvVars: Record<string, string> | undefined;
if (withServerSideEnvironment) {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
serverSideEnvVars = serverSideEnvironmentVariables;
}
const exp = await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars });
return {
projectId,
@@ -0,0 +1,15 @@
import ContextField, { ContextOptions } from './ContextField';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
export class ProjectIdContextField extends ContextField<string> {
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<string> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
const expBefore = await getPrivateExpoConfigAsync(projectDir);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
});
return projectId;
}
}
@@ -0,0 +1,42 @@
import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import { getPublicExpoConfigAsync } from '../../project/expoConfig';
type GetServerSideEnvironmentVariablesFn = (
maybeEnv?: Record<string, string>
) => Promise<Record<string, string>>;
export class ServerSideEnvironmentVariablesContextField extends ContextField<GetServerSideEnvironmentVariablesFn> {
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<GetServerSideEnvironmentVariablesFn> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
return async (maybeEnv?: Record<string, string>) => {
if (!withServerSideEnvironment) {
throw new Error(
'withServerSideEnvironment parameter is required to evaluate ServerSideEnvironmentVariablesContextField'
);
}
const exp = await getPublicExpoConfigAsync(projectDir, { env: maybeEnv });
const projectId = await getProjectIdAsync(sessionManager, exp, {
nonInteractive,
env: maybeEnv,
});
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
return serverSideEnvironmentVariables;
};
}
}
@@ -6,6 +6,7 @@ import { Role } from '../../../../graphql/generated';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { learnMore } from '../../../../log';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { isExpoInstalled } from '../../../../project/projectUtils';
import SessionManager from '../../../../user/SessionManager';
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from '../getProjectIdAsync';
@@ -21,6 +22,7 @@ jest.mock('../../../../ora', () => ({
}),
}));
jest.mock('../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../../project/projectUtils');
describe(getProjectIdAsync, () => {
let sessionManager: SessionManager;
@@ -71,6 +73,7 @@ describe(getProjectIdAsync, () => {
);
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
jest.mocked(isExpoInstalled).mockReturnValue(true);
});
it('gets the project ID from app config if exists', async () => {
@@ -222,7 +225,7 @@ describe(getProjectIdAsync, () => {
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
jest.mocked(modifyConfigAsync).mockResolvedValue({
type: 'success',
config: { expo: { name: 'test', slug: 'test', extra: { eas: { projectId: '2345' } } } },
config: { name: 'test', slug: 'test', extra: { eas: { projectId: '2345' } } },
});
jest
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
@@ -42,7 +42,7 @@ async function ensureEasCliIsNotInDependenciesAsync(projectDir: string): Promise
consoleWarn(`Found ${chalk.bold('eas-cli')} in your project dependencies.`);
}
const maybeRepoRoot = PackageManagerUtils.findWorkspaceRoot(projectDir) ?? projectDir;
const maybeRepoRoot = PackageManagerUtils.resolveWorkspaceRoot(projectDir) ?? projectDir;
if (maybeRepoRoot !== projectDir && (await isEasCliInDependenciesAsync(maybeRepoRoot))) {
printCliVersionWarning = true;
consoleWarn(`Found ${chalk.bold('eas-cli')} in your monorepo dependencies.`);
@@ -103,6 +103,8 @@ export async function findProjectRootAsync({
}
}
let ranEnsureEasCliIsNotInDependencies = false;
/**
* Determine the project root directory and ensure some constraints about the project setup
* like CLI version and dependencies.
@@ -113,6 +115,9 @@ export async function findProjectRootAsync({
export async function findProjectDirAndVerifyProjectSetupAsync(): Promise<string> {
const projectDir = await findProjectRootAsync();
await applyCliConfigAsync(projectDir);
await ensureEasCliIsNotInDependenciesAsync(projectDir);
if (!ranEnsureEasCliIsNotInDependencies) {
ranEnsureEasCliIsNotInDependencies = true;
await ensureEasCliIsNotInDependenciesAsync(projectDir);
}
return projectDir;
}
@@ -1,5 +1,4 @@
import { getProjectConfigDescription } from '@expo/config';
import { ExpoConfig } from '@expo/config-types';
import { ExpoConfig, getProjectConfigDescription } from '@expo/config';
import { Env } from '@expo/eas-build-job';
import chalk from 'chalk';
@@ -8,9 +7,11 @@ import { findProjectRootAsync } from './findProjectDirAndVerifyProjectSetupAsync
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { learnMore } from '../../../log';
import { ora } from '../../../ora';
import { createOrModifyExpoConfigAsync, getPrivateExpoConfig } from '../../../project/expoConfig';
import {
createOrModifyExpoConfigAsync,
getPrivateExpoConfigAsync,
} from '../../../project/expoConfig';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { toAppPrivacy } from '../../../project/projectUtils';
import SessionManager from '../../../user/SessionManager';
import { Actor, getActorUsername } from '../../../user/User';
@@ -26,7 +27,7 @@ export async function saveProjectIdToAppConfigAsync(
options: { env?: Env } = {}
): Promise<void> {
// NOTE(cedric): we disable plugins to avoid writing plugin-generated content to `expo.extra`
const exp = getPrivateExpoConfig(projectDir, { skipPlugins: true, ...options });
const exp = await getPrivateExpoConfigAsync(projectDir, { skipPlugins: true, ...options });
const result = await createOrModifyExpoConfigAsync(
projectDir,
{
@@ -195,7 +196,6 @@ export async function validateOrSetProjectIdAsync({
{
accountName: getAccountNameForEASProjectSync(exp, actor),
projectName: exp.slug,
privacy: toAppPrivacy(exp.privacy),
},
{
nonInteractive: options.nonInteractive,
@@ -0,0 +1,59 @@
import { ExpoGraphqlClient } from './createGraphqlClient';
import { EnvironmentVariableEnvironment } from '../../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
const cachedServerSideEnvironmentVariables: Record<
EnvironmentVariableEnvironment,
Record<string, string> | null
> = {
[EnvironmentVariableEnvironment.Development]: null,
[EnvironmentVariableEnvironment.Preview]: null,
[EnvironmentVariableEnvironment.Production]: null,
};
export async function loadServerSideEnvironmentVariablesAsync({
environment,
projectId,
graphqlClient,
}: {
environment: EnvironmentVariableEnvironment;
projectId: string;
graphqlClient: ExpoGraphqlClient;
}): Promise<Record<string, string>> {
// don't load environment variables if they were already loaded while executing a command
const cachedEnvVarsForEnvironment = cachedServerSideEnvironmentVariables[environment];
if (cachedEnvVarsForEnvironment) {
return cachedEnvVarsForEnvironment;
}
const environmentVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(
graphqlClient,
{
appId: projectId,
environment,
}
);
const serverEnvVars = Object.fromEntries(
environmentVariables
.filter(({ name, value }) => name && value)
.map(({ name, value }) => [name, value])
) as Record<string, string>;
if (Object.keys(serverEnvVars).length > 0) {
Log.log(
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment.toLowerCase()}" environment on EAS: ${Object.keys(
serverEnvVars
).join(', ')}.`
);
} else {
Log.log(
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment.toLowerCase()}" environment on EAS.`
);
}
Log.newLine();
cachedServerSideEnvironmentVariables[environment] = serverEnvVars;
return serverEnvVars;
}
+36 -24
View File
@@ -1,10 +1,6 @@
import { Flags } from '@oclif/core';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../graphql/generated';
import { EnvironmentVariableEnvironment } from '../graphql/generated';
// NOTE: not exactly true, but, provided mapToLowercase and upperCaseAsync
// are used in tandem, it saves on unnecessary typying in commands
@@ -26,15 +22,24 @@ export const EasNonInteractiveAndJsonFlags = {
}),
};
export const EasEnvironmentFlagParameters = {
description: "Environment variable's environment",
parse: upperCaseAsync,
options: mapToLowercase([
EnvironmentVariableEnvironment.Development,
EnvironmentVariableEnvironment.Preview,
EnvironmentVariableEnvironment.Production,
]),
};
export const EASEnvironmentFlag = {
environment: Flags.enum<EnvironmentVariableEnvironment>(EasEnvironmentFlagParameters),
};
export const EASMultiEnvironmentFlag = {
environment: Flags.enum<EnvironmentVariableEnvironment>({
description: "Environment variable's environment",
parse: upperCaseAsync,
options: mapToLowercase([
EnvironmentVariableEnvironment.Development,
EnvironmentVariableEnvironment.Preview,
EnvironmentVariableEnvironment.Production,
]),
...EasEnvironmentFlagParameters,
multiple: true,
}),
};
@@ -47,23 +52,19 @@ export const EASVariableFormatFlag = {
};
export const EASVariableVisibilityFlag = {
visibility: Flags.enum<EnvironmentVariableVisibility>({
visibility: Flags.enum<'plaintext' | 'sensitive' | 'secret'>({
description: 'Visibility of the variable',
options: mapToLowercase([
EnvironmentVariableVisibility.Secret,
EnvironmentVariableVisibility.Sensitive,
EnvironmentVariableVisibility.Public,
]),
parse: upperCaseAsync,
options: ['plaintext', 'sensitive', 'secret'],
}),
};
export const EASVariableScopeFlag = {
scope: Flags.enum<EnvironmentVariableScope>({
export type EASEnvironmentVariableScopeFlagValue = 'project' | 'account';
export const EASEnvironmentVariableScopeFlag = {
scope: Flags.enum<EASEnvironmentVariableScopeFlagValue>({
description: 'Scope for the variable',
options: mapToLowercase([EnvironmentVariableScope.Shared, EnvironmentVariableScope.Project]),
parse: upperCaseAsync,
default: EnvironmentVariableScope.Project,
options: ['project', 'account'],
default: 'project',
}),
};
@@ -78,3 +79,14 @@ export const EasJsonOnlyFlag = {
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
}),
};
export const EasUpdateEnvironmentFlag = {
environment: Flags.enum<EnvironmentVariableEnvironment | null>({
description:
'Environment to use for the server-side defined EAS environment variables during command execution.',
options: mapToLowercase(Object.values(EnvironmentVariableEnvironment)),
parse: upperCaseAsync,
required: false,
default: null,
}),
};
@@ -12,16 +12,14 @@ export default class AccountView extends EasCommand {
static override contextDefinition = {
...this.ContextOptions.MaybeLoggedIn,
...this.ContextOptions.SessionManagment,
};
async runAsync(): Promise<void> {
const {
maybeLoggedIn: { actor },
sessionManager,
maybeLoggedIn: { actor, authenticationInfo },
} = await this.getContextAsync(AccountView, { nonInteractive: true });
if (actor) {
const loggedInAs = sessionManager.getAccessToken()
const loggedInAs = authenticationInfo.accessToken
? `${getActorDisplayName(actor)} (authenticated using EXPO_TOKEN)`
: getActorDisplayName(actor);
Log.log(chalk.green(loggedInAs));
@@ -25,7 +25,7 @@ export default class BranchCreate extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
};
@@ -36,7 +36,7 @@ export default class BranchCreate extends EasCommand {
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(BranchCreate);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
vcsClient,
} = await this.getContextAsync(BranchCreate, {
@@ -58,7 +58,7 @@ export default class BranchCreate extends EasCommand {
type: 'text',
name: 'name',
message: 'Provide a branch name:',
initial: await getDefaultBranchNameAsync(vcsClient),
initial: (await getDefaultBranchNameAsync(vcsClient)) ?? undefined,
validate: value => (value ? true : validationMessage),
}));
}
@@ -79,7 +79,7 @@ export default class BranchDelete extends EasCommand {
static override description = 'delete a branch';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -108,7 +108,7 @@ export default class BranchDelete extends EasCommand {
}
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchDelete, { nonInteractive });
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
+2 -2
View File
@@ -13,14 +13,14 @@ export default class BranchList extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BranchList);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchList, {
nonInteractive: flags['non-interactive'],
@@ -63,7 +63,7 @@ export default class BranchRename extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -72,7 +72,7 @@ export default class BranchRename extends EasCommand {
flags: { json: jsonFlag, from: currentName, to: newName, 'non-interactive': nonInteractive },
} = await this.parse(BranchRename);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchRename, {
nonInteractive,
+2 -2
View File
@@ -27,7 +27,7 @@ export default class BranchView extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -38,7 +38,7 @@ export default class BranchView extends EasCommand {
} = await this.parse(BranchView);
const { 'non-interactive': nonInteractive } = flags;
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchView, {
nonInteractive,
@@ -110,7 +110,7 @@ export default class BuildCancel extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
};
@@ -128,7 +128,7 @@ export default class BuildCancel extends EasCommand {
}
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildCancel, {
nonInteractive,
@@ -38,6 +38,7 @@ export default class BuildConfigure extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildConfigure, {
nonInteractive: false,
withServerSideEnvironment: null,
});
Log.log(
@@ -94,7 +94,7 @@ export default class BuildDelete extends EasCommand {
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
};
@@ -112,7 +112,7 @@ export default class BuildDelete extends EasCommand {
}
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildDelete, { nonInteractive });
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
@@ -142,6 +142,7 @@ export default class Build extends EasCommand {
vcsClient,
} = await this.getContextAsync(Build, {
nonInteractive: flags.nonInteractive,
withServerSideEnvironment: null,
});
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
@@ -79,6 +79,7 @@ export default class BuildInspect extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildInspect, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const outputDirectory = path.resolve(process.cwd(), flags.output);
@@ -7,6 +7,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import { RequestedPlatform } from '../../platform';
import { enableJsonOutput } from '../../utils/json';
import GitNoCommitClient from '../../vcs/clients/gitNoCommit';
import NoVcsClient from '../../vcs/clients/noVcs';
/**
* This command will be run on the EAS Build workers, when building
@@ -63,7 +64,8 @@ export default class BuildInternal extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildInternal, {
nonInteractive: true,
vcsClientOverride: new GitNoCommitClient(),
vcsClientOverride: process.env.EAS_NO_VCS ? new NoVcsClient() : new GitNoCommitClient(),
withServerSideEnvironment: null,
});
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
+6 -2
View File
@@ -62,6 +62,9 @@ export default class BuildList extends EasCommand {
aliases: ['gitCommitHash'],
description: 'Filter only builds created with the specified git commit hash',
}),
'fingerprint-hash': Flags.string({
description: 'Filter only builds with the specified fingerprint hash',
}),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: 10, limit: BUILDS_LIMIT }),
...EasNonInteractiveAndJsonFlags,
@@ -72,7 +75,7 @@ export default class BuildList extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
};
@@ -99,7 +102,7 @@ export default class BuildList extends EasCommand {
process.exit(1);
}
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildList, {
nonInteractive,
@@ -130,6 +133,7 @@ export default class BuildList extends EasCommand {
buildProfile: flags['build-profile'],
gitCommitHash: flags['git-commit-hash'],
simulator: flags.simulator,
fingerprintHash: flags['fingerprint-hash'],
},
paginatedQueryOptions,
});
@@ -120,6 +120,7 @@ export default class BuildResign extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildResign, {
nonInteractive: flags.nonInteractive,
withServerSideEnvironment: null,
});
const maybeBuild = flags.maybeBuildId
+2 -3
View File
@@ -79,8 +79,7 @@ export default class Run extends EasCommand {
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.ProjectId,
...this.ContextOptions.Vcs,
};
@@ -90,7 +89,7 @@ export default class Run extends EasCommand {
const queryOptions = getPaginatedQueryOptions(flags);
const {
loggedIn: { graphqlClient },
privateProjectConfig: { projectId },
projectId,
} = await this.getContextAsync(Run, {
nonInteractive: false,
});
@@ -54,6 +54,7 @@ export default class BuildVersionGetView extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildVersionGetView, {
nonInteractive: true,
withServerSideEnvironment: null,
});
if (!flags.platform && flags['non-interactive']) {
@@ -54,6 +54,7 @@ export default class BuildVersionSetView extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildVersionSetView, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const platform = await selectPlatformAsync(flags.platform);
@@ -73,6 +73,7 @@ export default class BuildVersionSyncView extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildVersionSyncView, {
nonInteractive: true,
withServerSideEnvironment: null,
});
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
@@ -119,7 +120,7 @@ export default class BuildVersionSyncView extends EasCommand {
const workflow = await resolveWorkflowAsync(projectDir, profileInfo.platform, vcsClient);
if (!remoteVersions?.buildVersion) {
Log.warn(
`Skipping versions sync for ${platformDisplayName}. There are no versions configured on Expo servers, use "eas build:version:set" or run a build to initialize it.`
`Skipping versions sync for ${platformDisplayName}. There are no versions configured on EAS, use "eas build:version:set" or run a build to initialize it.`
);
continue;
}
+2 -2
View File
@@ -18,7 +18,7 @@ export default class BuildView extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
};
@@ -29,7 +29,7 @@ export default class BuildView extends EasCommand {
flags,
} = await this.parse(BuildView);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildView, {
nonInteractive: true,
@@ -23,7 +23,7 @@ export default class ChannelCreate extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -33,7 +33,7 @@ export default class ChannelCreate extends EasCommand {
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(ChannelCreate);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelCreate, {
nonInteractive,
@@ -31,7 +31,7 @@ export default class ChannelDelete extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -41,7 +41,7 @@ export default class ChannelDelete extends EasCommand {
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(ChannelDelete);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelDelete, {
nonInteractive,
@@ -74,7 +74,7 @@ export default class ChannelEdit extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -84,7 +84,7 @@ export default class ChannelEdit extends EasCommand {
flags: { branch: branchFlag, json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelEdit);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelEdit, {
nonInteractive,
@@ -18,7 +18,7 @@ export default class ChannelList extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -27,7 +27,7 @@ export default class ChannelList extends EasCommand {
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelList, {
nonInteractive,
@@ -0,0 +1,113 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
PauseUpdateChannelMutation,
PauseUpdateChannelMutationVariables,
UpdateChannelBasicInfoFragment,
} from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../../graphql/types/UpdateChannelBasicInfo';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export async function pauseUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: PauseUpdateChannelMutationVariables
): Promise<UpdateChannelBasicInfoFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<PauseUpdateChannelMutation, PauseUpdateChannelMutationVariables>(
gql`
mutation PauseUpdateChannel($channelId: ID!) {
updateChannel {
pauseUpdateChannel(channelId: $channelId) {
id
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId }
)
.toPromise()
);
const channel = data.updateChannel.pauseUpdateChannel;
if (!channel) {
throw new Error(`Could not find a channel with id: ${channelId}`);
}
return channel;
}
export default class ChannelPause extends EasCommand {
static override description = 'pause a channel to stop it from sending updates';
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to edit',
},
];
static override flags = {
branch: Flags.string({
description: 'Name of the branch to point to',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args,
flags: { json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelPause);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelPause, {
nonInteractive,
});
if (json) {
enableJsonOutput();
}
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: args.name,
})
: await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to edit',
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
});
const channel = await pauseUpdateChannelAsync(graphqlClient, {
channelId: existingChannel.id,
});
if (json) {
printJsonOnlyOutput(channel);
} else {
Log.withTick(chalk`Channel {bold ${channel.name}} is now paused.\n`);
Log.addNewLineIfNone();
Log.log(
chalk`Users with builds on channel {bold ${channel.name}} will no longer receive updates.`
);
}
}
}
@@ -0,0 +1,111 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
ResumeUpdateChannelMutation,
ResumeUpdateChannelMutationVariables,
UpdateChannelBasicInfoFragment,
} from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../../graphql/types/UpdateChannelBasicInfo';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export async function resumeUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: ResumeUpdateChannelMutationVariables
): Promise<UpdateChannelBasicInfoFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<ResumeUpdateChannelMutation, ResumeUpdateChannelMutationVariables>(
gql`
mutation ResumeUpdateChannel($channelId: ID!) {
updateChannel {
resumeUpdateChannel(channelId: $channelId) {
id
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId }
)
.toPromise()
);
const channel = data.updateChannel.resumeUpdateChannel;
if (!channel) {
throw new Error(`Could not find a channel with id: ${channelId}`);
}
return channel;
}
export default class ChannelResume extends EasCommand {
static override description = 'resume a channel to start sending updates';
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to edit',
},
];
static override flags = {
branch: Flags.string({
description: 'Name of the branch to point to',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args,
flags: { json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelResume);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelResume, {
nonInteractive,
});
if (json) {
enableJsonOutput();
}
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: args.name,
})
: await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to edit',
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
});
const channel = await resumeUpdateChannelAsync(graphqlClient, {
channelId: existingChannel.id,
});
if (json) {
printJsonOnlyOutput(channel);
} else {
Log.withTick(chalk`Channel {bold ${channel.name}} is now active.\n`);
Log.addNewLineIfNone();
Log.log(chalk`Users with builds on channel {bold ${channel.name}} will now receive updates.`);
}
}
}
@@ -138,6 +138,7 @@ export default class ChannelRollout extends EasCommand {
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelRollout, {
nonInteractive: argsAndFlags.nonInteractive,
withServerSideEnvironment: null,
});
if (argsAndFlags.json) {
enableJsonOutput();
@@ -26,7 +26,7 @@ export default class ChannelView extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -38,7 +38,7 @@ export default class ChannelView extends EasCommand {
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelView, {
nonInteractive,
+4 -7
View File
@@ -6,7 +6,6 @@ import chalk from 'chalk';
import { evaluateConfigWithEnvVarsAsync } from '../build/evaluateConfigWithEnvVarsAsync';
import EasCommand from '../commandUtils/EasCommand';
import { createGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../commandUtils/flags';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
@@ -35,7 +34,7 @@ export default class Config extends EasCommand {
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.SessionManagment,
...this.ContextOptions.DynamicLoggedIn,
};
async runAsync(): Promise<void> {
@@ -48,9 +47,10 @@ export default class Config extends EasCommand {
profile: maybeProfile,
'non-interactive': nonInteractive,
} = flags;
const { getDynamicPublicProjectConfigAsync, projectDir, sessionManager } =
const { getDynamicPublicProjectConfigAsync, projectDir, getDynamicLoggedInAsync } =
await this.getContextAsync(Config, {
nonInteractive,
withServerSideEnvironment: null,
});
const accessor = EasJsonAccessor.fromProjectPath(projectDir);
@@ -88,10 +88,7 @@ export default class Config extends EasCommand {
Log.log(JSON.stringify(profile, null, 2));
}
} else {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const { graphqlClient } = await getDynamicLoggedInAsync();
const { exp: appConfig } = await evaluateConfigWithEnvVarsAsync({
buildProfile: profile,
buildProfileName: profileName,
@@ -39,6 +39,7 @@ export default class InitializeBuildCredentials extends EasCommand {
vcsClient,
} = await this.getContextAsync(InitializeBuildCredentials, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const platform = await selectPlatformAsync(flags.platform);
@@ -22,12 +22,13 @@ export default class Credentials extends EasCommand {
const { flags } = await this.parse(Credentials);
const {
loggedIn: { actor, graphqlClient },
privateProjectConfig,
optionalPrivateProjectConfig: privateProjectConfig,
getDynamicPrivateProjectConfigAsync,
analytics,
vcsClient,
} = await this.getContextAsync(Credentials, {
nonInteractive: false,
withServerSideEnvironment: null,
});
await new SelectPlatform(
@@ -33,12 +33,9 @@ interface RawDeployAliasFlags {
}
export default class WorkerAlias extends EasCommand {
static override description = 'Assign deployment aliases';
static override aliases = ['deploy:alias', 'deploy:promote'];
// TODO(@kitten): Keep command hidden until worker deployments are live
static override hidden = true;
static override state = 'beta';
static override description = 'Assign deployment aliases.';
static override aliases = ['worker:alias', 'deploy:promote'];
static override state = 'preview';
static override flags = {
prod: Flags.boolean({
@@ -73,13 +70,14 @@ export default class WorkerAlias extends EasCommand {
enableJsonOutput();
}
Log.warn('EAS Worker Deployments are in beta and subject to breaking changes.');
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkerAlias, {
nonInteractive: true,
withServerSideEnvironment: null,
});
const { projectId } = await getDynamicPrivateProjectConfigAsync();
@@ -17,7 +17,12 @@ import {
assignWorkerDeploymentProductionAsync,
getSignedDeploymentUrlAsync,
} from '../../worker/deployment';
import { UploadParams, batchUploadAsync, uploadAsync } from '../../worker/upload';
import {
UploadParams,
batchUploadAsync,
callUploadApiAsync,
uploadAsync,
} from '../../worker/upload';
import {
formatWorkerDeploymentJson,
formatWorkerDeploymentTable,
@@ -38,26 +43,32 @@ interface DeployFlags {
environment?: EnvironmentVariableEnvironment;
deploymentIdentifier?: string;
exportDir: string;
dryRun: boolean;
}
interface RawDeployFlags {
'non-interactive': boolean;
environment?: string;
environment?: EnvironmentVariableEnvironment;
json: boolean;
prod: boolean;
alias?: string;
id?: string;
'export-dir': string;
'dry-run': boolean;
}
interface DeployInProgressParams {
id: string;
fullName: string;
baseURL: string;
token: string;
}
export default class WorkerDeploy extends EasCommand {
static override description = 'Deploy your Expo web build';
static override aliases = ['deploy'];
static override description = 'Deploy your Expo Router web build and API Routes.';
static override aliases = ['worker:deploy'];
static override usage = [chalk`deploy {dim [options]}`, `deploy --prod`];
// TODO(@kitten): Keep command hidden until worker deployments are live
static override hidden = true;
static override state = 'beta';
static override state = 'preview';
static override flags = {
prod: Flags.boolean({
@@ -78,10 +89,11 @@ export default class WorkerDeploy extends EasCommand {
helpValue: 'dir',
default: 'dist',
}),
environment: {
...EASEnvironmentFlag.environment,
description: 'Deploy with EAS Environment Variables matching the specified environment.',
},
'dry-run': Flags.boolean({
description: 'Outputs a tarball of the new deployment instead of uploading it.',
default: false,
}),
...EASEnvironmentFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -99,13 +111,13 @@ export default class WorkerDeploy extends EasCommand {
enableJsonOutput();
}
Log.warn('EAS Worker Deployments are in beta and subject to breaking changes.');
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
projectDir,
} = await this.getContextAsync(WorkerDeploy, flags);
} = await this.getContextAsync(WorkerDeploy, { ...flags, withServerSideEnvironment: null });
const projectDist = await resolveExportedProjectAsync(flags, projectDir);
const { projectId } = await getDynamicPrivateProjectConfigAsync();
@@ -126,7 +138,24 @@ export default class WorkerDeploy extends EasCommand {
}
}
async function uploadTarballAsync(tarPath: string, uploadUrl: string): Promise<any> {
async function finalizeDeployAsync(deployParams: DeployInProgressParams): Promise<void> {
const finalizeDeployUrl = new URL('/deploy/finalize', deployParams.baseURL);
finalizeDeployUrl.searchParams.set('token', deployParams.token);
const result = await callUploadApiAsync(finalizeDeployUrl, {
method: 'POST',
headers: {
accept: 'application/json',
},
});
if (!result || typeof result !== 'object' || !('success' in result) || !result.success) {
throw new Error('Deploy failed: Incomplete asset uploads. Please try again');
}
}
async function uploadTarballAsync(
tarPath: string,
uploadUrl: string
): Promise<DeployInProgressParams> {
const { response } = await uploadAsync({
url: uploadUrl,
filePath: tarPath,
@@ -150,19 +179,23 @@ export default class WorkerDeploy extends EasCommand {
if (!json.success || !json.result || typeof json.result !== 'object') {
throw new Error(json.message ? `Upload failed: ${json.message}` : 'Upload failed!');
}
return json.result;
const { id, fullName, token } = json.result;
if (typeof token !== 'string') {
throw new Error('Upload failed: API failed to return a deployment token');
} else if (typeof id !== 'string') {
throw new Error('Upload failed: API failed to return a deployment identifier');
} else if (typeof fullName !== 'string') {
throw new Error('Upload failed: API failed to return a script name');
}
const baseURL = new URL('/', uploadUrl).toString();
return { id, fullName, baseURL, token };
}
}
async function uploadAssetsAsync(
assetMap: WorkerAssets.AssetMap,
uploads: Record<string, string>
deployParams: DeployInProgressParams
): Promise<void> {
if (typeof uploads !== 'object' || !uploads) {
return;
}
// TODO(@kitten): Batch and upload multiple files in parallel
const uploadParams: UploadParams[] = [];
const assetPath = projectDist.type === 'server' ? projectDist.clientPath : projectDist.path;
if (!assetPath) {
@@ -170,10 +203,9 @@ export default class WorkerDeploy extends EasCommand {
}
for await (const asset of WorkerAssets.listAssetMapFilesAsync(assetPath, assetMap)) {
const uploadURL = uploads[asset.normalizedPath];
if (uploadURL) {
uploadParams.push({ url: uploadURL, filePath: asset.path });
}
const uploadURL = new URL(`/asset/${asset.sha512}`, deployParams.baseURL);
uploadURL.searchParams.set('token', deployParams.token);
uploadParams.push({ url: uploadURL.toString(), filePath: asset.path });
}
const progress = {
@@ -214,11 +246,11 @@ export default class WorkerDeploy extends EasCommand {
let assetMap: WorkerAssets.AssetMap;
let tarPath: string;
let deployResult: any;
let deployResult: DeployInProgressParams;
let progress = ora('Preparing project').start();
try {
const manifest = await WorkerAssets.createManifestAsync(
const manifestResult = await WorkerAssets.createManifestAsync(
{
environment: flags.environment,
projectDir,
@@ -226,16 +258,33 @@ export default class WorkerDeploy extends EasCommand {
},
graphqlClient
);
if (manifestResult.conflictingVariableNames?.length) {
Log.warn(
'> The following environment variables were present in local .env files as well as EAS environment variables. ' +
'In case of conflict, the EAS environment variable values will be used: ' +
manifestResult.conflictingVariableNames.join(' ')
);
}
assetMap = await WorkerAssets.createAssetMapAsync(
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path
);
tarPath = await WorkerAssets.packFilesIterableAsync(
emitWorkerTarballAsync({
assetMap,
manifest,
manifest: manifestResult.manifest,
})
);
if (flags.dryRun) {
const DRY_RUN_OUTPUT_PATH = 'deploy.tar.gz';
await fs.promises.copyFile(tarPath, DRY_RUN_OUTPUT_PATH);
progress.succeed('Saved deploy.tar.gz tarball');
if (flags.json) {
printJsonOnlyOutput({ tarPath: DRY_RUN_OUTPUT_PATH });
}
return;
}
const uploadUrl = await getSignedDeploymentUrlAsync(graphqlClient, {
appId: projectId,
deploymentIdentifier: flags.deploymentIdentifier,
@@ -253,7 +302,8 @@ export default class WorkerDeploy extends EasCommand {
throw error;
}
await uploadAssetsAsync(assetMap, deployResult.uploads);
await uploadAssetsAsync(assetMap, deployResult);
await finalizeDeployAsync(deployResult);
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
if (flags.aliasName) {
@@ -349,6 +399,8 @@ export default class WorkerDeploy extends EasCommand {
aliasName: flags.alias?.trim().toLowerCase(),
deploymentIdentifier: flags.id?.trim(),
exportDir: flags['export-dir'],
environment: flags['environment'],
dryRun: flags['dry-run'],
};
}
}
@@ -378,7 +430,7 @@ async function resolveExportedProjectAsync(
if (!exportDirStat?.isDirectory()) {
throw new Error(
`No "${flags.exportDir}/" folder found. Prepare your project for deployment with "npx expo export --platform web"`
`No "${flags.exportDir}/" folder found. Export your app with "npx expo export --platform web"`
);
}
@@ -403,7 +455,8 @@ function logExportedProjectInfo(
// Only show the timestamp for exports older than 1 minute
if (project.modifiedAt && Date.now() - project.modifiedAt.getTime() > 60_000) {
modifiedAgo = ` - exported ${formatTimeAgo(project.modifiedAt)}`;
Log.warn(`> Project export: ${project.type}${modifiedAgo}`);
} else {
Log.log(chalk`{dim > Project export: ${project.type}}`);
}
Log.log(chalk`{dim > Project export: ${project.type}${modifiedAgo}}`);
}
@@ -15,9 +15,10 @@ export default class DeviceCreate extends EasCommand {
// this command is interactive by design
const {
loggedIn: { actor, graphqlClient },
privateProjectConfig,
optionalPrivateProjectConfig: privateProjectConfig,
} = await this.getContextAsync(DeviceCreate, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const ctx = await createContextAsync({
@@ -34,7 +34,7 @@ export default class DeviceDelete extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -43,7 +43,7 @@ export default class DeviceDelete extends EasCommand {
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
let { 'apple-team-id': appleTeamIdentifier, udid } = flags;
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(DeviceDelete, {
nonInteractive: paginatedQueryOptions.nonInteractive,
@@ -147,10 +147,10 @@ export default class DeviceDelete extends EasCommand {
graphqlClient: ExpoGraphqlClient,
chosenDevice: AppleDevice | AppleDeviceQueryResult
): Promise<void> {
const removalSpinner = ora(`Removing Apple device on Expo`).start();
const removalSpinner = ora(`Removing Apple device on EAS`).start();
try {
await AppleDeviceMutation.deleteAppleDeviceAsync(graphqlClient, chosenDevice.id);
removalSpinner.succeed('Removed Apple device from Expo');
removalSpinner.succeed('Removed Apple device from EAS');
} catch (err) {
removalSpinner.fail();
throw err;
+2 -2
View File
@@ -21,7 +21,7 @@ export default class BuildList extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -29,7 +29,7 @@ export default class BuildList extends EasCommand {
const { flags } = await this.parse(BuildList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildList, {
nonInteractive: paginatedQueryOptions.nonInteractive,
@@ -35,7 +35,7 @@ export default class DeviceRename extends EasCommand {
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -44,7 +44,7 @@ export default class DeviceRename extends EasCommand {
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
let { 'apple-team-id': appleTeamIdentifier, udid, name } = flags;
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(DeviceRename, {
nonInteractive: paginatedQueryOptions.nonInteractive,
@@ -105,12 +105,12 @@ export default class DeviceRename extends EasCommand {
chosenDevice: AppleDevice | AppleDeviceQueryResult,
newDeviceName: string
): Promise<void> {
const removalSpinner = ora(`Renaming Apple device on Expo`).start();
const removalSpinner = ora(`Renaming Apple device on EAS`).start();
try {
await AppleDeviceMutation.updateAppleDeviceAsync(graphqlClient, chosenDevice.id, {
name: newDeviceName,
});
removalSpinner.succeed('Renamed Apple device on Expo');
removalSpinner.succeed('Renamed Apple device on EAS');
} catch (err) {
removalSpinner.fail();
throw err;
+2 -2
View File
@@ -11,7 +11,7 @@ export default class DeviceView extends EasCommand {
static override args = [{ name: 'UDID' }];
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -32,7 +32,7 @@ If you are not sure what is the UDID of the device you are looking for, run:
throw new Error('Device UDID is missing');
}
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(DeviceView, {
nonInteractive: true,
@@ -0,0 +1,385 @@
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import {
parseVisibility,
promptVariableEnvironmentAsync,
promptVariableNameAsync,
promptVariableTypeAsync,
promptVariableValueAsync,
} from '../../../utils/prompts';
import EnvCreate from '../create';
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../utils/prompts');
describe(EnvCreate, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const variableId = 'testId';
const testAccountId = 'test-account-id';
beforeEach(() => {
jest.resetAllMocks();
jest
.mocked(parseVisibility)
.mockImplementation(jest.requireActual('../../../utils/prompts').parseVisibility);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mockImplementation(async (_client, input, _appId) => ({
...input,
id: variableId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
}));
jest
.mocked(EnvironmentVariableMutation.createSharedVariableAsync)
.mockImplementation(async (_client, input, _appId) => ({
...input,
id: variableId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Shared,
}));
jest
.mocked(EnvironmentVariableMutation.updateAsync)
.mockImplementation(async (_client, input) => ({
...input,
id: variableId,
name: 'VarName',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Shared,
type: EnvironmentSecretType.String,
}));
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockImplementation(async () => []);
jest.mocked(EnvironmentVariablesQuery.sharedAsync).mockImplementation(async () => []);
});
describe('in interactive mode', () => {
it('creates a project variable', async () => {
const command = new EnvCreate(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--visibility',
'secret',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
visibility: EnvironmentVariableVisibility.Secret,
type: EnvironmentSecretType.String,
},
testProjectId
);
});
it('updates an existing variable in the same environment', async () => {
const command = new EnvCreate(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--visibility',
'secret',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
const otherVariableId = 'otherId';
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
// @ts-expect-error
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [EnvironmentVariableEnvironment.Production],
scope: EnvironmentVariableScope.Project,
},
]);
// @ts-expect-error
jest.spyOn(command, 'promptForOverwriteAsync').mockResolvedValue(true);
jest
.mocked(EnvironmentVariableMutation.updateAsync)
// @ts-expect-error
.mockImplementation(async (_client, input) => ({
...input,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
}));
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
filterNames: ['VarName'],
});
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
visibility: EnvironmentVariableVisibility.Secret,
});
});
it('creates an account-wide variable', async () => {
const command = new EnvCreate(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--scope',
'account',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
privateProjectConfig: { projectId: testProjectId },
});
await command.runAsync();
expect(EnvironmentVariableMutation.createSharedVariableAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'VarName',
value: 'VarValue',
isGlobal: true,
environments: [EnvironmentVariableEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
'test-account-id'
);
});
it('throws if an account-wide variable already exists', async () => {
const command = new EnvCreate(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--scope',
'account',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
privateProjectConfig: { projectId: testProjectId },
});
jest
.mocked(EnvironmentVariablesQuery.sharedAsync)
// @ts-expect-error
.mockImplementation(async () => [
{
id: 'otherId',
environments: [EnvironmentVariableEnvironment.Production],
scope: EnvironmentVariableScope.Shared,
},
]);
await expect(command.runAsync()).rejects.toThrow();
});
it('updates if an account-wide variable already exists and --force flag is set', async () => {
const command = new EnvCreate(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--force',
'--scope',
'account',
],
mockConfig
);
const otherVariableId = 'otherId';
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
privateProjectConfig: { projectId: testProjectId },
});
jest
.mocked(EnvironmentVariablesQuery.sharedAsync)
// @ts-expect-error
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [EnvironmentVariableEnvironment.Production],
scope: EnvironmentVariableScope.Shared,
},
]);
await command.runAsync();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
});
});
it('creates an account-wide variable and links it', async () => {
const command = new EnvCreate(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--environment',
'development',
'--scope',
'account',
'--link',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariableMutation.createSharedVariableAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'VarName',
value: 'VarValue',
environments: [
EnvironmentVariableEnvironment.Production,
EnvironmentVariableEnvironment.Development,
],
visibility: EnvironmentVariableVisibility.Public,
isGlobal: true,
type: EnvironmentSecretType.String,
},
testAccountId
);
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'testId',
testProjectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'testId',
testProjectId,
EnvironmentVariableEnvironment.Development
);
});
it('prompts for missing arguments', async () => {
const command = new EnvCreate([], mockConfig);
jest.mocked(promptVariableNameAsync).mockImplementation(async () => 'VarName');
jest.mocked(promptVariableValueAsync).mockImplementation(async () => 'VarValue');
jest
.mocked(promptVariableTypeAsync)
.mockImplementation(async () => EnvironmentSecretType.String);
jest
.mocked(promptVariableEnvironmentAsync)
// @ts-expect-error
.mockImplementation(async () => [EnvironmentVariableEnvironment.Production]);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(promptVariableNameAsync).toHaveBeenCalled();
expect(promptVariableValueAsync).toHaveBeenCalled();
expect(promptVariableEnvironmentAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
testProjectId
);
});
});
});
@@ -0,0 +1,124 @@
import { Config } from '@oclif/core';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, toggleConfirmAsync } from '../../../prompts';
import EnvDelete from '../delete';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../log');
describe(EnvDelete, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockContext = {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
};
beforeEach(() => {
jest.resetAllMocks();
});
it('deletes a variable by name in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
environments: [EnvironmentVariableEnvironment.Production],
},
];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvDelete(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'production',
'--non-interactive',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(graphqlClient, variableId);
expect(Log.withTick).toHaveBeenCalledWith('️Deleted variable TEST_VARIABLE".');
});
it('prompts for variable selection when name is not provided', async () => {
const mockVariables = [
{ id: variableId, name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project },
];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
(promptAsync as jest.Mock).mockResolvedValue({ variable: mockVariables[0] });
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvDelete([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(graphqlClient, variableId);
expect(Log.withTick).toHaveBeenCalledWith('️Deleted variable TEST_VARIABLE".');
});
it('throws an error when variable name is not found', async () => {
const mockVariables = [
{ id: variableId, name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project },
];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvDelete(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow('Variable "NON_EXISTENT_VARIABLE" not found.');
});
it('throws an error when multiple variables with the same name are found', async () => {
const mockVariables = [
{ id: variableId, name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project },
{ id: '2', name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project },
];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvDelete(['--variable-name', 'TEST_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
'Multiple variables with name "TEST_VARIABLE" found. Please select the variable to delete interactively or run command with --variable-environment ENVIRONMENT option.'
);
});
it('cancels deletion when user does not confirm', async () => {
const mockVariables = [
{ id: variableId, name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project },
];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
(promptAsync as jest.Mock).mockResolvedValue({ variable: mockVariables[0] });
(toggleConfirmAsync as jest.Mock).mockResolvedValue(false);
const command = new EnvDelete(['--non-interactive'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrowErrorMatchingSnapshot();
expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,114 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptVariableEnvironmentAsync, promptVariableNameAsync } from '../../../utils/prompts';
import EnvGet from '../get';
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../utils/prompts');
describe(EnvGet, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const mockVariables: EnvironmentVariableFragment[] = [
{
id: 'var1',
name: 'TEST_VAR_1',
value: 'value1',
environments: [EnvironmentVariableEnvironment.Production],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
];
it('retrieves environment variables successfully', async () => {
jest
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
.mockResolvedValueOnce(mockVariables);
jest.spyOn(Log, 'log').mockImplementation(() => {});
const command = new EnvGet(['--variable-name', 'TEST_VAR_1'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
graphqlClient,
{
appId: testProjectId,
environment: undefined,
filterNames: ['TEST_VAR_1'],
includeFileContent: true,
}
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
});
it('handles errors during retrieval', async () => {
const errorMessage =
"Variable name is required. Run the command with '--variable-name VARIABLE_NAME' flag";
const command = new EnvGet([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
privateProjectConfig: { projectId: testProjectId },
});
await expect(command.runAsync()).rejects.toThrow(errorMessage);
});
it('prompts for variable name and environment if the name is ambigous', async () => {
jest
.mocked(promptVariableEnvironmentAsync)
.mockResolvedValueOnce(EnvironmentVariableEnvironment.Production);
jest.mocked(promptVariableNameAsync).mockResolvedValueOnce('TEST_VAR_1');
const command = new EnvGet([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
privateProjectConfig: { projectId: testProjectId },
});
jest.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).mockResolvedValueOnce([
...mockVariables,
{
id: 'var2',
name: 'TEST_VAR_1',
value: 'value1',
environments: [EnvironmentVariableEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
]);
await command.runAsync();
expect(promptVariableEnvironmentAsync).toHaveBeenCalled();
expect(promptVariableNameAsync).toHaveBeenCalled();
});
});
@@ -0,0 +1,271 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
import EnvLink from '../link';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
describe(EnvLink, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockContext = {
projectId,
loggedIn: { graphqlClient },
};
const successMessage = (env: EnvironmentVariableEnvironment): string =>
`Linked variable ${chalk.bold('TEST_VARIABLE')} to project ${chalk.bold(
'@testuser/testpp'
)} in ${env.toLocaleLowerCase()}.`;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('links an account-wide variable to the current project in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
const command = new EnvLink(
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Development)
);
});
it('links an account-wide variable to the current project to a specified environment', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
const command = new EnvLink(
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
});
it('uses --variable-environment to select the variable with ambigous name', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockImplementation(
(_client, { environment }) => {
return mockVariables.filter(v => v.environments.includes(environment));
}
);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--environment',
'production',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'other-id',
projectId,
EnvironmentVariableEnvironment.Production
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
});
it('prompts for variable selection when the name is ambigous', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({ environments: mockVariables[0].environments });
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(selectAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Preview)
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvLink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
it('uses environments from prompt to both link and unlink environments', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [EnvironmentVariableEnvironment.Production],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvLink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
});
@@ -0,0 +1,167 @@
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import EnvList from '../list';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
const mockVariables: EnvironmentVariableFragment[] = [
{
id: 'var1',
name: 'TEST_VAR_1',
value: 'value1',
environments: [EnvironmentVariableEnvironment.Production],
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
createdAt: undefined,
updatedAt: undefined,
type: EnvironmentSecretType.String,
},
{
id: 'var2',
name: 'TEST_VAR_2',
value: 'value2',
environments: [EnvironmentVariableEnvironment.Development],
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
createdAt: undefined,
updatedAt: undefined,
type: EnvironmentSecretType.String,
},
];
describe(EnvList, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('lists project environment variables successfully', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce(mockVariables);
const command = new EnvList([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: undefined,
includeFileContent: false,
});
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
});
it('lists project environment variables in specified environments', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce(mockVariables);
const command = new EnvList(['--environment', 'production'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: EnvironmentVariableEnvironment.Production,
includeFileContent: false,
});
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
});
it('lists project environment variables including sensitive values', async () => {
jest
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
.mockResolvedValueOnce(mockVariables);
const command = new EnvList(['--include-sensitive'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
graphqlClient,
{
appId: testProjectId,
environment: undefined,
includeFileContent: false,
}
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
});
it('lists account-wide environment variables successfully', async () => {
jest.mocked(EnvironmentVariablesQuery.sharedAsync).mockResolvedValueOnce(mockVariables);
const command = new EnvList(['--scope', 'account'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: undefined,
includeFileContent: false,
});
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
});
it('lists account-wide environment variables including sensitive values', async () => {
jest
.mocked(EnvironmentVariablesQuery.sharedWithSensitiveAsync)
.mockResolvedValueOnce(mockVariables);
const command = new EnvList(['--include-sensitive', '--scope', 'account'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.sharedWithSensitiveAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: undefined,
includeFileContent: false,
});
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
});
});
@@ -0,0 +1,226 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
import EnvUnlink from '../unlink';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
describe(EnvUnlink, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockContext = {
projectId,
loggedIn: { graphqlClient },
};
const successMessage = (env: EnvironmentVariableEnvironment): string =>
`Unlinked variable ${chalk.bold('TEST_VARIABLE')} from project ${chalk.bold(
'@testuser/testpp'
)} in ${env.toLocaleLowerCase()}.`;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('unlinks an account-wide variable from the current project in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
const command = new EnvUnlink(
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Development)
);
});
it('unlinks an account-wide variable from the current project in a specified environment', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Production],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
const command = new EnvUnlink(
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
});
it('prompts for variable selection when the name is ambigous', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvUnlink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(selectAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Preview)
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvUnlink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
it('uses environments from prompt to both link and unlink environments', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [EnvironmentVariableEnvironment.Production],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvUnlink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvUnlink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
});
@@ -0,0 +1,165 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, toggleConfirmAsync } from '../../../prompts';
import EnvUpdate from '../update';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
describe(EnvUpdate, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockContext = {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
};
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('updates a project variable by selected name in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
const command = new EnvUpdate(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--non-interactive',
'--value',
'new-value',
'--name',
'NEW_VARIABLE',
'--environment',
'production',
'--visibility',
'plaintext',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: variableId,
name: 'NEW_VARIABLE',
value: 'new-value',
environments: [EnvironmentVariableEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
});
expect(Log.withTick).toHaveBeenCalledWith(
`Updated variable ${chalk.bold('TEST_VARIABLE')} on project @testuser/testpp.`
);
});
it('updates an account-wide variable by selected name in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
const command = new EnvUpdate(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--non-interactive',
'--value',
'new-value',
'--name',
'NEW_VARIABLE',
'--environment',
'production',
'--scope',
'account',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: variableId,
name: 'NEW_VARIABLE',
value: 'new-value',
environments: [EnvironmentVariableEnvironment.Production],
});
expect(Log.withTick).toHaveBeenCalledWith(
`Updated variable ${chalk.bold('TEST_VARIABLE')} on account testuser.`
);
});
it('prompts for variable selection when selected name is not provided', async () => {
const mockVariables = [
{ id: variableId, name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project },
];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({ name: mockVariables[0].name });
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvUpdate([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ id: variableId })
);
expect(Log.withTick).toHaveBeenCalledWith(
`Updated variable ${chalk.bold('TEST_VARIABLE')} on project @testuser/testpp.`
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvUpdate(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
'Variable with name NON_EXISTENT_VARIABLE does not exist on project @testuser/testpp.'
);
});
});
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EnvDelete cancels deletion when user does not confirm 1`] = `"Environment variable needs 'name' to be specified when running in non-interactive mode. Run the command with --variable-name VARIABLE_NAME flag to fix the issue"`;
+291 -138
View File
@@ -1,14 +1,18 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASEnvironmentFlag,
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
EASMultiEnvironmentFlag,
EASNonInteractiveFlag,
EASVariableScopeFlag,
EASVariableVisibilityFlag,
} from '../../commandUtils/flags';
import {
EnvironmentSecretType,
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
@@ -22,27 +26,50 @@ import {
} from '../../project/projectUtils';
import { confirmAsync } from '../../prompts';
import {
parseVisibility,
promptVariableEnvironmentAsync,
promptVariableNameAsync,
promptVariableTypeAsync,
promptVariableValueAsync,
promptVariableVisibilityAsync,
} from '../../utils/prompts';
import { isEnvironment, performForEnvironmentsAsync } from '../../utils/variableUtils';
type CreateFlags = {
interface RawCreateFlags {
name?: string;
value?: string;
link?: boolean;
force?: boolean;
visibility?: EnvironmentVariableVisibility;
scope?: EnvironmentVariableScope;
environment?: EnvironmentVariableEnvironment;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
scope: EASEnvironmentVariableScopeFlagValue;
environment?: EnvironmentVariableEnvironment[];
'non-interactive': boolean;
};
}
export default class EnvironmentVariableCreate extends EasCommand {
static override description =
'create an environment variable on the current project or owner account';
interface CreateFlags {
name?: string;
value?: string;
link?: boolean;
force?: boolean;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
scope: EnvironmentVariableScope;
environment?: EnvironmentVariableEnvironment[];
'non-interactive': boolean;
}
static override hidden = true;
export default class EnvCreate extends EasCommand {
static override description = 'create an environment variable for the current project or account';
static override args = [
{
name: 'environment',
description:
"Environment to create the variable in. One of 'production', 'preview', or 'development'.",
required: false,
},
];
static override flags = {
name: Flags.string({
@@ -52,42 +79,51 @@ export default class EnvironmentVariableCreate extends EasCommand {
description: 'Text value or the variable',
}),
link: Flags.boolean({
description: 'Link shared variable to the current project',
description: 'Link account-wide variable to the current project',
hidden: true, // every account-wide variable is global for now so it's not user facing
}),
force: Flags.boolean({
description: 'Overwrite existing variable',
default: false,
}),
type: Flags.enum<'string' | 'file'>({
description: 'The type of variable',
options: ['string', 'file'],
}),
...EASVariableVisibilityFlag,
...EASVariableScopeFlag,
...EASEnvironmentFlag,
...EASEnvironmentVariableScopeFlag,
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.Analytics,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(EnvironmentVariableCreate);
const { args, flags } = await this.parse(EnvCreate);
let {
const validatedFlags = this.sanitizeFlags(flags);
const {
name,
value,
scope,
'non-interactive': nonInteractive,
environment,
environment: environments,
visibility,
link,
force,
} = this.validateFlags(flags);
type,
fileName,
} = await this.promptForMissingFlagsAsync(validatedFlags, args);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvironmentVariableCreate, {
} = await this.getContextAsync(EnvCreate, {
nonInteractive,
});
@@ -96,90 +132,77 @@ export default class EnvironmentVariableCreate extends EasCommand {
getOwnerAccountForProjectIdAsync(graphqlClient, projectId),
]);
if (!name) {
name = await promptVariableNameAsync(nonInteractive);
}
let overwrite = false;
visibility = visibility ?? EnvironmentVariableVisibility.Public;
if (!value) {
value = await promptVariableValueAsync({
nonInteractive,
hidden: visibility !== EnvironmentVariableVisibility.Public,
});
}
if (scope === EnvironmentVariableScope.Project) {
if (!environment) {
environment = await promptVariableEnvironmentAsync(nonInteractive);
}
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
environment,
filterNames: [name],
});
const existingVariable = existingVariables.find(variable => variable.name === name);
const existingVariable = existingVariables.find(
variable => !environments || variable.environments?.some(env => environments?.includes(env))
);
if (existingVariable) {
if (existingVariable.scope === EnvironmentVariableScope.Project) {
await this.promptForOverwriteAsync({
nonInteractive,
force,
message: `Variable ${name} already exists on this project.`,
suggestion: 'Do you want to overwrite it?',
});
overwrite = true;
}
if (existingVariable.scope === EnvironmentVariableScope.Shared) {
if (!nonInteractive) {
const confirmation = await confirmAsync({
message: `Shared variable ${name} already exists on this project. Do you want to unlink it first?`,
});
await this.promptForOverwriteAsync({
nonInteractive,
force,
message: `Account-wide variable with ${name} name already exists on this account.`,
suggestion: 'Do you want to unlink it first?',
});
if (!confirmation) {
Log.log('Aborting');
throw new Error(`Shared variable ${name} already exists on this project.`);
}
} else if (!force) {
throw new Error(
`Shared variable ${name} already exists on this project. Use --force to overwrite it.`
);
}
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
existingVariable.id,
projectId,
environment
);
Log.withTick(
`Unlinking shared variable ${chalk.bold(name)} on project ${chalk.bold(
`Unlinking account-wide variable ${chalk.bold(name)} on project ${chalk.bold(
projectDisplayName
)}.`
);
} else {
if (!nonInteractive) {
const confirmation = await confirmAsync({
message: `Variable ${name} already exists on this project. Do you want to overwrite it?`,
});
if (!confirmation) {
Log.log('Aborting');
throw new Error(
`Variable ${name} already exists on this project. Use --force to overwrite it.`
);
}
} else if (!force) {
throw new Error(
`Variable ${name} already exists on this project. Use --force to overwrite it.`
await performForEnvironmentsAsync(environments, async environment => {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
existingVariable.id,
projectId,
environment
);
}
overwrite = true;
});
}
}
const variable = await EnvironmentVariableMutation.createForAppAsync(
graphqlClient,
{
name,
value,
environment,
visibility,
overwrite,
},
projectId
);
const variable =
overwrite && existingVariable
? await EnvironmentVariableMutation.updateAsync(graphqlClient, {
id: existingVariable.id,
name,
value,
visibility,
environments,
type,
fileName,
})
: await EnvironmentVariableMutation.createForAppAsync(
graphqlClient,
{
name,
value,
environments,
visibility,
type: type ?? EnvironmentSecretType.String,
fileName,
},
projectId
);
if (!variable) {
throw new Error(
`Could not create variable with name ${name} on project ${projectDisplayName}`
@@ -190,41 +213,48 @@ export default class EnvironmentVariableCreate extends EasCommand {
`Created a new variable ${chalk.bold(name)} on project ${chalk.bold(projectDisplayName)}.`
);
} else if (scope === EnvironmentVariableScope.Shared) {
const sharedVariables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
const existingVariables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
filterNames: [name],
});
const existingVariable = sharedVariables.find(variable => variable.name === name);
const existingVariable = existingVariables.find(
variable => !environments || variable.environments?.some(env => environments?.includes(env))
);
if (existingVariable) {
throw new Error(
`Shared variable with ${name} name already exists on this account.\n` +
`Use a different name or delete the existing variable on website or by using eas env:delete --name ${name} --scope shared command.`
);
}
if (environment && !link) {
const confirmation = await confirmAsync({
message: `Unexpected argument: --environment can only be used with --link flag. Do you want to link the variable to the current project?`,
});
if (!confirmation) {
Log.log('Aborting');
throw new Error('Unexpected argument: --environment can only be used with --link flag.');
if (force) {
overwrite = true;
} else {
throw new Error(
`Account-wide variable with ${name} name already exists on this account.\n` +
`Use a different name or delete the existing variable on website or by using the "eas env:delete --name ${name} --scope account" command.`
);
}
}
if (!environment && link) {
environment = await promptVariableEnvironmentAsync(nonInteractive);
}
const variable = await EnvironmentVariableMutation.createSharedVariableAsync(
graphqlClient,
{
name,
value,
visibility,
},
ownerAccount.id
);
const variable =
overwrite && existingVariable
? await EnvironmentVariableMutation.updateAsync(graphqlClient, {
id: existingVariable.id,
name,
value,
visibility,
environments,
type,
})
: await EnvironmentVariableMutation.createSharedVariableAsync(
graphqlClient,
{
name,
value,
visibility,
environments,
isGlobal: true, // TODO: every account-wide variable is global for now so it's not user facing
type: type ?? EnvironmentSecretType.String,
},
ownerAccount.id
);
if (!variable) {
throw new Error(
@@ -236,41 +266,164 @@ export default class EnvironmentVariableCreate extends EasCommand {
`Created a new variable ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
);
if (link && environment) {
if (link) {
Log.withTick(
`Linking shared variable ${chalk.bold(name)} to project ${chalk.bold(
`Linking account-wide variable ${chalk.bold(name)} to project ${chalk.bold(
projectDisplayName
)}.`
);
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
variable.id,
projectId,
environment
);
await performForEnvironmentsAsync(environments, async environment => {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
variable.id,
projectId,
environment
);
});
Log.withTick(
`Linked shared variable ${chalk.bold(name)} to project ${chalk.bold(projectDisplayName)}.`
`Linked account-wide variable ${chalk.bold(name)} to project ${chalk.bold(
projectDisplayName
)}.`
);
}
}
}
private validateFlags(flags: CreateFlags): CreateFlags {
if (flags.scope !== EnvironmentVariableScope.Shared && flags.link) {
private async promptForOverwriteAsync({
nonInteractive,
force,
message,
suggestion,
}: {
nonInteractive: boolean;
force: boolean;
message: string;
suggestion: string;
}): Promise<void> {
if (!nonInteractive) {
const confirmation = await confirmAsync({
message: `${message} ${suggestion}`,
});
if (!confirmation) {
Log.log('Aborting');
throw new Error(`${message}`);
}
} else if (!force) {
throw new Error(`${message} Use --force to overwrite it.`);
}
}
private async promptForMissingFlagsAsync(
{
name,
value,
environment: environments,
visibility,
'non-interactive': nonInteractive,
type,
...rest
}: CreateFlags,
{ environment }: { environment?: string }
): Promise<
Required<
Omit<CreateFlags, 'type' | 'visibility'> & {
type: EnvironmentSecretType | undefined;
visibility: EnvironmentVariableVisibility;
}
> & { fileName?: string }
> {
if (!name) {
name = await promptVariableNameAsync(nonInteractive);
}
let newType;
let newVisibility = visibility ? parseVisibility(visibility) : undefined;
if (type === 'file') {
newType = EnvironmentSecretType.FileBase64;
} else if (type === 'string') {
newType = EnvironmentSecretType.String;
}
if (!type && !value && !nonInteractive) {
newType = await promptVariableTypeAsync(nonInteractive);
}
if (!newVisibility) {
newVisibility = await promptVariableVisibilityAsync(nonInteractive);
}
if (!value) {
value = await promptVariableValueAsync({
nonInteractive,
hidden: newVisibility !== EnvironmentVariableVisibility.Public,
filePath: newType === EnvironmentSecretType.FileBase64,
});
}
let environmentFilePath: string | undefined;
let fileName: string | undefined;
if (newType === EnvironmentSecretType.FileBase64) {
environmentFilePath = path.resolve(value);
if (!(await fs.pathExists(environmentFilePath))) {
throw new Error(`File "${value}" does not exist`);
}
fileName = path.basename(environmentFilePath);
}
value = environmentFilePath ? await fs.readFile(environmentFilePath, 'base64') : value;
if (environment && !isEnvironment(environment.toUpperCase())) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
let newEnvironments = environments
? environments
: environment
? [environment.toUpperCase() as EnvironmentVariableEnvironment]
: undefined;
if (!newEnvironments) {
newEnvironments = await promptVariableEnvironmentAsync({ nonInteractive, multiple: true });
if (!newEnvironments || newEnvironments.length === 0) {
throw new Error('No environments selected');
}
}
newVisibility = newVisibility ?? EnvironmentVariableVisibility.Public;
return {
name,
value,
environment: newEnvironments,
visibility: newVisibility,
link: rest.link ?? false,
force: rest.force ?? false,
'non-interactive': nonInteractive,
type: newType,
fileName,
...rest,
};
}
private sanitizeFlags(flags: RawCreateFlags): CreateFlags {
if (flags.scope !== 'account' && flags.link) {
throw new Error(
`Unexpected argument: --link can only be used when creating shared variables`
`Unexpected argument: --link can only be used when creating account-wide variables`
);
}
if (
flags.scope === EnvironmentVariableScope.Shared &&
flags.environment &&
!flags.link &&
flags['non-interactive']
) {
throw new Error(
'Unexpected argument: --environment in non-interactive mode can only be used with --link flag.'
);
}
return flags;
return {
...flags,
scope:
flags.scope === 'account'
? EnvironmentVariableScope.Shared
: EnvironmentVariableScope.Project,
};
}
}
+98 -49
View File
@@ -1,96 +1,126 @@
import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASEnvironmentFlag,
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
EASNonInteractiveFlag,
EASVariableScopeFlag,
EasEnvironmentFlagParameters,
} from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment, EnvironmentVariableScope } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
type DeleteFlags = {
name?: string;
environment?: EnvironmentVariableEnvironment;
interface DeleteFlags {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
scope?: EnvironmentVariableScope;
};
scope: EnvironmentVariableScope;
}
export default class EnvironmentVariableDelete extends EasCommand {
static override description = 'delete an environment variable by name';
interface RawDeleteFlags {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
scope: EASEnvironmentVariableScopeFlagValue;
}
static override hidden = true;
export default class EnvDelete extends EasCommand {
static override description = 'delete an environment variable for the current project or account';
static override flags = {
name: Flags.string({
'variable-name': Flags.string({
description: 'Name of the variable to delete',
}),
...EASVariableScopeFlag,
...EASEnvironmentFlag,
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable to delete',
}),
...EASEnvironmentVariableScopeFlag,
...EASNonInteractiveFlag,
};
static override args = [
{
name: 'environment',
description:
"Current environment of the variable to delete. One of 'production', 'preview', or 'development'.",
required: false,
},
];
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(EnvironmentVariableDelete);
let { name, environment, 'non-interactive': nonInteractive, scope } = this.validateFlags(flags);
const { args, flags } = await this.parse(EnvDelete);
const {
privateProjectConfig: { projectId },
'variable-name': name,
'variable-environment': environment,
'non-interactive': nonInteractive,
scope,
} = this.sanitizeInputs(flags, args);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvironmentVariableDelete, {
} = await this.getContextAsync(EnvDelete, {
nonInteractive,
});
if (scope === EnvironmentVariableScope.Project) {
if (!environment) {
environment = await promptVariableEnvironmentAsync(nonInteractive);
}
}
const variables =
scope === EnvironmentVariableScope.Project && environment
scope === EnvironmentVariableScope.Project
? await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
environment,
})
: await EnvironmentVariablesQuery.sharedAsync(graphqlClient, { appId: projectId });
: await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
environment,
});
let selectedVariable;
if (!name) {
({ name } = await promptAsync({
({ variable: selectedVariable } = await promptAsync({
type: 'select',
name: 'name',
name: 'variable',
message: 'Pick the variable to be deleted:',
choices: variables
.filter(({ scope: variableScope }) => scope === variableScope)
.map(variable => ({
title: variable.name,
value: variable.name,
})),
.map(variable => {
return {
title: formatVariableName(variable),
value: variable,
};
}),
}));
} else {
const selectedVariables = variables.filter(
variable =>
variable.name === name && (!environment || variable.environments?.includes(environment))
);
if (!name) {
throw new Error(
`Environment variable wasn't selected. Run the command again and select existing variable or run it with ${chalk.bold(
'--name VARIABLE_NAME'
)} flag to fix the issue.`
);
if (selectedVariables.length !== 1) {
if (selectedVariables.length === 0) {
throw new Error(`Variable "${name}" not found.`);
} else {
throw new Error(
`Multiple variables with name "${name}" found. Please select the variable to delete interactively or run command with --variable-environment ENVIRONMENT option.`
);
}
}
selectedVariable = selectedVariables[0];
}
const selectedVariable = variables.find(variable => variable.name === name);
if (!selectedVariable) {
throw new Error(`Variable "${name}" not found.`);
}
assert(selectedVariable, `Variable "${name}" not found.`);
if (!nonInteractive) {
Log.addNewLineIfNone();
@@ -106,7 +136,7 @@ export default class EnvironmentVariableDelete extends EasCommand {
});
if (!confirmed) {
Log.error(`Canceled deletion of variable ${selectedVariable.name}.`);
throw new Error(`Variable "${name}" not deleted.`);
throw new Error(`Variable "${selectedVariable.name}" not deleted.`);
}
}
@@ -115,17 +145,36 @@ export default class EnvironmentVariableDelete extends EasCommand {
Log.withTick(`️Deleted variable ${selectedVariable.name}".`);
}
private validateFlags(flags: DeleteFlags): DeleteFlags {
private sanitizeInputs(
flags: RawDeleteFlags,
{ environment }: { environment?: string }
): DeleteFlags {
if (flags['non-interactive']) {
if (!flags.name) {
if (!flags['variable-name']) {
throw new Error(
`Environment variable needs 'name' to be specified when running in non-interactive mode. Run the command with ${chalk.bold(
'--name VARIABLE_NAME'
'--variable-name VARIABLE_NAME'
)} flag to fix the issue`
);
}
}
return flags;
const scope =
flags.scope === 'account'
? EnvironmentVariableScope.Shared
: EnvironmentVariableScope.Project;
if (environment) {
environment = environment.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error(
"Invalid environment. Use one of 'production', 'preview', or 'development'."
);
}
return { ...flags, 'variable-environment': environment, scope };
}
return { ...flags, scope };
}
}
+105 -53
View File
@@ -3,96 +3,152 @@ import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { isEnvironment } from '../../utils/variableUtils';
type ParsedFlags =
| {
nonInteractive: true;
environment: EnvironmentVariableEnvironment;
command: string;
}
| {
nonInteractive: false;
environment?: EnvironmentVariableEnvironment;
command: string;
};
interface RawFlags {
environment?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
}
export default class EnvExec extends EasCommand {
static override description =
'execute a bash command with environment variables from the selected environment';
static override hidden = true;
'execute a command with environment variables from the selected environment';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
static override flags = {
...EASEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override args = [
{
name: 'BASH_COMMAND',
name: 'environment',
required: true,
description:
"Environment to execute the command in. One of 'production', 'preview', or 'development'.",
},
{
name: 'bash_command',
required: true,
description: 'bash command to execute with the environment variables from the environment',
},
];
private isNonInteractive: boolean = false;
async runAsync(): Promise<void> {
const {
flags,
args: { BASH_COMMAND: command },
} = await this.parse(EnvExec);
const parsedFlags = this.sanitizeFlags(flags);
const { flags, args } = await this.parse(EnvExec);
const parsedFlags = this.sanitizeFlagsAndArgs(flags, args);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvExec, {
nonInteractive: parsedFlags.nonInteractive,
});
this.isNonInteractive = parsedFlags.nonInteractive;
const environment =
parsedFlags.environment ?? (await promptVariableEnvironmentAsync(parsedFlags.nonInteractive));
parsedFlags.environment ??
(await promptVariableEnvironmentAsync({ nonInteractive: parsedFlags.nonInteractive }));
const environmentVariables = await this.loadEnvironmentVariablesAsync({
graphqlClient,
projectId,
environment,
nonInteractive: parsedFlags.nonInteractive,
});
await this.runCommandWithEnvVarsAsync({ command, environmentVariables });
if (parsedFlags.nonInteractive) {
await this.runCommandNonInteractiveWithEnvVarsAsync({
command: parsedFlags.command,
environmentVariables,
});
} else {
await this.runCommandWithEnvVarsAsync({
command: parsedFlags.command,
environmentVariables,
});
}
}
private sanitizeFlags(rawFlags: RawFlags): ParsedFlags {
const environment = rawFlags.environment;
if (rawFlags['non-interactive']) {
if (!environment) {
throw new Error(
'You must specify an environment when running in non-interactive mode. Use the --environment flag.'
);
}
return {
nonInteractive: true,
environment,
};
private sanitizeFlagsAndArgs(
rawFlags: RawFlags,
{ bash_command, environment }: Record<string, string>
): ParsedFlags {
if (rawFlags['non-interactive'] && (!bash_command || !environment)) {
throw new Error(
"You must specify both environment and bash command when running in non-interactive mode. Run command as `eas env:exec ENVIRONMENT 'bash command'`."
);
}
environment = environment?.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const firstChar = bash_command[0];
const lastChar = bash_command[bash_command.length - 1];
const cleanCommand =
(firstChar === '"' && lastChar === '"') || (firstChar === "'" && lastChar === "'")
? bash_command.slice(1, -1)
: bash_command;
return {
nonInteractive: false,
environment: rawFlags.environment,
nonInteractive: rawFlags['non-interactive'],
environment,
command: cleanCommand,
};
}
// eslint-disable-next-line async-protect/async-suffix
protected override async catch(err: Error): Promise<any> {
// when in non-interactive, make the behavior of this command a pure passthrough, outputting only the subprocess' stdout and stderr and exiting with the
// sub-command's exit status
if (this.isNonInteractive) {
process.exitCode = process.exitCode ?? (err as any).status ?? 1;
} else {
await super.catch(err);
}
}
private async runCommandNonInteractiveWithEnvVarsAsync({
command,
environmentVariables,
}: {
command: string;
environmentVariables: Record<string, string>;
}): Promise<void> {
await spawnAsync(command, [], {
shell: true,
stdio: 'inherit',
env: {
...process.env,
...environmentVariables,
},
});
}
private async runCommandWithEnvVarsAsync({
command,
environmentVariables,
@@ -101,7 +157,8 @@ export default class EnvExec extends EasCommand {
environmentVariables: Record<string, string>;
}): Promise<void> {
Log.log(`Running command: ${chalk.bold(command)}`);
const spawnPromise = spawnAsync('bash', ['-c', command], {
const spawnPromise = spawnAsync(command, [], {
shell: true,
stdio: ['inherit', 'pipe', 'pipe'],
env: {
...process.env,
@@ -137,10 +194,12 @@ export default class EnvExec extends EasCommand {
graphqlClient,
projectId,
environment,
nonInteractive,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
environment: EnvironmentVariableEnvironment;
nonInteractive: boolean;
}): Promise<Record<string, string>> {
const environmentVariablesQueryResult =
await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(graphqlClient, {
@@ -148,31 +207,24 @@ export default class EnvExec extends EasCommand {
environment,
});
const secretEnvironmentVariables = environmentVariablesQueryResult.filter(
({ value }) => !value
);
if (secretEnvironmentVariables.length > 0) {
Log.warn(`The following environment variables are secret and cannot be downloaded locally:`);
for (const { name } of secretEnvironmentVariables) {
Log.warn(`- ${name}`);
}
Log.warn('Proceeding with the rest of the environment variables.');
Log.newLine();
}
const nonSecretEnvironmentVariables = environmentVariablesQueryResult.filter(
({ value }) => !!value
);
if (nonSecretEnvironmentVariables.length === 0) {
throw new Error('No readable environment variables found for the selected environment.');
if (!nonInteractive) {
if (nonSecretEnvironmentVariables.length > 0) {
Log.log(
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment.toLowerCase()}" environment on EAS: ${nonSecretEnvironmentVariables
.map(e => e.name)
.join(', ')}.`
);
} else {
Log.log(
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment.toLowerCase()}" environment on EAS.`
);
}
Log.newLine();
}
Log.log(
`Loaded environment variables for the selected environment "${environment.toLowerCase()}":`
);
for (const { name } of nonSecretEnvironmentVariables) {
Log.log(`- ${name}`);
}
Log.newLine();
const environmentVariables: Record<string, string> = {};
for (const { name, value } of nonSecretEnvironmentVariables) {
+107 -50
View File
@@ -4,64 +4,84 @@ import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EASEnvironmentFlag,
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
EASNonInteractiveFlag,
EASVariableFormatFlag,
EASVariableScopeFlag,
EasEnvironmentFlagParameters,
} from '../../commandUtils/flags';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { formatVariable } from '../../utils/formatVariable';
import { promptVariableEnvironmentAsync, promptVariableNameAsync } from '../../utils/prompts';
import { formatVariable, formatVariableValue, isEnvironment } from '../../utils/variableUtils';
type GetFlags = {
name?: string;
environment?: EnvironmentVariableEnvironment;
interface RawGetFlags {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
format?: string;
scope: EASEnvironmentVariableScopeFlagValue;
}
interface GetFlags {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
format?: string;
scope: EnvironmentVariableScope;
};
}
export default class EnvironmentVariableGet extends EasCommand {
static override description = 'get environment variable';
static override hidden = true;
export default class EnvGet extends EasCommand {
static override description = 'view an environment variable for the current project or account';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
static override args = [
{
name: 'environment',
description:
"Current environment of the variable. One of 'production', 'preview', or 'development'.",
required: false,
},
];
static override flags = {
name: Flags.string({
'variable-name': Flags.string({
description: 'Name of the variable',
}),
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable',
}),
...EASVariableFormatFlag,
...EASVariableScopeFlag,
...EASEnvironmentVariableScopeFlag,
...EASNonInteractiveFlag,
...EASEnvironmentFlag,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(EnvironmentVariableGet);
const { args, flags } = await this.parse(EnvGet);
let {
environment,
name,
'variable-environment': environment,
'variable-name': name,
'non-interactive': nonInteractive,
format,
scope,
} = this.validateFlags(flags);
} = this.sanitizeInputs(flags, args);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvironmentVariableGet, {
} = await this.getContextAsync(EnvGet, {
nonInteractive,
});
@@ -69,16 +89,36 @@ export default class EnvironmentVariableGet extends EasCommand {
name = await promptVariableNameAsync(nonInteractive);
}
if (!environment && scope === EnvironmentVariableScope.Project) {
environment = await promptVariableEnvironmentAsync(nonInteractive);
if (!environment) {
environment = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: false,
});
}
const variable = await getVariableAsync(graphqlClient, scope, projectId, name, environment);
if (!variable) {
const variables = await getVariablesAsync(graphqlClient, scope, projectId, name, environment);
if (variables.length === 0) {
Log.error(`Variable with name "${name}" not found`);
return;
}
if (!variable.value) {
let variable;
if (variables.length > 1) {
const variableInEnvironment = variables.find(v => v.environments?.includes(environment!));
if (!variableInEnvironment) {
throw new Error(
`Variable with name "${name}" not found in environment "${environment.toLocaleLowerCase()}"`
);
}
variable = variableInEnvironment;
} else {
variable = variables[0];
}
if (variable.visibility === EnvironmentVariableVisibility.Secret) {
throw new Error(
`${chalk.bold(
variable.name
@@ -87,63 +127,80 @@ export default class EnvironmentVariableGet extends EasCommand {
}
if (format === 'short') {
Log.log(`${chalk.bold(variable.name)}=${variable.value}`);
Log.log(`${chalk.bold(variable.name)}=${formatVariableValue(variable)}`);
} else {
Log.log(formatVariable(variable));
}
}
private validateFlags(flags: GetFlags): GetFlags {
if (flags.environment && flags.scope === EnvironmentVariableScope.Shared) {
throw new Error(`Unexpected argument: --environment can only be used with project variables`);
}
private sanitizeInputs(flags: RawGetFlags, { environment }: { environment?: string }): GetFlags {
if (flags['non-interactive']) {
if (!flags.name) {
throw new Error('Variable name is required. Run the command with --name flag.');
if (!flags['variable-name']) {
throw new Error('Variable name is required. Run the command with --variable-name flag.');
}
if (!flags.scope) {
throw new Error('Scope is required. Run the command with --scope flag.');
}
if (!flags.environment && flags.scope === EnvironmentVariableScope.Project) {
if (!(flags['variable-environment'] ?? environment)) {
throw new Error('Environment is required.');
}
}
return flags;
if (environment && flags['variable-environment']) {
throw new Error(
"You can't use both --variable-environment flag when environment is passed as an argument. Run `eas env:get --help` for more information."
);
}
const scope =
flags.scope === 'account'
? EnvironmentVariableScope.Shared
: EnvironmentVariableScope.Project;
if (environment) {
environment = environment.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error(
"Invalid environment. Use one of 'production', 'preview', or 'development'."
);
}
return {
...flags,
'variable-environment': environment,
scope,
};
}
return { ...flags, scope };
}
}
async function getVariableAsync(
async function getVariablesAsync(
graphqlClient: ExpoGraphqlClient,
scope: string,
projectId: string,
name: string | undefined,
environment: string | undefined
): Promise<EnvironmentVariableFragment | null> {
if (!environment && scope === EnvironmentVariableScope.Project) {
throw new Error('Environment is required.');
}
environment: EnvironmentVariableEnvironment | undefined
): Promise<EnvironmentVariableFragment[]> {
if (!name) {
throw new Error("Variable name is required. Run the command with '--name VARIABLE_NAME' flag.");
throw new Error(
"Variable name is required. Run the command with '--variable-name VARIABLE_NAME' flag."
);
}
if (environment && scope === EnvironmentVariableScope.Project) {
if (scope === EnvironmentVariableScope.Project) {
const appVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(graphqlClient, {
appId: projectId,
environment,
filterNames: [name],
includeFileContent: true,
});
return appVariables[0];
}
if (scope === EnvironmentVariableScope.Shared) {
return appVariables;
} else {
const sharedVariables = await EnvironmentVariablesQuery.sharedWithSensitiveAsync(
graphqlClient,
{
appId: projectId,
filterNames: [name],
includeFileContent: true,
}
);
return sharedVariables[0];
return sharedVariables;
}
return null;
}
+153 -34
View File
@@ -2,84 +2,203 @@ import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
import {
EASMultiEnvironmentFlag,
EASNonInteractiveFlag,
EasEnvironmentFlagParameters,
} from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
export default class EnvironmentVariableLink extends EasCommand {
static override description = 'link a shared environment variable to the current project';
type LinkFlags = {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
environment?: EnvironmentVariableEnvironment[];
};
export default class EnvLink extends EasCommand {
static override description = 'link an account-wide environment variable to the current project';
// for now we only roll out global account-wide env variables so this should stay hidden
static override hidden = true;
static override flags = {
name: Flags.string({
'variable-name': Flags.string({
description: 'Name of the variable',
}),
...EASEnvironmentFlag,
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable to link',
}),
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override args = [
{
name: 'environment',
description:
"Environment to pull variables from. One of 'production', 'preview', or 'development'.",
required: false,
},
];
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvLink);
let {
flags: { name, 'non-interactive': nonInteractive, environment },
} = await this.parse(EnvironmentVariableLink);
'variable-name': name,
'variable-environment': currentEnvironment,
'non-interactive': nonInteractive,
environment: environments,
} = this.validateInputs(flags, args);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvironmentVariableLink, {
} = await this.getContextAsync(EnvLink, {
nonInteractive,
});
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
environment: currentEnvironment,
filterNames: name ? [name] : undefined,
});
if (!name) {
name = await selectAsync(
'Select shared variable',
let selectedVariable = variables[0];
if (variables.length > 1) {
if (nonInteractive) {
throw new Error(
'Multiple variables found, run command with --variable-name and --variable-environment arguments.'
);
}
selectedVariable = await selectAsync(
'Select account-wide variable',
variables.map(variable => ({
title: variable.name,
value: variable.name,
title: formatVariableName(variable),
value: variable,
}))
);
}
const selectedVariable = variables.find(variable => variable.name === name);
if (!selectedVariable) {
throw new Error(`Shared variable ${name} doesn't exist`);
throw new Error(`Account-wide variable ${name} doesn't exist`);
}
if (!environment) {
environment = await promptVariableEnvironmentAsync(nonInteractive);
let explicitSelect = false;
if (!nonInteractive && !environments) {
const selectedEnvironments =
(selectedVariable.linkedEnvironments ?? []).length > 0
? selectedVariable.linkedEnvironments
: selectedVariable.environments;
environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
selectedEnvironments: selectedEnvironments ?? [],
});
explicitSelect = true;
}
const linkedVariable = await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
if (!linkedVariable) {
throw new Error(
`Could not link variable with name ${selectedVariable.name} to project with id ${projectId}`
if (!environments) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.`
);
return;
}
Log.withTick(
`Linked variable ${chalk.bold(linkedVariable.name)} to project ${chalk.bold(
projectDisplayName
)}.`
);
for (const environment of Object.values(EnvironmentVariableEnvironment)) {
try {
if (
selectedVariable.linkedEnvironments?.includes(environment) ===
environments.includes(environment)
) {
if (!explicitSelect && environments.includes(environment)) {
Log.withTick(
`Variable ${chalk.bold(
selectedVariable.name
)} is already linked to ${environment.toLocaleLowerCase()}.`
);
}
continue;
}
if (environments.includes(environment)) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
} else if (explicitSelect) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
}
} catch (err: any) {
Log.warn(err.message);
}
}
}
private validateInputs(flags: LinkFlags, { environment }: { environment?: string }): LinkFlags {
environment = environment?.toUpperCase();
if (environment && !isEnvironment(environment)) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const environments = flags.environment
? flags.environment
: environment
? [environment as EnvironmentVariableEnvironment]
: undefined;
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error(
`Environment variable needs 'name' to be specified when running in non-interactive mode. Run the command with ${chalk.bold(
'--variable-name VARIABLE_NAME'
)} flag to fix the issue`
);
}
}
return {
...flags,
environment: environments,
};
}
}
+157 -73
View File
@@ -4,27 +4,92 @@ import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EASEnvironmentFlag,
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
EASMultiEnvironmentFlag,
EASVariableFormatFlag,
EASVariableScopeFlag,
} from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment, EnvironmentVariableScope } from '../../graphql/generated';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableScope,
} from '../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
EnvironmentVariableWithFileContent,
EnvironmentVariablesQuery,
} from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { formatVariable } from '../../utils/formatVariable';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import {
formatVariable,
formatVariableValue,
isEnvironment,
performForEnvironmentsAsync,
} from '../../utils/variableUtils';
export default class EnvironmentValueList extends EasCommand {
static override description = 'list environment variables for the current project';
async function getVariablesForScopeAsync(
graphqlClient: ExpoGraphqlClient,
{
scope,
includingSensitive,
includeFileContent,
environment,
projectId,
}: {
scope: EnvironmentVariableScope;
includingSensitive: boolean;
includeFileContent: boolean;
environment?: EnvironmentVariableEnvironment;
projectId: string;
}
): Promise<EnvironmentVariableWithFileContent[]> {
if (scope === EnvironmentVariableScope.Project) {
if (includingSensitive) {
return await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(graphqlClient, {
appId: projectId,
environment,
includeFileContent,
});
}
return await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
environment,
includeFileContent,
});
}
static override hidden = true;
return includingSensitive
? await EnvironmentVariablesQuery.sharedWithSensitiveAsync(graphqlClient, {
appId: projectId,
environment,
includeFileContent,
})
: await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
environment,
includeFileContent,
});
}
interface RawListFlags {
scope: EASEnvironmentVariableScopeFlagValue;
format: string;
environment: EnvironmentVariableEnvironment[] | undefined;
'include-sensitive': boolean;
'include-file-content': boolean;
'non-interactive'?: boolean;
}
interface ListFlags {
scope: EnvironmentVariableScope;
format: string;
environment: EnvironmentVariableEnvironment[] | undefined;
'include-sensitive': boolean;
'include-file-content': boolean;
'non-interactive': boolean;
}
export default class EnvList extends EasCommand {
static override description = 'list environment variables for the current project or account';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
@@ -33,86 +98,105 @@ export default class EnvironmentValueList extends EasCommand {
description: 'Display sensitive values in the output',
default: false,
}),
'include-file-content': Flags.boolean({
description: 'Display files content in the output',
default: false,
}),
...EASMultiEnvironmentFlag,
...EASVariableFormatFlag,
...EASVariableScopeFlag,
...EASEnvironmentFlag,
...EASEnvironmentVariableScopeFlag,
};
static override args = [
{
name: 'environment',
description:
"Environment to list the variables from. One of 'production', 'preview', or 'development'.",
required: false,
},
];
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvList);
let {
flags: { environment, format, scope, 'include-sensitive': includeSensitive },
} = await this.parse(EnvironmentValueList);
format,
environment: environments,
scope,
'include-sensitive': includeSensitive,
'include-file-content': includeFileContent,
'non-interactive': nonInteractive,
} = this.sanitizeInputs(flags, args);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvironmentValueList, {
} = await this.getContextAsync(EnvList, {
nonInteractive: true,
});
if (scope === EnvironmentVariableScope.Project && !environment) {
environment = await promptVariableEnvironmentAsync(false);
if (!environments) {
environments = await promptVariableEnvironmentAsync({ nonInteractive, multiple: true });
}
const variables = await this.getVariablesForScopeAsync(graphqlClient, {
scope,
includingSensitive: includeSensitive,
environment,
projectId,
});
await performForEnvironmentsAsync(environments, async environment => {
const variables = await getVariablesForScopeAsync(graphqlClient, {
scope,
includingSensitive: includeSensitive,
includeFileContent,
environment,
projectId,
});
if (format === 'short') {
for (const variable of variables) {
// TODO: Add Learn more link
Log.addNewLineIfNone();
if (environment) {
Log.log(chalk.bold(`Environment: ${environment.toLocaleLowerCase()}`));
}
if (variables.length === 0) {
Log.log('No variables found for this environment.');
return;
}
if (format === 'short') {
for (const variable of variables) {
Log.log(`${chalk.bold(variable.name)}=${formatVariableValue(variable)}`);
}
} else {
if (scope === EnvironmentVariableScope.Shared) {
Log.log(chalk.bold('Account-wide variables for this account:'));
} else {
Log.log(chalk.bold(`Variables for this project:`));
}
Log.log(
`${chalk.bold(variable.name)}=${
variable.value ??
"***** (This is a secret env variable that can only be accessed on EAS builder and can't be read in any UI. Learn more.)"
}`
variables.map(variable => formatVariable(variable)).join(`\n\n${chalk.dim('———')}\n\n`)
);
}
} else {
if (scope === EnvironmentVariableScope.Shared) {
Log.log(chalk.bold('Shared variables for this account:'));
} else {
Log.log(chalk.bold(`Variables for this project for environment ${environment}:`));
}
Log.log(
variables.map(variable => formatVariable(variable)).join(`\n\n${chalk.dim('———')}\n\n`)
);
}
});
}
private async getVariablesForScopeAsync(
graphqlClient: ExpoGraphqlClient,
{
scope,
includingSensitive,
environment,
projectId,
}: {
scope: EnvironmentVariableScope;
includingSensitive: boolean;
environment?: EnvironmentVariableEnvironment;
projectId: string;
}
): Promise<EnvironmentVariableFragment[]> {
if (scope === EnvironmentVariableScope.Project && environment) {
if (includingSensitive) {
return await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(graphqlClient, {
appId: projectId,
environment,
});
}
return await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
environment,
});
private sanitizeInputs(
flags: RawListFlags,
{ environment }: { environment?: string }
): ListFlags {
if (environment && !isEnvironment(environment.toUpperCase())) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
return includingSensitive
? await EnvironmentVariablesQuery.sharedWithSensitiveAsync(graphqlClient, {
appId: projectId,
})
: await EnvironmentVariablesQuery.sharedAsync(graphqlClient, { appId: projectId });
const environments = flags.environment
? flags.environment
: environment
? [environment.toUpperCase() as EnvironmentVariableEnvironment]
: undefined;
return {
...flags,
'non-interactive': flags['non-interactive'] ?? false,
environment: environments,
scope:
flags.scope === 'account'
? EnvironmentVariableScope.Shared
: EnvironmentVariableScope.Project,
};
}
}
+88 -32
View File
@@ -1,27 +1,42 @@
import { Flags } from '@oclif/core';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentVariableFragment } from '../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import { EnvironmentSecretType, EnvironmentVariableVisibility } from '../../graphql/generated';
import {
EnvironmentVariableWithFileContent,
EnvironmentVariablesQuery,
} from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { isEnvironment } from '../../utils/variableUtils';
export default class EnvironmentVariablePull extends EasCommand {
static override description = 'pull env file';
static override hidden = true;
export default class EnvPull extends EasCommand {
static override description =
'pull environment variables for the selected environment to .env file';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
static override args = [
{
name: 'environment',
description:
"Environment to pull variables from. One of 'production', 'preview', or 'development'.",
required: false,
},
];
static override flags = {
...EASEnvironmentFlag,
...EASNonInteractiveFlag,
...EASEnvironmentFlag,
path: Flags.string({
description: 'Path to the result `.env` file',
default: '.env.local',
@@ -30,16 +45,25 @@ export default class EnvironmentVariablePull extends EasCommand {
async runAsync(): Promise<void> {
let {
flags: { environment, path: targetPath, 'non-interactive': nonInteractive },
} = await this.parse(EnvironmentVariablePull);
args: { environment: argEnvironment },
flags: { environment: flagEnvironment, path: targetPath, 'non-interactive': nonInteractive },
} = await this.parse(EnvPull);
let environment = flagEnvironment?.toUpperCase() ?? argEnvironment?.toUpperCase();
if (!environment) {
environment = await promptVariableEnvironmentAsync(nonInteractive);
environment = await promptVariableEnvironmentAsync({ nonInteractive });
}
if (!isEnvironment(environment)) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvironmentVariablePull, {
projectDir,
} = await this.getContextAsync(EnvPull, {
nonInteractive,
});
@@ -50,6 +74,7 @@ export default class EnvironmentVariablePull extends EasCommand {
{
appId: projectId,
environment,
includeFileContent: true,
}
);
@@ -63,32 +88,63 @@ export default class EnvironmentVariablePull extends EasCommand {
}
}
const filePrefix = `# Environment: ${environment}\n\n`;
let currentEnvLocal: Record<string, string> = {};
const envFileContent = environmentVariables
.map((variable: EnvironmentVariableFragment) => {
if (variable.value === null) {
return `# ${variable.name}=***** (secret variables are not available for reading)`;
if (await fs.exists(targetPath)) {
currentEnvLocal = dotenv.parse(await fs.readFile(targetPath, 'utf8'));
}
const filePrefix = `# Environment: ${environment.toLocaleLowerCase()}\n\n`;
const isFileVariablePresent = environmentVariables.some(v => {
return v.type === EnvironmentSecretType.FileBase64 && v.valueWithFileContent;
});
const envDir = path.join(projectDir, '.eas', '.env');
if (isFileVariablePresent) {
await fs.mkdir(envDir, { recursive: true });
}
const skippedSecretVariables: string[] = [];
const overridenSecretVariables: string[] = [];
const envFileContentLines = await Promise.all(
environmentVariables.map(async (variable: EnvironmentVariableWithFileContent) => {
if (variable.visibility === EnvironmentVariableVisibility.Secret) {
if (currentEnvLocal[variable.name]) {
overridenSecretVariables.push(variable.name);
return `${variable.name}=${currentEnvLocal[variable.name]}`;
}
skippedSecretVariables.push(variable.name);
return `# ${variable.name}=***** (secret)`;
}
if (variable.type === EnvironmentSecretType.FileBase64 && variable.valueWithFileContent) {
const filePath = path.join(envDir, variable.name);
await fs.writeFile(filePath, variable.valueWithFileContent, 'base64');
return `${variable.name}=${filePath}`;
}
return `${variable.name}=${variable.value}`;
})
.join('\n');
await fs.writeFile(targetPath, filePrefix + envFileContent);
const secretEnvVariables = environmentVariables.filter(
(variable: EnvironmentVariableFragment) => variable.value === null
);
if (secretEnvVariables.length > 0) {
Log.warn(
`The eas env:pull command tried to pull environment variables with "secret" visibility. The variables with "secret" visibility are not available for reading, therefore thet were marked as "*****" in the generated .env file. Provide values for these manually in ${targetPath} if needed. Skipped variables: ${secretEnvVariables
.map(v => v.name)
.join('\n')}`
);
Log.warn();
}
await fs.writeFile(targetPath, filePrefix + envFileContentLines.join('\n'));
Log.log(
`Pulled environment variables from ${environment.toLowerCase()} environment to ${targetPath}.`
`Pulled plain text and sensitive environment variables from "${environment.toLowerCase()}" environment to ${targetPath}.`
);
if (overridenSecretVariables.length > 0) {
Log.addNewLineIfNone();
Log.log(`Reused local values for following secrets: ${overridenSecretVariables.join('\n')}.`);
}
if (skippedSecretVariables.length > 0) {
Log.addNewLineIfNone();
Log.log(
`The following variables have the secret visibility and can't be read outside of EAS servers. Set their values manually in your .env file: ${skippedSecretVariables.join(
', '
)}.`
);
}
}
}
+161 -114
View File
@@ -1,9 +1,10 @@
import { Flags } from '@oclif/core';
import dotenv from 'dotenv';
import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag } from '../../commandUtils/flags';
import { EASMultiEnvironmentFlag } from '../../commandUtils/flags';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
@@ -17,145 +18,168 @@ import {
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { isEnvironment } from '../../utils/variableUtils';
type PushFlags = {
environment?: EnvironmentVariableEnvironment;
path: string;
};
export default class EnvironmentVariablePush extends EasCommand {
static override description = 'push env file';
static override hidden = true;
export default class EnvPush extends EasCommand {
static override description =
'push environment variables from .env file to the selected environment';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
static override flags = {
...EASEnvironmentFlag,
...EASMultiEnvironmentFlag,
path: Flags.string({
description: 'Path to the input `.env` file',
default: '.env.local',
}),
};
static override args = [
{
name: 'environment',
description:
"Environment to push variables to. One of 'production', 'preview', or 'development'.",
required: false,
},
];
async runAsync(): Promise<void> {
const { flags } = await this.parse(EnvironmentVariablePush);
const { args, flags } = await this.parse(EnvPush);
let { environment: environments, path: envPath } = this.parseFlagsAndArgs(flags, args);
const { environment, path: envPath } = this.validateFlags(flags);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvironmentVariablePush, {
} = await this.getContextAsync(EnvPush, {
nonInteractive: false,
});
if (!environments) {
environments = await promptVariableEnvironmentAsync({
nonInteractive: false,
multiple: true,
});
}
const updateVariables: Record<string, EnvironmentVariablePushInput> =
await this.parseEnvFileAsync(envPath, environment);
await this.parseEnvFileAsync(envPath, environments);
const variableNames = Object.keys(updateVariables);
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
environment,
filterNames: variableNames,
});
for (const environment of environments) {
const displayedEnvironment = environment.toLocaleLowerCase();
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
environment,
filterNames: variableNames,
});
const existingDifferentVariables: EnvironmentVariableFragment[] = [];
// Remove variables that are the same as the ones in the environment
existingVariables.forEach(variable => {
const existingVariableUpdate = updateVariables[variable.name];
if (existingVariableUpdate && existingVariableUpdate.value !== variable.value) {
existingDifferentVariables.push(variable);
} else {
delete updateVariables[variable.name];
}
});
const existingDifferentVariables: EnvironmentVariableFragment[] = [];
// Remove variables that are the same as the ones in the environment
existingVariables.forEach(existingVariable => {
const existingVariableUpdate = updateVariables[existingVariable.name];
const existingDifferentSharedVariables = existingDifferentVariables.filter(
variable => variable.scope === EnvironmentVariableScope.Shared
);
if (existingVariableUpdate) {
const hasMoreEnvironments = existingVariableUpdate.environments.some(
newEnv => !existingVariable.environments?.includes(newEnv)
);
if (existingDifferentSharedVariables.length > 0) {
const existingDifferentSharedVariablesNames = existingDifferentSharedVariables.map(
variable => variable.name
if (existingVariableUpdate.value !== existingVariable.value || hasMoreEnvironments) {
existingDifferentVariables.push(existingVariable);
} else {
delete updateVariables[existingVariable.name];
}
}
});
const existingDifferentSharedVariables = existingDifferentVariables.filter(
variable => variable.scope === EnvironmentVariableScope.Shared
);
Log.error('Shared variables cannot be overwritten by eas env:push command.');
Log.error('Remove them from the env file or unlink them from the project to continue:');
existingDifferentSharedVariablesNames.forEach(name => {
Log.error(`- ${name}`);
});
throw new Error('Shared variables cannot be overwritten by eas env:push command');
}
if (existingDifferentVariables.length > 0) {
Log.warn('Some variables already exist in the environment.');
const variableNames = existingDifferentVariables.map(variable => variable.name);
const confirmationMessage =
variableNames.length > 1
? `The ${variableNames.join(
', '
)} environment variables already exist in ${environment} environment. Do you want to override them all?`
: `The ${variableNames[0]} environment variable already exists in ${environment} environment. Do you want to override it?`;
const confirm = await confirmAsync({
message: confirmationMessage,
});
let variablesToOverwrite: string[] = [];
if (!confirm && existingDifferentVariables.length === 1) {
throw new Error('No new variables to push.');
}
if (confirm) {
variablesToOverwrite = existingDifferentVariables.map(variable => variable.name);
} else {
const promptResult = await promptAsync({
type: 'multiselect',
name: 'variablesToOverwrite',
message: 'Select variables to overwrite:',
// @ts-expect-error property missing from `@types/prompts`
optionsPerPage: 20,
choices: existingDifferentVariables.map(variable => ({
title: `${variable.name}: ${updateVariables[variable.name].value} (was ${
variable.value ?? '(secret)'
})`,
value: variable.name,
})),
if (existingDifferentSharedVariables.length > 0) {
const existingDifferentSharedVariablesNames = existingDifferentSharedVariables.map(
variable => variable.name
);
Log.error('Account-wide variables cannot be overwritten by eas env:push command.');
Log.error('Remove them from the env file or unlink them from the project to continue:');
existingDifferentSharedVariablesNames.forEach(name => {
Log.error(`- ${name}`);
});
variablesToOverwrite = promptResult.variablesToOverwrite;
throw new Error('Account-wide variables cannot be overwritten by eas env:push command');
}
for (const existingVariable of existingVariables) {
const name = existingVariable.name;
if (variablesToOverwrite.includes(name)) {
updateVariables[name]['overwrite'] = true;
if (existingDifferentVariables.length > 0) {
Log.warn(`Some variables already exist in the ${displayedEnvironment} environment.`);
const variableNames = existingDifferentVariables.map(variable => variable.name);
const confirmationMessage =
variableNames.length > 1
? `The ${variableNames.join(
', '
)} environment variables already exist in ${displayedEnvironment} environment. Do you want to override them all?`
: `The ${variableNames[0]} environment variable already exists in ${displayedEnvironment} environment. Do you want to override it?`;
const confirm = await confirmAsync({
message: confirmationMessage,
});
let variablesToOverwrite: string[] = [];
if (!confirm && existingDifferentVariables.length === 0) {
throw new Error('No new variables to push.');
}
if (confirm) {
variablesToOverwrite = existingDifferentVariables.map(variable => variable.name);
} else {
delete updateVariables[name];
const promptResult = await promptAsync({
type: 'multiselect',
name: 'variablesToOverwrite',
message: 'Select variables to overwrite:',
// @ts-expect-error property missing from `@types/prompts`
optionsPerPage: 20,
choices: existingDifferentVariables.map(variable => ({
title: `${variable.name}: ${updateVariables[variable.name].value} (was ${
variable.value ?? '(secret)'
})`,
value: variable.name,
})),
});
variablesToOverwrite = promptResult.variablesToOverwrite;
}
for (const existingVariable of existingVariables) {
const name = existingVariable.name;
if (variablesToOverwrite.includes(name)) {
updateVariables[name]['overwrite'] = true;
} else {
delete updateVariables[name];
}
}
}
}
// Check if any of the sensitive variables already exist in the environment. Prompt the user to overwrite them.
const existingSensitiveVariables = existingVariables.filter(
variable => variable.value === null
);
if (existingSensitiveVariables.length > 0) {
const existingSensitiveVariablesNames = existingSensitiveVariables.map(
variable => `- ${variable.name}`
// Check if any of the sensitive variables already exist in the environment. Prompt the user to overwrite them.
const existingSensitiveVariables = existingVariables.filter(
variable => variable.visibility !== EnvironmentVariableVisibility.Public
);
const confirm = await confirmAsync({
message: `You are about to overwrite sensitive variables.\n${existingSensitiveVariablesNames.join(
'\n'
)}\n Do you want to continue?`,
});
if (!confirm) {
throw new Error('Aborting...');
if (existingSensitiveVariables.length > 0) {
const existingSensitiveVariablesNames = existingSensitiveVariables.map(
variable => `- ${variable.name}`
);
const confirm = await confirmAsync({
message: `You are about to overwrite sensitive variables.\n${existingSensitiveVariablesNames.join(
'\n'
)}\n Do you want to continue?`,
});
if (!confirm) {
throw new Error('Aborting...');
}
}
}
@@ -171,13 +195,30 @@ export default class EnvironmentVariablePush extends EasCommand {
variablesToPush,
projectId
);
Log.log(`Uploaded env file to ${environments.join(', ').toLocaleLowerCase()}.`);
}
Log.log(`Uploaded env file to ${environment} environment.`);
parseFlagsAndArgs(
flags: { path: string; environment: EnvironmentVariableEnvironment[] | undefined },
{ environment }: Record<string, string>
): { environment?: EnvironmentVariableEnvironment[]; path: string } {
if (environment && !isEnvironment(environment.toUpperCase())) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const environments =
flags.environment ??
(environment ? [environment.toUpperCase() as EnvironmentVariableEnvironment] : undefined);
return {
...flags,
environment: environments,
};
}
private async parseEnvFileAsync(
envPath: string,
environment: string
environments: EnvironmentVariableEnvironment[]
): Promise<Record<string, EnvironmentVariablePushInput>> {
if (!(await fs.exists(envPath))) {
throw new Error(`File ${envPath} does not exist.`);
@@ -186,11 +227,25 @@ export default class EnvironmentVariablePush extends EasCommand {
const variables: Record<string, string> = dotenv.parse(await fs.readFile(envPath, 'utf8'));
const hasFileVariables = Object.values(variables).some(value =>
value.includes(path.join('.eas', '.env'))
);
if (hasFileVariables) {
Log.warn('File variables are not supported in this command.');
}
for (const [name, value] of Object.entries(variables)) {
// Skip file variables
const fileVariablePath = path.join('.eas', '.env', name);
if (value.endsWith(fileVariablePath)) {
Log.warn(`Skipping file variable ${name}`);
continue;
}
pushInput[name] = {
name,
value,
environment,
environments,
visibility: name.startsWith('EXPO_SENSITIVE')
? EnvironmentVariableVisibility.Sensitive
: EnvironmentVariableVisibility.Public,
@@ -199,12 +254,4 @@ export default class EnvironmentVariablePush extends EasCommand {
return pushInput;
}
private validateFlags(flags: PushFlags): Required<PushFlags> {
if (!flags.environment) {
throw new Error('Please provide an environment to push the env file to.');
}
return { ...flags, environment: flags.environment };
}
}
+143 -47
View File
@@ -2,95 +2,191 @@ import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentVariableScope } from '../../graphql/generated';
import { EASMultiEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
export default class EnvironmentVariableUnlink extends EasCommand {
static override description = 'unlink a shared environment variable to the current project';
type UnlinkFlags = {
'variable-name'?: string;
'non-interactive': boolean;
environment?: EnvironmentVariableEnvironment[];
};
export default class EnvUnlink extends EasCommand {
static override description =
'unlink an account-wide environment variable from the current project';
// for now we only roll out global account-wide env variables so this should stay hidden
static override hidden = true;
static override flags = {
name: Flags.string({
'variable-name': Flags.string({
description: 'Name of the variable',
}),
...EASEnvironmentFlag,
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
static override args = [
{
name: 'environment',
description:
"Environment to unlink the variable from. One of 'production', 'preview', or 'development'.",
required: false,
},
];
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvUnlink);
let {
flags: { name, 'non-interactive': nonInteractive, environment },
} = await this.parse(EnvironmentVariableUnlink);
'variable-name': name,
'non-interactive': nonInteractive,
environment: unlinkEnvironments,
} = this.validateInputs(flags, args);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvironmentVariableUnlink, {
} = await this.getContextAsync(EnvUnlink, {
nonInteractive,
});
if (!environment) {
environment = await promptVariableEnvironmentAsync(nonInteractive);
}
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const appVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
environment,
filterNames: name ? [name] : undefined,
});
const linkedVariables = appVariables.filter(
({ scope }) => scope === EnvironmentVariableScope.Shared
);
if (linkedVariables.length === 0) {
throw new Error(`There are no linked shared env variables for project ${projectDisplayName}`);
}
let selectedVariable = variables[0];
if (!name) {
name = await selectAsync(
'Select shared variable to unlink',
linkedVariables.map(variable => ({
title: variable.name,
value: variable.name,
if (variables.length > 1) {
if (nonInteractive) {
throw new Error("Multiple variables found, please select one using '--variable-name'");
}
selectedVariable = await selectAsync(
'Select account-wide variable',
variables.map(variable => ({
title: formatVariableName(variable),
value: variable,
}))
);
}
const selectedVariable = linkedVariables.find(variable => variable.name === name);
if (!selectedVariable) {
throw new Error(`Shared variable ${name} doesn't exist`);
throw new Error(`Account-wide variable ${name} doesn't exist`);
}
const unlinkedVariable = await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
let explicitSelect = false;
if (!unlinkedVariable) {
throw new Error(
`Could not unlink variable with name ${selectedVariable.name} from project ${projectDisplayName}`
if (!nonInteractive && !unlinkEnvironments) {
const selectedEnvironments =
(selectedVariable.linkedEnvironments ?? []).length > 0
? selectedVariable.linkedEnvironments
: selectedVariable.environments;
const environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
selectedEnvironments: selectedEnvironments ?? [],
});
explicitSelect = true;
unlinkEnvironments = Object.values(EnvironmentVariableEnvironment).filter(
env => !environments.includes(env)
);
}
Log.withTick(
`Unlinked variable ${chalk.bold(unlinkedVariable.name)} from the project ${chalk.bold(
projectDisplayName
)}.`
);
if (!unlinkEnvironments) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.`
);
return;
}
for (const environment of Object.values(EnvironmentVariableEnvironment)) {
if (
selectedVariable.linkedEnvironments?.includes(environment) !==
unlinkEnvironments.includes(environment)
) {
if (!explicitSelect && unlinkEnvironments.includes(environment)) {
Log.withTick(
`Variable ${chalk.bold(
selectedVariable.name
)} is already unlinked from ${environment.toLocaleLowerCase()}.`
);
}
continue;
}
if (unlinkEnvironments.includes(environment)) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
} else if (explicitSelect) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
}
}
}
private validateInputs(
flags: UnlinkFlags,
{ environment }: { environment?: string }
): UnlinkFlags {
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error(
'Current name is required in non-interactive mode. Run the command with --variable-name flag.'
);
}
}
if (environment) {
environment = environment.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error(
"Invalid environment. Use one of 'production', 'preview', or 'development'."
);
}
return {
environment: [environment],
...flags,
};
}
return flags;
}
}
+291 -140
View File
@@ -1,14 +1,25 @@
import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASEnvironmentFlag,
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
EASMultiEnvironmentFlag,
EASNonInteractiveFlag,
EASVariableScopeFlag,
EASVariableVisibilityFlag,
EasEnvironmentFlagParameters,
} from '../../commandUtils/flags';
import { EnvironmentVariableScope, EnvironmentVariableVisibility } from '../../graphql/generated';
import {
EnvironmentSecretType,
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
@@ -17,57 +28,100 @@ import {
getOwnerAccountForProjectIdAsync,
} from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import { promptVariableEnvironmentAsync, promptVariableValueAsync } from '../../utils/prompts';
import {
parseVisibility,
promptVariableEnvironmentAsync,
promptVariableNameAsync,
promptVariableTypeAsync,
promptVariableValueAsync,
promptVariableVisibilityAsync,
} from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
type UpdateFlags = {
interface RawUpdateFlags {
name?: string;
value?: string;
scope?: EnvironmentVariableScope;
environment?: string;
visibility?: EnvironmentVariableVisibility;
scope: EASEnvironmentVariableScopeFlagValue;
environment?: EnvironmentVariableEnvironment[];
visibility?: 'plaintext' | 'sensitive' | 'secret';
type?: 'string' | 'file';
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
};
}
export default class EnvironmentVariableUpdate extends EasCommand {
static override description =
'update an environment variable on the current project or owner account';
interface UpdateFlags {
name?: string;
value?: string;
scope: EnvironmentVariableScope;
environment?: EnvironmentVariableEnvironment[];
visibility?: 'plaintext' | 'sensitive' | 'secret';
type?: 'string' | 'file';
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
}
static override hidden = true;
export default class EnvUpdate extends EasCommand {
static override description = 'update an environment variable on the current project or account';
static override flags = {
'variable-name': Flags.string({
description: 'Current name of the variable',
}),
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable to update',
}),
name: Flags.string({
description: 'Name of the variable',
description: 'New name of the variable',
}),
value: Flags.string({
description: 'Text value or the variable',
description: 'New value or the variable',
}),
type: Flags.enum<'string' | 'file'>({
description: 'The type of variable',
options: ['string', 'file'],
}),
...EASVariableVisibilityFlag,
...EASVariableScopeFlag,
...EASEnvironmentFlag,
...EASEnvironmentVariableScopeFlag,
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override args = [
{
name: 'environment',
description:
"Current environment of the variable to update. One of 'production', 'preview', or 'development'.",
required: false,
},
];
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.ProjectId,
...this.ContextOptions.Analytics,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(EnvironmentVariableUpdate);
let {
const { args, flags } = await this.parse(EnvUpdate);
const {
name,
value,
value: rawValue,
scope,
'variable-name': currentName,
'variable-environment': currentEnvironment,
'non-interactive': nonInteractive,
environment,
environment: environments,
type,
visibility,
} = this.validateFlags(flags);
} = this.sanitizeInputs(flags, args);
const {
privateProjectConfig: { projectId },
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvironmentVariableUpdate, {
} = await this.getContextAsync(EnvUpdate, {
nonInteractive,
});
@@ -76,132 +130,229 @@ export default class EnvironmentVariableUpdate extends EasCommand {
getOwnerAccountForProjectIdAsync(graphqlClient, projectId),
]);
let selectedVariable: EnvironmentVariableFragment;
let existingVariables: EnvironmentVariableFragment[] = [];
const suffix =
scope === EnvironmentVariableScope.Project
? `on project ${projectDisplayName}`
: `on account ${ownerAccount.name}`;
if (scope === EnvironmentVariableScope.Project) {
if (!environment) {
environment = await promptVariableEnvironmentAsync(nonInteractive);
}
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
environment,
environment: currentEnvironment,
filterNames: currentName ? [currentName] : undefined,
});
if (!name) {
name = await selectAsync(
'Select variable',
existingVariables.map(variable => ({
title: variable.name,
value: variable.name,
}))
);
}
const existingVariable = existingVariables.find(variable => variable.name === name);
if (!existingVariable) {
throw new Error(
`Variable with name ${name} does not exist on project ${projectDisplayName}`
);
}
if (!value) {
value = await promptVariableValueAsync({
nonInteractive,
required: false,
initial: existingVariable.value,
});
if (!value || value.length === 0) {
value = '';
}
}
const variable = await EnvironmentVariableMutation.createForAppAsync(
graphqlClient,
{
name,
value,
environment,
visibility,
overwrite: true,
},
projectId
);
if (!variable) {
throw new Error(
`Could not update variable with name ${name} on project ${projectDisplayName}`
);
}
Log.withTick(
`Updated variable ${chalk.bold(name)} on project ${chalk.bold(projectDisplayName)}.`
);
} else if (scope === EnvironmentVariableScope.Shared) {
const sharedVariables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
});
if (!name) {
name = await selectAsync(
'Select variable',
sharedVariables.map(variable => ({
title: variable.name,
value: variable.name,
}))
);
}
const existingVariable = sharedVariables.find(variable => variable.name === name);
if (!existingVariable) {
throw new Error(
"Variable with this name doesn't exist on this account. Use a different name."
);
}
if (!value) {
value = await promptVariableValueAsync({
nonInteractive,
required: false,
initial: existingVariable.value,
});
if (!value || value.length === 0) {
value = '';
}
}
const variable = await EnvironmentVariableMutation.createSharedVariableAsync(
graphqlClient,
{
name,
value,
visibility,
overwrite: true,
},
ownerAccount.id
);
if (!variable) {
throw new Error(
`Could not update variable with name ${name} on account ${ownerAccount.name}`
);
}
Log.withTick(
`Updated shared variable ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
);
}
if (scope === EnvironmentVariableScope.Shared) {
existingVariables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
environment: currentEnvironment,
filterNames: currentName ? [currentName] : undefined,
});
}
if (existingVariables.length === 0) {
throw new Error(
`Variable with name ${currentName} ${
currentEnvironment ? `in environment ${currentEnvironment.toLocaleLowerCase()}` : ''
} does not exist ${suffix}.`
);
} else if (existingVariables.length > 1) {
selectedVariable = await selectAsync(
'Select variable',
existingVariables.map(variable => ({
title: formatVariableName(variable),
value: variable,
}))
);
} else {
selectedVariable = existingVariables[0];
}
assert(selectedVariable, 'Variable must be selected');
const {
name: newName,
value: newValue,
environment: newEnvironments,
visibility: newVisibility,
type: newType,
fileName,
} = await this.promptForMissingFlagsAsync(selectedVariable, {
name,
value: rawValue,
environment: environments,
visibility,
'non-interactive': nonInteractive,
type,
scope,
});
const variable = await EnvironmentVariableMutation.updateAsync(graphqlClient, {
id: selectedVariable.id,
name: newName,
value: newValue,
environments: newEnvironments,
type: newType,
visibility: newVisibility,
fileName: newValue ? fileName : undefined,
});
if (!variable) {
throw new Error(`Could not update variable with name ${name} ${suffix}`);
}
Log.withTick(`Updated variable ${chalk.bold(selectedVariable.name)} ${suffix}.`);
}
private validateFlags(flags: UpdateFlags): UpdateFlags {
private sanitizeInputs(
flags: RawUpdateFlags,
{ environment }: { environment?: string }
): UpdateFlags {
if (flags['non-interactive']) {
if (!flags.name) {
if (!flags['variable-name']) {
throw new Error(
'Variable name is required in non-interactive mode. Run the command with --name flag.'
'Current name is required in non-interactive mode. Run the command with --variable-name flag.'
);
}
if (flags.scope === EnvironmentVariableScope.Project && !flags.environment) {
throw new Error(
'Environment is required when updating project-wide variable in non-interactive mode. Run the command with --environment flag.'
);
if (flags['type'] && !flags['value']) {
throw new Error('Value is required when type is set. Run the command with --value flag.');
}
}
return flags;
const scope =
flags.scope === 'account'
? EnvironmentVariableScope.Shared
: EnvironmentVariableScope.Project;
if (environment) {
environment = environment.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error(
"Invalid environment. Use one of 'production', 'preview', or 'development'."
);
}
return {
...flags,
'variable-environment': environment,
scope,
};
}
return { ...flags, scope };
}
private async promptForMissingFlagsAsync(
selectedVariable: EnvironmentVariableFragment,
{
name,
value,
environment: environments,
visibility,
'non-interactive': nonInteractive,
type,
...rest
}: UpdateFlags
): Promise<
Omit<UpdateFlags, 'type' | 'visibility'> & {
type?: EnvironmentSecretType;
visibility?: EnvironmentVariableVisibility;
fileName?: string;
}
> {
let newType;
let newVisibility: EnvironmentVariableVisibility | undefined;
let fileName: string | undefined;
if (type === 'file') {
newType = EnvironmentSecretType.FileBase64;
} else if (type === 'string') {
newType = EnvironmentSecretType.String;
}
if (!nonInteractive) {
if (!name) {
name = await promptVariableNameAsync(nonInteractive, selectedVariable.name);
if (!name || name.length === 0) {
name = undefined;
}
}
if (!type && !value && !nonInteractive) {
newType = await promptVariableTypeAsync(nonInteractive, selectedVariable.type);
}
if (!value) {
value = await promptVariableValueAsync({
nonInteractive,
required: false,
filePath: (newType ?? selectedVariable.type) === EnvironmentSecretType.FileBase64,
initial:
(newType ?? selectedVariable.type) === EnvironmentSecretType.FileBase64
? undefined
: selectedVariable.value,
});
if (!value || value.length === 0 || value === selectedVariable.value) {
value = undefined;
newType = undefined;
}
}
let environmentFilePath: string | undefined;
if ((newType ?? selectedVariable.type) === EnvironmentSecretType.FileBase64 && value) {
environmentFilePath = path.resolve(value);
if (!(await fs.pathExists(environmentFilePath))) {
throw new Error(`File "${value}" does not exist`);
}
fileName = path.basename(environmentFilePath);
}
value = environmentFilePath ? await fs.readFile(environmentFilePath, 'base64') : value;
if (!environments || environments.length === 0) {
environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
selectedEnvironments: selectedVariable.environments ?? [],
});
if (
!environments ||
environments.length === 0 ||
environments === selectedVariable.environments
) {
environments = undefined;
}
}
if (!visibility) {
newVisibility = await promptVariableVisibilityAsync(
nonInteractive,
selectedVariable.visibility
);
if (!newVisibility || newVisibility === selectedVariable.visibility) {
newVisibility = undefined;
}
}
}
if (visibility) {
newVisibility = parseVisibility(visibility);
}
return {
...rest,
name,
value,
environment: environments,
visibility: newVisibility,
'non-interactive': nonInteractive,
type: newType,
fileName,
};
}
}
@@ -0,0 +1,394 @@
import { Platform } from '@expo/eas-build-job';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { fetchBuildsAsync, formatBuild } from '../../commandUtils/builds';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppPlatform, BuildStatus } from '../../graphql/generated';
import { FingerprintMutation } from '../../graphql/mutations/FingerprintMutation';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { maybeUploadFingerprintAsync } from '../../project/maybeUploadFingerprintAsync';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
import { selectAsync } from '../../prompts';
import { Fingerprint, FingerprintDiffItem } from '../../utils/fingerprint';
import { createFingerprintAsync, diffFingerprint } from '../../utils/fingerprintCli';
import { abridgedDiff } from '../../utils/fingerprintDiff';
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
import { enableJsonOutput } from '../../utils/json';
export default class FingerprintCompare extends EasCommand {
static override description = 'compare fingerprints of the current project, builds and updates';
static override hidden = true;
static override flags = {
'build-id': Flags.string({
aliases: ['buildId'],
description: 'Compare the fingerprint with the build with the specified ID',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(FingerprintCompare);
const { json: jsonFlag, 'non-interactive': nonInteractive, buildId: buildIdFromArg } = flags;
const {
projectId,
privateProjectConfig: { projectDir },
loggedIn: { graphqlClient },
vcsClient,
} = await this.getContextAsync(FingerprintCompare, {
nonInteractive,
withServerSideEnvironment: null,
});
if (jsonFlag) {
enableJsonOutput();
}
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
let buildId: string | null = buildIdFromArg;
if (!buildId) {
if (nonInteractive) {
throw new Error('Build ID must be provided in non-interactive mode');
}
buildId = await selectBuildToCompareAsync(graphqlClient, projectId, displayName, {
filters: { hasFingerprint: true },
});
if (!buildId) {
return;
}
}
Log.log(`Comparing fingerprints of the current project and build ${buildId}…`);
const buildWithFingerprint = await BuildQuery.withFingerprintByIdAsync(graphqlClient, buildId);
const fingerprintDebugUrl = buildWithFingerprint.fingerprint?.debugInfoUrl;
if (!fingerprintDebugUrl) {
Log.error('A fingerprint for the build could not be found.');
return;
}
const fingerprintResponse = await fetch(fingerprintDebugUrl);
const fingerprint = (await fingerprintResponse.json()) as Fingerprint;
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient);
const buildPlatform = buildWithFingerprint.platform;
const workflow = workflows[appPlatformToPlatform(buildPlatform)];
const projectFingerprint = await createFingerprintAsync(projectDir, {
workflow,
platforms: [appPlatformToString(buildPlatform)],
debug: true,
env: undefined,
});
if (!projectFingerprint) {
Log.error('Project fingerprints can only be computed for projects with SDK 52 or higher');
return;
}
const uploadedFingerprint = await maybeUploadFingerprintAsync({
hash: fingerprint.hash,
fingerprint: {
fingerprintSources: fingerprint.sources,
isDebugFingerprintSource: Log.isDebug,
},
graphqlClient,
});
await FingerprintMutation.createFingerprintAsync(graphqlClient, projectId, {
hash: uploadedFingerprint.hash,
source: uploadedFingerprint.fingerprintSource,
});
if (fingerprint.hash === projectFingerprint.hash) {
Log.log(`✅ Project fingerprint matches build`);
return;
} else {
Log.log(`🔄 Project fingerprint differs from build`);
}
const fingerprintDiffs = diffFingerprint(projectDir, fingerprint, projectFingerprint);
if (!fingerprintDiffs) {
Log.error('Fingerprint diffs can only be computed for projects with SDK 52 or higher');
return;
}
const filePathDiffs = fingerprintDiffs.filter(diff => {
let sourceType;
if (diff.op === 'added') {
sourceType = diff.addedSource.type;
} else if (diff.op === 'removed') {
sourceType = diff.removedSource.type;
} else if (diff.op === 'changed') {
sourceType = diff.beforeSource.type;
}
return sourceType === 'dir' || sourceType === 'file';
});
if (filePathDiffs.length > 0) {
Log.newLine();
Log.log('📁 Paths with native dependencies:');
}
const fields = [];
for (const diff of filePathDiffs) {
const field = getDiffFilePathFields(diff);
if (!field) {
throw new Error(`Unsupported diff: ${JSON.stringify(diff)}`);
}
fields.push(field);
}
Log.log(
formatFields(fields, {
labelFormat: label => ` ${chalk.dim(label)}:`,
})
);
const contentDiffs = fingerprintDiffs.filter(diff => {
let sourceType;
if (diff.op === 'added') {
sourceType = diff.addedSource.type;
} else if (diff.op === 'removed') {
sourceType = diff.removedSource.type;
} else if (diff.op === 'changed') {
sourceType = diff.beforeSource.type;
}
return sourceType === 'contents';
});
for (const diff of contentDiffs) {
printContentDiff(diff);
}
}
}
function printContentDiff(diff: FingerprintDiffItem): void {
if (diff.op === 'added') {
const sourceType = diff.addedSource.type;
if (sourceType === 'contents') {
printContentSource({
op: diff.op,
sourceType,
contentsId: diff.addedSource.id,
contentsAfter: diff.addedSource.contents,
});
}
} else if (diff.op === 'removed') {
const sourceType = diff.removedSource.type;
if (sourceType === 'contents') {
printContentSource({
op: diff.op,
sourceType,
contentsId: diff.removedSource.id,
contentsBefore: diff.removedSource.contents,
});
}
} else if (diff.op === 'changed') {
const sourceType = diff.beforeSource.type;
if (sourceType === 'contents') {
if (diff.afterSource.type !== 'contents') {
throw new Error(
`Changed fingerprint source types must be the same, received ${diff.beforeSource.type}, ${diff.afterSource.type}`
);
}
printContentSource({
op: diff.op,
sourceType: diff.beforeSource.type, // before and after source types should be the same
contentsId: diff.beforeSource.id, // before and after content ids should be the same
contentsBefore: diff.beforeSource.contents,
contentsAfter: diff.afterSource.contents,
});
}
}
}
function getDiffFilePathFields(diff: FingerprintDiffItem): FormatFieldsItem | null {
if (diff.op === 'added') {
const sourceType = diff.addedSource.type;
if (sourceType !== 'contents') {
return getFilePathSourceFields({
op: diff.op,
sourceType,
filePath: diff.addedSource.filePath,
});
}
} else if (diff.op === 'removed') {
const sourceType = diff.removedSource.type;
if (sourceType !== 'contents') {
return getFilePathSourceFields({
op: diff.op,
sourceType,
filePath: diff.removedSource.filePath,
});
}
} else if (diff.op === 'changed') {
const sourceType = diff.beforeSource.type;
if (sourceType !== 'contents') {
return getFilePathSourceFields({
op: diff.op,
sourceType: diff.beforeSource.type, // before and after source types should be the same
filePath: diff.beforeSource.filePath, // before and after filePaths should be the same
});
}
}
return null;
}
function getFilePathSourceFields({
op,
sourceType,
filePath,
}: {
op: 'added' | 'removed' | 'changed';
sourceType: 'dir' | 'file';
filePath: string;
}): FormatFieldsItem {
if (sourceType === 'dir') {
if (op === 'added') {
return { label: 'new directory', value: filePath };
} else if (op === 'removed') {
return { label: 'removed directory', value: filePath };
} else if (op === 'changed') {
return { label: 'modified directory', value: filePath };
}
} else if (sourceType === 'file') {
if (op === 'added') {
return { label: 'new file', value: filePath };
} else if (op === 'removed') {
return { label: 'removed file', value: filePath };
} else if (op === 'changed') {
return { label: 'modified file', value: filePath };
}
}
throw new Error(`Unsupported source and op: ${sourceType}, ${op}`);
}
const PRETTY_CONTENT_ID: Record<string, string> = {
'expoAutolinkingConfig:ios': 'Expo autolinking config (iOS)',
'expoAutolinkingConfig:android': 'Expo autolinking config (Android)',
'packageJson:scripts': 'package.json scripts',
expoConfig: 'Expo config',
};
function printContentSource({
op,
contentsBefore,
contentsAfter,
contentsId,
}: {
op: 'added' | 'removed' | 'changed';
sourceType: 'contents';
contentsBefore?: string | Buffer;
contentsAfter?: string | Buffer;
contentsId: string;
}): void {
Log.newLine();
const prettyContentId = PRETTY_CONTENT_ID[contentsId] ?? contentsId;
if (op === 'added') {
Log.log(`${chalk.dim('📝 New content')}: ${prettyContentId}`);
} else if (op === 'removed') {
Log.log(`${chalk.dim('📝 Removed content')}: ${prettyContentId}`);
} else if (op === 'changed') {
Log.log(`${chalk.dim('📝 Modified content')}: ${prettyContentId}`);
}
printContentsDiff(contentsBefore ?? '', contentsAfter ?? '');
}
function printContentsDiff(contents1: string | Buffer, contents2: string | Buffer): void {
const stringifiedContents1 = Buffer.isBuffer(contents1) ? contents1.toString() : contents1;
const stringifiedContents2 = Buffer.isBuffer(contents2) ? contents2.toString() : contents2;
const isStr1JSON = isJSON(stringifiedContents1);
const isStr2JSON = isJSON(stringifiedContents2);
const prettifiedContents1 = isStr1JSON
? JSON.stringify(JSON.parse(stringifiedContents1), null, 2)
: stringifiedContents1;
const prettifiedContents2 = isStr2JSON
? JSON.stringify(JSON.parse(stringifiedContents2), null, 2)
: stringifiedContents2;
abridgedDiff(prettifiedContents1, prettifiedContents2, 0);
}
function isJSON(str: string): boolean {
try {
JSON.parse(str);
return true;
} catch {
return false;
}
}
function appPlatformToPlatform(platform: AppPlatform): Platform {
switch (platform) {
case AppPlatform.Android:
return Platform.ANDROID;
case AppPlatform.Ios:
return Platform.IOS;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
function appPlatformToString(platform: AppPlatform): string {
switch (platform) {
case AppPlatform.Android:
return 'android';
case AppPlatform.Ios:
return 'ios';
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
export async function selectBuildToCompareAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
projectDisplayName: string,
{
filters,
}: {
filters?: {
statuses?: BuildStatus[];
platform?: RequestedPlatform;
profile?: string;
hasFingerprint?: boolean;
};
} = {}
): Promise<string | null> {
const spinner = ora().start('Fetching builds…');
let builds;
try {
builds = await fetchBuildsAsync({ graphqlClient, projectId, filters });
spinner.stop();
} catch (error) {
spinner.fail(
`Something went wrong and we couldn't fetch the builds for the project ${projectDisplayName}.`
);
throw error;
}
if (builds.length === 0) {
Log.warn(`No fingerprints have been computed for builds of project ${projectDisplayName}.`);
return null;
} else {
const build = await selectAsync<string>(
'Which build do you want to compare?',
builds.map(build => ({
title: formatBuild(build),
value: build.id,
}))
);
return build;
}
}
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more