mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
190
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d7ac68da | ||
|
|
1b713af42f | ||
|
|
3ac6472682 | ||
|
|
4b8a9d195f | ||
|
|
30c5e9f4c1 | ||
|
|
d0c8f6bab7 | ||
|
|
faf6bffc62 | ||
|
|
ad55fdae37 | ||
|
|
b5f77404cb | ||
|
|
a1d4f26236 | ||
|
|
6c7bae2349 | ||
|
|
d7e48c2894 | ||
|
|
918a7f9501 | ||
|
|
852dac030f | ||
|
|
911cc90b93 | ||
|
|
b9234abff1 | ||
|
|
574bde9831 | ||
|
|
78837a413a | ||
|
|
6f63315f8e | ||
|
|
e8ac97c8cc | ||
|
|
a6fc31787d | ||
|
|
3056d8e23a | ||
|
|
1bf6757a72 | ||
|
|
b948dd5674 | ||
|
|
0e7a8bfe5e | ||
|
|
b86696fcdf | ||
|
|
f334bdbcc7 | ||
|
|
f16433048a | ||
|
|
e83061de36 | ||
|
|
73e0170836 | ||
|
|
078c7287d3 | ||
|
|
a65e691325 | ||
|
|
b306109676 | ||
|
|
035ca03b96 | ||
|
|
b3d092423b | ||
|
|
b768966ec4 | ||
|
|
1b40c1c20a | ||
|
|
98344b8fc2 | ||
|
|
f5480b4770 | ||
|
|
a2929ad364 | ||
|
|
6deee68a00 | ||
|
|
16d190ab84 | ||
|
|
6f2fc7c37b | ||
|
|
1b78407f15 | ||
|
|
05c8fde824 | ||
|
|
d9af6cb301 | ||
|
|
365e71f042 | ||
|
|
e74c606efa | ||
|
|
28f3a92c38 | ||
|
|
be9fb59e6d | ||
|
|
365a7465f4 | ||
|
|
85a66629c8 | ||
|
|
8a2d68fcb5 | ||
|
|
62819e0956 | ||
|
|
5c6880f4ce | ||
|
|
d5f562edf8 | ||
|
|
077d8b3d86 | ||
|
|
21c8131bcf | ||
|
|
18bf41782f | ||
|
|
0513fc51a6 | ||
|
|
528988dc0f | ||
|
|
7f46baeb21 | ||
|
|
608d61ed69 | ||
|
|
2b2b20a0f9 | ||
|
|
77423d1179 | ||
|
|
ebc277dfe4 | ||
|
|
427c8f152d | ||
|
|
1c245d23b4 | ||
|
|
29c4695351 | ||
|
|
7b968f6e0d | ||
|
|
8a38cc24e1 | ||
|
|
a85b9e2d80 | ||
|
|
e0f69327da | ||
|
|
76d4e65d92 | ||
|
|
58917ecbb4 | ||
|
|
e75a102361 | ||
|
|
061f626b2c | ||
|
|
3159550468 | ||
|
|
a20bd0051e | ||
|
|
0b64d1c8df | ||
|
|
fe37600323 | ||
|
|
ab507d0bdd | ||
|
|
7505b8d40a | ||
|
|
b31ddee6e7 | ||
|
|
01ce802b73 | ||
|
|
f75ef22154 | ||
|
|
7d3f4e8fd4 | ||
|
|
004caf4bb7 | ||
|
|
5a16f3d6d8 | ||
|
|
d2e4cb5fc6 | ||
|
|
2c1c9b91cb | ||
|
|
b447194c85 | ||
|
|
9ab095597a | ||
|
|
b1edff7242 | ||
|
|
4d9e37269c | ||
|
|
e7b48949db | ||
|
|
1c3edbe492 | ||
|
|
deaa3a9105 | ||
|
|
cbfcdaec5d | ||
|
|
37a32c93ee | ||
|
|
6461a78fa2 | ||
|
|
86c8ac093f | ||
|
|
608cdff981 | ||
|
|
4dc4ef85fa | ||
|
|
eaf15acb76 | ||
|
|
ae4ca91658 | ||
|
|
23ebb77952 | ||
|
|
ed2352f289 | ||
|
|
38ae8b68f1 | ||
|
|
5e02807ac9 | ||
|
|
f05d6ca3a2 | ||
|
|
b57082647d | ||
|
|
9d4fdc3c75 | ||
|
|
d1af9e385f | ||
|
|
59bbe19dde | ||
|
|
5e5b55f9e4 | ||
|
|
a1d086ffc6 | ||
|
|
2a96d04be3 | ||
|
|
d126ee988a | ||
|
|
4bb550e3bb | ||
|
|
6a55c65908 | ||
|
|
ab3495da5e | ||
|
|
63b67635e0 | ||
|
|
4b5cda3bbc | ||
|
|
63e3c1f2ab | ||
|
|
3f1543989f | ||
|
|
1c964d2ab1 | ||
|
|
bc282b6262 | ||
|
|
17661d1cf0 | ||
|
|
8d35745124 | ||
|
|
3740ee4ef9 | ||
|
|
751d3d1287 | ||
|
|
1fd14c07e1 | ||
|
|
39ea1705d8 | ||
|
|
2c96b9929d | ||
|
|
0dae24ae47 | ||
|
|
a63efe01a1 | ||
|
|
803457b37a | ||
|
|
dcb1305501 | ||
|
|
97fe2d62d2 | ||
|
|
ca0850b82b | ||
|
|
039a7b408b | ||
|
|
68660ae845 | ||
|
|
34bb2c8a75 | ||
|
|
ac5cf3d7dd | ||
|
|
8d686b855b | ||
|
|
19973bb86f | ||
|
|
d132ac09de | ||
|
|
4c5e5d4ded | ||
|
|
44af16efe1 | ||
|
|
f34487cc8e | ||
|
|
7ea1c5de17 | ||
|
|
261f611770 | ||
|
|
f9337f419a | ||
|
|
4280e10836 | ||
|
|
a2fe99c97e | ||
|
|
868fd399a4 | ||
|
|
7d60ba2cea | ||
|
|
db7461a569 | ||
|
|
facffbdcd7 | ||
|
|
dbdefa0e3d | ||
|
|
66daeade84 | ||
|
|
48ebd9bb78 | ||
|
|
3d3574d351 | ||
|
|
8da6adf4da | ||
|
|
bd2d39f26b | ||
|
|
e16e1f3871 | ||
|
|
d8bd6a1548 | ||
|
|
0f2fad3a65 | ||
|
|
78e05c48c8 | ||
|
|
fa10404843 | ||
|
|
5bf2c3a65b | ||
|
|
ec71e1168f | ||
|
|
8bf28029dc | ||
|
|
fc3c1e73a8 | ||
|
|
bb3915dc7e | ||
|
|
753df2c428 | ||
|
|
3111492f83 | ||
|
|
9c95755a46 | ||
|
|
da727ba170 | ||
|
|
4571910c4d | ||
|
|
c7cfcafff8 | ||
|
|
6f913f7320 | ||
|
|
0a49296231 | ||
|
|
7fc09b2376 | ||
|
|
fed28a87be | ||
|
|
073bdcd575 | ||
|
|
6979c8d67b | ||
|
|
0869bc511f | ||
|
|
693562d742 |
No files matched your search
+6
-12
@@ -3,24 +3,18 @@ commentConfiguration:
|
||||
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
|
||||
rules:
|
||||
- patterns: ['**/*']
|
||||
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela']
|
||||
- patterns: ['packages/eas-cli/src/build/**']
|
||||
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela']
|
||||
- patterns: ['packages/eas-cli/src/commands/build/**']
|
||||
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela']
|
||||
- patterns: ['packages/eas-cli/src/submit/**']
|
||||
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela']
|
||||
- patterns: ['packages/eas-cli/src/commands/submit.ts']
|
||||
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela', 'radoslawkrzemien']
|
||||
- patterns: ['packages/eas-cli/src/update/**']
|
||||
mentions: ['EvanBacon', 'byCedric', 'wschurman']
|
||||
mentions: ['wschurman', 'quinlanj']
|
||||
- patterns: ['packages/eas-cli/src/commands/update/**']
|
||||
mentions: ['EvanBacon', 'byCedric', 'wschurman']
|
||||
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']
|
||||
- patterns: ['packages/eas-json/**']
|
||||
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela']
|
||||
@@ -226,16 +226,3 @@ jobs:
|
||||
SLACK_USERNAME: 'EAS CLI CI'
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_FOOTER: ''
|
||||
open-pr-in-universe:
|
||||
name: Open a PR with `@expo/eas-json` bump in universe
|
||||
needs: publish-to-npm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Open a PR with `@expo/eas-json` version bump in universe
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
repo: expo/universe
|
||||
ref: main
|
||||
workflow: update-eas-json.yml
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
@@ -28,6 +28,9 @@ jobs:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn typecheck
|
||||
- run: |
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Test"
|
||||
- run: yarn test
|
||||
if: ${{ !matrix.coverage }}
|
||||
env:
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
|
||||
notify-slack:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
needs: [test]
|
||||
name: Notify Slack
|
||||
if: ${{ github.ref == 'refs/heads/main' && always() }}
|
||||
steps:
|
||||
|
||||
+287
@@ -12,6 +12,293 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Fix `eas fingerprint:compare` URL generation and pretty prints. ([#2909](https://github.com/expo/eas-cli/pull/2909) by [@quinlanj](https://github.com/quinlanj))
|
||||
- fix formatFields to handle empty array. ([#2914](https://github.com/expo/eas-cli/pull/2914) by [@quinlanj](https://github.com/quinlanj))
|
||||
- fix git diff header in `fingerprint:compare`. ([#2915](https://github.com/expo/eas-cli/pull/2915) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [15.0.12](https://github.com/expo/eas-cli/releases/tag/v15.0.12) - 2025-02-22
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix APNS key creation. ([#2916](https://github.com/expo/eas-cli/pull/2916) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
## [15.0.11](https://github.com/expo/eas-cli/releases/tag/v15.0.11) - 2025-02-21
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix internal TestFlight group creation. ([#2906](https://github.com/expo/eas-cli/pull/2906) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Narrow amount of data queried for basic update channel operations. ([#2901](https://github.com/expo/eas-cli/pull/2901) by [@wschurman](https://github.com/wschurman))
|
||||
- Fix `eas fingerprint:compare` description. ([#2908](https://github.com/expo/eas-cli/pull/2908) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Skip auto-creation of TestFlight group when there are already exisitng TestFlight groups and allow to opt out of the behavior by setting `EAS_NO_AUTO_TESTFLIGHT_SETUP` env var. ([#2856](https://github.com/expo/eas-cli/pull/2856) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [15.0.10](https://github.com/expo/eas-cli/releases/tag/v15.0.10) - 2025-02-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix files deleted in working directory not being removed from the project archive when `requireCommit` is false. ([#2900](https://github.com/expo/eas-cli/pull/2900) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [15.0.9](https://github.com/expo/eas-cli/releases/tag/v15.0.9) - 2025-02-09
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix files not being ignored when creating a tarball on Windows in Git repository in no `requireCommit` mode. ([#2894](https://github.com/expo/eas-cli/pull/2894) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [15.0.8](https://github.com/expo/eas-cli/releases/tag/v15.0.8) - 2025-02-09
|
||||
|
||||
## [15.0.7](https://github.com/expo/eas-cli/releases/tag/v15.0.7) - 2025-02-09
|
||||
|
||||
## [15.0.6](https://github.com/expo/eas-cli/releases/tag/v15.0.6) - 2025-02-07
|
||||
|
||||
## [15.0.5](https://github.com/expo/eas-cli/releases/tag/v15.0.5) - 2025-02-06
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Do not copy files over onto a cloned Git repository when packing the project archive if `requireCommit` is true. ([#2885](https://github.com/expo/eas-cli/pull/2885) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Fix `EISDIR` error when archiving project with submodules ignored. ([#2884](https://github.com/expo/eas-cli/pull/2884) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [15.0.4](https://github.com/expo/eas-cli/releases/tag/v15.0.4) - 2025-02-05
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fixed `GitClient` not respecting `.easignore` file. ([#2873](https://github.com/expo/eas-cli/pull/2873) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Fix symlink support in `makeShallowCopyAsync`. ([#2874](https://github.com/expo/eas-cli/pull/2874) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Allow excluding `.git` directory from project archive by adding it to `.easignore`. ([#2879](https://github.com/expo/eas-cli/pull/2879) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Popup website in fingerprint:compare. ([#2859](https://github.com/expo/eas-cli/pull/2859) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Fix fingerprint:compare URL. ([#2861](https://github.com/expo/eas-cli/pull/2861) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Make less gql calls in fingerprint:compare. ([#2860](https://github.com/expo/eas-cli/pull/2860) by [@quinlanj](https://github.com/quinlanj))
|
||||
- No longer require owner field for SDK >= 53 or canary. ([#2835](https://github.com/expo/eas-cli/pull/2835) by [@wschurman](https://github.com/wschurman))
|
||||
- Add --open flag to fingerprint:compare. ([#2872](https://github.com/expo/eas-cli/pull/2872) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [15.0.3](https://github.com/expo/eas-cli/releases/tag/v15.0.3) - 2025-02-04
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fixed EAS server environment variables does not pass to `npx expo-updates runtimeversion:resolve` call. ([#2867](https://github.com/expo/eas-cli/pull/2867) by [@kudo](https://github.com/kudo))
|
||||
|
||||
## [15.0.2](https://github.com/expo/eas-cli/releases/tag/v15.0.2) - 2025-02-04
|
||||
|
||||
## [15.0.1](https://github.com/expo/eas-cli/releases/tag/v15.0.1) - 2025-02-04
|
||||
|
||||
## [15.0.0](https://github.com/expo/eas-cli/releases/tag/v15.0.0) - 2025-02-04
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Use Git to archive projects containing a Git repository. (Previously, Git would only be used if `requireCommit` flag in `eas.json` was set to `true`.) ([#2841](https://github.com/expo/eas-cli/pull/2841) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Print warning for `NoVcsClient` only once. ([#2863](https://github.com/expo/eas-cli/pull/2863) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add update support for fingerprint:compare. ([#2850](https://github.com/expo/eas-cli/pull/2850) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add update group id support for fingerprint:compare. ([#2851](https://github.com/expo/eas-cli/pull/2851) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add better interactive support for fingerprint:compare. ([#2854](https://github.com/expo/eas-cli/pull/2854) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [14.7.1](https://github.com/expo/eas-cli/releases/tag/v14.7.1) - 2025-01-31
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Account for `ios.config.usesNonExemptEncryption` in non-exempt encryption status prompt. ([#2852](https://github.com/expo/eas-cli/pull/2852) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
## [14.7.0](https://github.com/expo/eas-cli/releases/tag/v14.7.0) - 2025-01-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--submit` and `-s` as aliases for `--auto-submit` flag. ([#2846](https://github.com/expo/eas-cli/pull/2846) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Skip non-exempt check in non-interactive mode. ([#2849](https://github.com/expo/eas-cli/pull/2849) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
## [14.6.0](https://github.com/expo/eas-cli/releases/tag/v14.6.0) - 2025-01-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Prompt to set non-exempt encryption status for the iOS app to support faster store submissions. ([#2843](https://github.com/expo/eas-cli/pull/2843) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Automatically create internal TestFlight group in EAS Submit command. ([#2839](https://github.com/expo/eas-cli/pull/2839) by [@evanbacon](https://github.com/evanbacon))
|
||||
- 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))
|
||||
- Release `eas fingerprint:compare`. ([#2821](https://github.com/expo/eas-cli/pull/2821) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- 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
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "13.2.2",
|
||||
"version": "15.0.12",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+563
-171
File diff suppressed because it is too large.
Load diff
@@ -12,3 +12,6 @@ if (process.env.TMPDIR) {
|
||||
(fs.realpath as any).native = jest.fn();
|
||||
|
||||
module.exports = fs;
|
||||
|
||||
// Ensure requiring node:fs returns the mock too. This is needed for expo/config and expo/json-file.
|
||||
jest.mock('node:fs', () => fs);
|
||||
Generated
+3311
-309
File diff suppressed because it is too large.
Load diff
@@ -1,34 +1,34 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "13.2.2",
|
||||
"version": "15.0.12",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.0",
|
||||
"@expo/apple-utils": "2.1.11",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "9.0.4",
|
||||
"@expo/config-plugins": "8.0.10",
|
||||
"@expo/eas-build-job": "1.0.149",
|
||||
"@expo/eas-json": "13.2.2",
|
||||
"@expo/env": "^0.3.0",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/eas-build-job": "1.0.165",
|
||||
"@expo/eas-json": "15.0.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.1.3",
|
||||
"@expo/package-manager": "1.5.2",
|
||||
"@expo/pkcs12": "0.1.2",
|
||||
"@expo/plist": "0.1.3",
|
||||
"@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": "7.0.9",
|
||||
"@expo/prebuild-config": "8.0.17",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "1.0.149",
|
||||
"@expo/steps": "1.0.166",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^1.26.2",
|
||||
"@oclif/plugin-autocomplete": "^2.3.10",
|
||||
@@ -41,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",
|
||||
@@ -64,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",
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -38,7 +38,7 @@ describe(ensureProjectConfiguredAsync, () => {
|
||||
}),
|
||||
});
|
||||
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeTruthy();
|
||||
const vcsClientMock = jest.mocked(new GitClient());
|
||||
const vcsClientMock = jest.mocked(new GitClient({ requireCommit: false }));
|
||||
vcsClientMock.showChangedFilesAsync.mockImplementation(async () => {});
|
||||
vcsClientMock.isCommitRequiredAsync.mockImplementation(async () => false);
|
||||
vcsClientMock.trackFileAsync.mockImplementation(async () => {});
|
||||
@@ -60,7 +60,7 @@ describe(ensureProjectConfiguredAsync, () => {
|
||||
});
|
||||
});
|
||||
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeFalsy();
|
||||
const vcsClientMock = jest.mocked(new GitClient());
|
||||
const vcsClientMock = jest.mocked(new GitClient({ requireCommit: false }));
|
||||
vcsClientMock.showChangedFilesAsync.mockImplementation(async () => {});
|
||||
vcsClientMock.isCommitRequiredAsync.mockImplementation(async () => false);
|
||||
vcsClientMock.trackFileAsync.mockImplementation(async () => {});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
transformProjectArchive,
|
||||
transformWorkflow,
|
||||
} from '../graphql';
|
||||
import { buildProfileEnvironmentToEnvironment } from '../utils/environment';
|
||||
|
||||
export function transformJob(job: Android.Job): AndroidJobInput {
|
||||
return {
|
||||
@@ -29,6 +30,7 @@ export function transformJob(job: Android.Job): AndroidJobInput {
|
||||
experimental: job.experimental,
|
||||
mode: transformBuildMode(job.mode),
|
||||
customBuildConfig: job.customBuildConfig,
|
||||
environment: buildProfileEnvironmentToEnvironment(job.environment),
|
||||
loggerLevel: job.loggerLevel
|
||||
? loggerLevelToGraphQLWorkerLoggerLevel[job.loggerLevel]
|
||||
: undefined,
|
||||
|
||||
@@ -92,6 +92,7 @@ export async function prepareJobAsync(
|
||||
gradleCommand: buildProfile.gradleCommand,
|
||||
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
|
||||
buildArtifactPaths: buildProfile.buildArtifactPaths,
|
||||
environment: ctx.buildProfile.environment,
|
||||
buildType,
|
||||
username,
|
||||
...(ctx.android.versionCodeOverride && {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -35,8 +35,10 @@ import { collectMetadataAsync } from './metadata';
|
||||
import { printDeprecationWarnings } from './utils/printBuildInfo';
|
||||
import {
|
||||
LocalFile,
|
||||
assertProjectTarballSizeDoesNotExceedLimit,
|
||||
makeProjectMetadataFileAsync,
|
||||
makeProjectTarballAsync,
|
||||
maybeWarnAboutProjectTarballSize,
|
||||
reviewAndCommitChangesAsync,
|
||||
} from './utils/repository';
|
||||
import { BuildEvent } from '../analytics/AnalyticsManager';
|
||||
@@ -275,19 +277,8 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
);
|
||||
const projectTarball = await makeProjectTarballAsync(ctx.vcsClient);
|
||||
|
||||
if (projectTarball.size > 1024 * 1024 * 100) {
|
||||
Log.warn(
|
||||
`Your project archive is ${formatBytes(
|
||||
projectTarball.size
|
||||
)}. You can reduce its size and the time it takes to upload by excluding files that are unnecessary for the build process in ${chalk.bold(
|
||||
'.easignore'
|
||||
)} file. ${learnMore('https://expo.fyi/eas-build-archive')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (projectTarball.size > 2 * 1024 * 1024 * 1024) {
|
||||
throw new Error('Project archive is too big. Maximum allowed size is 2GB.');
|
||||
}
|
||||
maybeWarnAboutProjectTarballSize(projectTarball.size);
|
||||
assertProjectTarballSizeDoesNotExceedLimit(projectTarball.size);
|
||||
|
||||
projectTarballPath = projectTarball.path;
|
||||
const [bucketKey, { metadataLocation }] = await Promise.all([
|
||||
@@ -671,7 +662,7 @@ async function computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync<T extends
|
||||
}> {
|
||||
const runtimeAndFingerprintMetadata =
|
||||
await computeAndMaybeUploadFingerprintFromExpoUpdatesAsync(ctx);
|
||||
if (!runtimeAndFingerprintMetadata?.fingerprint) {
|
||||
if (!runtimeAndFingerprintMetadata?.fingerprintHash) {
|
||||
const fingerprint = await computeAndMaybeUploadFingerprintWithoutExpoUpdatesAsync(ctx);
|
||||
return {
|
||||
...runtimeAndFingerprintMetadata,
|
||||
@@ -690,10 +681,7 @@ async function computeAndMaybeUploadFingerprintFromExpoUpdatesAsync<T extends Pl
|
||||
): Promise<{
|
||||
runtimeVersion?: string;
|
||||
fingerprintSource?: FingerprintSource;
|
||||
fingerprint?: {
|
||||
fingerprintSources: object[];
|
||||
isDebugFingerprintSource: boolean;
|
||||
};
|
||||
fingerprintHash?: string;
|
||||
}> {
|
||||
const resolvedRuntimeVersion = await resolveRuntimeVersionAsync({
|
||||
exp: ctx.exp,
|
||||
@@ -712,7 +700,10 @@ async function computeAndMaybeUploadFingerprintFromExpoUpdatesAsync<T extends Pl
|
||||
* 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,
|
||||
};
|
||||
@@ -720,14 +711,14 @@ async function computeAndMaybeUploadFingerprintFromExpoUpdatesAsync<T extends Pl
|
||||
|
||||
const uploadedFingerprint = await maybeUploadFingerprintAsync({
|
||||
hash: resolvedRuntimeVersion.runtimeVersion,
|
||||
fingerprint: resolvedRuntimeVersion.fingerprint,
|
||||
fingerprint: resolvedRuntimeVersion.expoUpdatesRuntimeFingerprint,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
localBuildMode: ctx.localBuildOptions.localBuildMode,
|
||||
});
|
||||
return {
|
||||
runtimeVersion: uploadedFingerprint.hash,
|
||||
fingerprintSource: uploadedFingerprint.fingerprintSource,
|
||||
fingerprint: resolvedRuntimeVersion.fingerprint,
|
||||
fingerprintHash: resolvedRuntimeVersion.expoUpdatesRuntimeFingerprintHash ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -740,7 +731,7 @@ async function computeAndMaybeUploadFingerprintWithoutExpoUpdatesAsync<T extends
|
||||
}> {
|
||||
const fingerprint = await createFingerprintAsync(ctx.projectDir, {
|
||||
workflow: ctx.workflow,
|
||||
platform: ctx.platform,
|
||||
platforms: [ctx.platform],
|
||||
env: ctx.env,
|
||||
});
|
||||
if (!fingerprint) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
|
||||
|
||||
import { maybeBailOnRepoStatusAsync, reviewAndCommitChangesAsync } from './utils/repository';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
@@ -52,6 +53,78 @@ async function configureAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function doesBuildProfileExistAsync({
|
||||
projectDir,
|
||||
profileName,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profileName: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJson = await easJsonAccessor.readRawJsonAsync();
|
||||
if (!easJson.build?.[profileName]) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
Log.error(`We were unable to read ${chalk.bold('eas.json')} contents. Error: ${error}.`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBuildProfileAsync({
|
||||
projectDir,
|
||||
profileName,
|
||||
profileContents,
|
||||
vcsClient,
|
||||
nonInteractive,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profileName: string;
|
||||
profileContents: Record<string, any>;
|
||||
vcsClient: Client;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<void> {
|
||||
const spinner = ora(`Adding "${profileName}" build profile to ${chalk.bold('eas.json')}`).start();
|
||||
try {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
return {
|
||||
...easJsonRawObject,
|
||||
build: {
|
||||
...easJsonRawObject.build,
|
||||
[profileName]: profileContents,
|
||||
},
|
||||
};
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
spinner.succeed(
|
||||
`Successfully added "${profileName}" build profile to ${chalk.bold('eas.json')}.`
|
||||
);
|
||||
|
||||
if (await vcsClient.isCommitRequiredAsync()) {
|
||||
Log.newLine();
|
||||
await reviewAndCommitChangesAsync(
|
||||
vcsClient,
|
||||
`Add "${profileName}" build profile to eas.json`,
|
||||
{
|
||||
nonInteractive,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(
|
||||
`We were not able to configure "${profileName}" build profile inside of ${chalk.bold(
|
||||
'eas.json'
|
||||
)}. Error: ${error}.`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const EAS_JSON_DEFAULT: EasJson = {
|
||||
cli: {
|
||||
version: `>= ${easCliVersion}`,
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
|
||||
import { isEnvironment } from './utils/environment';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EnvironmentVariableEnvironment } from '../graphql/generated';
|
||||
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
|
||||
function isEnvironment(env: string): env is EnvironmentVariableEnvironment {
|
||||
return Object.values(EnvironmentVariableEnvironment).includes(
|
||||
env as EnvironmentVariableEnvironment
|
||||
);
|
||||
}
|
||||
|
||||
export async function evaluateConfigWithEnvVarsAsync<Config extends { projectId: string }, Opts>({
|
||||
buildProfile,
|
||||
buildProfileName,
|
||||
@@ -54,9 +49,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
|
||||
@@ -83,24 +79,13 @@ async function resolveEnvVarsAsync({
|
||||
|
||||
if (Object.keys(serverEnvVars).length > 0) {
|
||||
Log.log(
|
||||
`Environment variables loaded from the "${environment.toLowerCase()}" environment on EAS servers: ${Object.keys(
|
||||
`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 found for the "${environment.toLowerCase()}" environment on EAS servers.`
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedEnvVars = environmentVariables.filter(({ name, value }) => name && !value);
|
||||
if (encryptedEnvVars.length > 0) {
|
||||
Log.warn(
|
||||
`Some environment variables defined in the "${environment.toLowerCase()}" environment on EAS servers are of "encrypted" type and cannot be read outside of the EAS servers (including EAS CLI): ${encryptedEnvVars
|
||||
.map(({ name }) => name)
|
||||
.join(
|
||||
', '
|
||||
)}. However, they will be available during the build process happening on the EAS servers. This can lead to potential configuration mismatches between the local development environment and the build environment if the encrypted environment variables are used to resolve the app config.`
|
||||
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment.toLowerCase()}" environment on EAS.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,10 +95,6 @@ async function resolveEnvVarsAsync({
|
||||
buildProfile.env && Object.keys(buildProfile.env).join(', ')
|
||||
}.`
|
||||
);
|
||||
} else {
|
||||
Log.log(
|
||||
`No environment variables specified in the "${buildProfileName}" build profile "env" configuration.`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -126,7 +107,7 @@ async function resolveEnvVarsAsync({
|
||||
);
|
||||
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 servers: ${overlappingKeys.join(
|
||||
`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.`
|
||||
);
|
||||
@@ -145,3 +126,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',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { IosCredentials } from '../../credentials/ios/types';
|
||||
import { BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
|
||||
import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../../project/ios/exemptEncryption';
|
||||
import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
|
||||
import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/target';
|
||||
import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build';
|
||||
@@ -28,6 +29,7 @@ export async function createIosContextAsync(
|
||||
|
||||
if (ctx.workflow === Workflow.MANAGED) {
|
||||
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx);
|
||||
await ensureNonExemptEncryptionIsDefinedForManagedProjectAsync(ctx);
|
||||
}
|
||||
|
||||
checkNodeEnvVariable(ctx);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
transformProjectArchive,
|
||||
transformWorkflow,
|
||||
} from '../graphql';
|
||||
import { buildProfileEnvironmentToEnvironment } from '../utils/environment';
|
||||
|
||||
export function transformJob(job: Ios.Job): IosJobInput {
|
||||
return {
|
||||
@@ -31,6 +32,7 @@ export function transformJob(job: Ios.Job): IosJobInput {
|
||||
experimental: job.experimental,
|
||||
mode: transformBuildMode(job.mode),
|
||||
customBuildConfig: job.customBuildConfig,
|
||||
environment: buildProfileEnvironmentToEnvironment(job.environment),
|
||||
loggerLevel: job.loggerLevel
|
||||
? loggerLevelToGraphQLWorkerLoggerLevel[job.loggerLevel]
|
||||
: undefined,
|
||||
|
||||
@@ -88,6 +88,7 @@ export async function prepareJobAsync(
|
||||
buildConfiguration: buildProfile.buildConfiguration,
|
||||
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
|
||||
buildArtifactPaths: buildProfile.buildArtifactPaths,
|
||||
environment: ctx.buildProfile.environment,
|
||||
username,
|
||||
...(ctx.ios.buildNumberOverride && {
|
||||
version: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.144';
|
||||
const PLUGIN_PACKAGE_VERSION = '1.0.168';
|
||||
|
||||
export enum LocalBuildMode {
|
||||
/**
|
||||
|
||||
@@ -62,7 +62,6 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
requiredPackageManager: ctx.requiredPackageManager ?? undefined,
|
||||
selectedImage: ctx.buildProfile.image,
|
||||
customNodeVersion: ctx.buildProfile.node,
|
||||
environment: ctx.buildProfile.environment,
|
||||
simulator: 'simulator' in ctx.buildProfile && ctx.buildProfile.simulator,
|
||||
};
|
||||
return sanitizeMetadata(metadata);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { LoggerLevel } from '@expo/logger';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import { pathExists } from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { prepareAndroidBuildAsync } from './android/build';
|
||||
@@ -62,7 +63,7 @@ import {
|
||||
validateBuildProfileVersionSettingsAsync,
|
||||
} from '../project/remoteVersionSource';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { runAsync } from '../run/run';
|
||||
import { getEasBuildRunCachedAppPath, runAsync } from '../run/run';
|
||||
import { isRunnableOnSimulatorOrEmulator } from '../run/utils';
|
||||
import { createSubmissionContextAsync } from '../submit/context';
|
||||
import {
|
||||
@@ -99,15 +100,27 @@ export interface BuildFlags {
|
||||
repack: boolean;
|
||||
}
|
||||
|
||||
export async function runBuildAndSubmitAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
analytics: Analytics,
|
||||
vcsClient: Client,
|
||||
projectDir: string,
|
||||
flags: BuildFlags,
|
||||
actor: Actor,
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn
|
||||
): Promise<{
|
||||
export async function runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
projectDir,
|
||||
flags,
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
downloadSimBuildAutoConfirm,
|
||||
envOverride,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
vcsClient: Client;
|
||||
projectDir: string;
|
||||
flags: BuildFlags;
|
||||
actor: Actor;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
downloadSimBuildAutoConfirm?: boolean;
|
||||
envOverride?: Env;
|
||||
}): Promise<{
|
||||
buildIds: string[];
|
||||
}> {
|
||||
await vcsClient.ensureRepoExistsAsync();
|
||||
@@ -192,13 +205,15 @@ export async function runBuildAndSubmitAsync(
|
||||
for (const buildProfile of buildProfiles) {
|
||||
const platform = toAppPlatform(buildProfile.platform);
|
||||
|
||||
const { env } = await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
getProjectConfig: getDynamicPrivateProjectConfigAsync,
|
||||
opts: { env: buildProfile.profile.env },
|
||||
});
|
||||
const { env } = !envOverride
|
||||
? await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
getProjectConfig: getDynamicPrivateProjectConfigAsync,
|
||||
opts: { env: buildProfile.profile.env },
|
||||
})
|
||||
: { env: envOverride };
|
||||
|
||||
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
|
||||
projectDir,
|
||||
@@ -308,7 +323,7 @@ export async function runBuildAndSubmitAsync(
|
||||
[BuildStatus.Errored, BuildStatus.Canceled, BuildStatus.PendingCancel].includes(build?.status)
|
||||
);
|
||||
|
||||
await maybeDownloadAndRunSimulatorBuildsAsync(builds, flags);
|
||||
await maybeDownloadAndRunSimulatorBuildsAsync(builds, flags, downloadSimBuildAutoConfirm);
|
||||
|
||||
if (haveAllBuildsFailedOrCanceled || !flags.autoSubmit) {
|
||||
if (flags.json) {
|
||||
@@ -531,18 +546,29 @@ function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | nul
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
|
||||
export async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
|
||||
assert(build.artifacts?.applicationArchiveUrl);
|
||||
const cachedAppPath = getEasBuildRunCachedAppPath(build.project.id, build.id, build.platform);
|
||||
|
||||
if (await pathExists(cachedAppPath)) {
|
||||
Log.newLine();
|
||||
Log.log(`Using cached app...`);
|
||||
await runAsync(cachedAppPath, build.platform);
|
||||
return;
|
||||
}
|
||||
|
||||
const buildPath = await downloadAndMaybeExtractAppAsync(
|
||||
build.artifacts.applicationArchiveUrl,
|
||||
build.platform
|
||||
build.platform,
|
||||
cachedAppPath
|
||||
);
|
||||
await runAsync(buildPath, build.platform);
|
||||
}
|
||||
|
||||
async function maybeDownloadAndRunSimulatorBuildsAsync(
|
||||
builds: MaybeBuildFragment[],
|
||||
flags: BuildFlags
|
||||
flags: BuildFlags,
|
||||
autoConfirm?: boolean
|
||||
): Promise<void> {
|
||||
const simBuilds = builds.filter(truthy).filter(isRunnableOnSimulatorOrEmulator);
|
||||
|
||||
@@ -550,11 +576,15 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
|
||||
for (const simBuild of simBuilds) {
|
||||
if (simBuild.platform === AppPlatform.Android || process.platform === 'darwin') {
|
||||
Log.newLine();
|
||||
const confirm = await confirmAsync({
|
||||
message: `Install and run the ${
|
||||
simBuild.platform === AppPlatform.Android ? 'Android' : 'iOS'
|
||||
} build on ${simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'}?`,
|
||||
});
|
||||
const confirm =
|
||||
autoConfirm ??
|
||||
(await confirmAsync({
|
||||
message: `Install and run the ${
|
||||
simBuild.platform === AppPlatform.Android ? 'Android' : 'iOS'
|
||||
} build on ${
|
||||
simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'
|
||||
}?`,
|
||||
}));
|
||||
if (confirm) {
|
||||
await downloadAndRunAsync(simBuild);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
|
||||
|
||||
type Environment = NonNullable<BuildProfile['environment']>;
|
||||
|
||||
const BuildProfileEnvironmentToEnvironment: Record<Environment, EnvironmentVariableEnvironment> = {
|
||||
production: EnvironmentVariableEnvironment.Production,
|
||||
preview: EnvironmentVariableEnvironment.Preview,
|
||||
development: EnvironmentVariableEnvironment.Development,
|
||||
};
|
||||
|
||||
export function isEnvironment(env: string): env is EnvironmentVariableEnvironment {
|
||||
return Object.values(EnvironmentVariableEnvironment).includes(
|
||||
env as EnvironmentVariableEnvironment
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProfileEnvironmentToEnvironment(
|
||||
environment: BuildProfile['environment']
|
||||
): EnvironmentVariableEnvironment | null {
|
||||
if (!environment) {
|
||||
return null;
|
||||
}
|
||||
return BuildProfileEnvironmentToEnvironment[environment];
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import path from 'path';
|
||||
import tar from 'tar';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import Log from '../../log';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { formatBytes } from '../../utils/files';
|
||||
@@ -169,6 +169,34 @@ export async function makeProjectTarballAsync(vcsClient: Client): Promise<LocalF
|
||||
return { size, path: tarPath };
|
||||
}
|
||||
|
||||
export function maybeWarnAboutProjectTarballSize(size: number): void {
|
||||
if (size <= 150 /* MiB */ * 1024 /* KiB */ * 1024 /* B */) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.warn(
|
||||
`Your project archive is ${formatBytes(
|
||||
size
|
||||
)}. You can reduce its size and the time it takes to upload by excluding files that are unnecessary for the build process in ${chalk.bold(
|
||||
'.easignore'
|
||||
)} file. ${learnMore('https://expo.fyi/eas-build-archive')}`
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_ALLOWED_PROJECT_TARBALL_SIZE =
|
||||
2 /* GiB */ * 1024 /* MiB */ * 1024 /* KiB */ * 1024; /* B */
|
||||
export function assertProjectTarballSizeDoesNotExceedLimit(size: number): void {
|
||||
if (size <= MAX_ALLOWED_PROJECT_TARBALL_SIZE) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Project archive is too big. Maximum allowed size is ${formatBytes(
|
||||
MAX_ALLOWED_PROJECT_TARBALL_SIZE
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
enum ShouldCommitChanges {
|
||||
Yes,
|
||||
ShowDiffFirst,
|
||||
|
||||
@@ -63,3 +63,12 @@ export function getWorkflowRunUrl(
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
projectName
|
||||
)}/github`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -289,7 +289,7 @@ export async function doesChannelExistAsync(
|
||||
{ appId, channelName }: { appId: string; channelName: string }
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
|
||||
appId,
|
||||
channelName,
|
||||
});
|
||||
@@ -303,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
|
||||
@@ -319,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, {
|
||||
@@ -327,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, {
|
||||
@@ -335,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;
|
||||
}
|
||||
@@ -358,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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+174
-79
@@ -78,7 +78,12 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
it('gets the project ID from app config if exists', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
exp: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
@@ -91,7 +96,7 @@ describe(getProjectIdAsync, () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).resolves.toEqual('1234');
|
||||
@@ -99,7 +104,13 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
it('throws when the owner is out of sync', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'test', owner: 'wat', extra: { eas: { projectId: '1234' } } },
|
||||
exp: {
|
||||
sdkVersion: '54.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
owner: 'wat',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
@@ -112,7 +123,13 @@ describe(getProjectIdAsync, () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'test', owner: 'wat', extra: { eas: { projectId: '1234' } } },
|
||||
{
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
owner: 'wat',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
@@ -122,83 +139,143 @@ describe(getProjectIdAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the owner is not specified and is different than logged in user', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
fullName: '@totallybrent/test',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
describe('when sdkVersion is less than 53', () => {
|
||||
it('throws when the owner is not specified and is different than logged in user', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
fullName: '@totallybrent/test',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) does not match the logged in user (notnotbrent) and the "owner" field is not specified. To ensure all libraries work correctly, "owner": "totallybrent" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) does not match the logged in user (notnotbrent) and the "owner" field is not specified. To ensure all libraries work correctly, "owner": "totallybrent" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
it('throws when the owner is not specified and is different than logged in user which is a robot', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'Robot',
|
||||
id: 'robot_id',
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
|
||||
},
|
||||
{
|
||||
id: 'account_id_2',
|
||||
name: 'dominik',
|
||||
users: [{ role: Role.ViewOnly, actor: { id: 'robot_id' } }],
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
},
|
||||
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
|
||||
});
|
||||
const sessionManagerRobot = instance(sessionManagerMock);
|
||||
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
fullName: '@totallybrent/test',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManagerRobot,
|
||||
{
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) must be specified in "owner" field when using a robot access token. To ensure all libraries work correctly, "owner": "totallybrent" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the owner is not specified and is different than logged in user which is a robot', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'Robot',
|
||||
id: 'robot_id',
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
|
||||
},
|
||||
{
|
||||
id: 'account_id_2',
|
||||
name: 'dominik',
|
||||
users: [{ role: Role.ViewOnly, actor: { id: 'robot_id' } }],
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
},
|
||||
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
|
||||
});
|
||||
const sessionManagerRobot = instance(sessionManagerMock);
|
||||
describe('when sdkVersion is 53 or higher', () => {
|
||||
it('does not throw when the owner is not specified', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: {
|
||||
sdkVersion: '53.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
fullName: '@totallybrent/test',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
});
|
||||
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
fullName: '@totallybrent/test',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{
|
||||
sdkVersion: '53.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManagerRobot,
|
||||
{ name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) must be specified in "owner" field when using a robot access token. To ensure all libraries work correctly, "owner": "totallybrent" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the slug is out of sync', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'wat', extra: { eas: { projectId: '1234' } } },
|
||||
exp: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'wat',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
@@ -211,7 +288,7 @@ describe(getProjectIdAsync, () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'wat', extra: { eas: { projectId: '1234' } } },
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'wat', extra: { eas: { projectId: '1234' } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
@@ -222,10 +299,17 @@ describe(getProjectIdAsync, () => {
|
||||
});
|
||||
|
||||
it('fetches the project ID when not in app config, and sets it in the config', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
|
||||
jest
|
||||
.mocked(getConfig)
|
||||
.mockReturnValue({ exp: { sdkVersion: '52.0.0', name: 'test', slug: 'test' } } as any);
|
||||
jest.mocked(modifyConfigAsync).mockResolvedValue({
|
||||
type: 'success',
|
||||
config: { expo: { name: 'test', slug: 'test', extra: { eas: { projectId: '2345' } } } },
|
||||
config: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '2345' } },
|
||||
},
|
||||
});
|
||||
jest
|
||||
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
|
||||
@@ -233,7 +317,7 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
const projectId = await getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'test' },
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
|
||||
{
|
||||
nonInteractive: false,
|
||||
}
|
||||
@@ -252,7 +336,9 @@ describe(getProjectIdAsync, () => {
|
||||
});
|
||||
|
||||
it('throws if writing the ID back to the config fails', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
|
||||
jest
|
||||
.mocked(getConfig)
|
||||
.mockReturnValue({ exp: { sdkVersion: '52.0.0', name: 'test', slug: 'test' } } as any);
|
||||
jest.mocked(modifyConfigAsync).mockResolvedValue({
|
||||
type: 'fail',
|
||||
config: null,
|
||||
@@ -262,13 +348,22 @@ describe(getProjectIdAsync, () => {
|
||||
.mockImplementation(async () => '4567');
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(sessionManager, { name: 'test', slug: 'test' }, { nonInteractive: false })
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws if extra.eas.projectId is not a string', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'test', extra: { eas: { projectId: 1234 } } },
|
||||
exp: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: 1234 } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
@@ -281,7 +376,7 @@ describe(getProjectIdAsync, () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'wat', extra: { eas: { projectId: 1234 } } },
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'wat', extra: { eas: { projectId: 1234 } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
|
||||
+7
-2
@@ -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,6 +1,7 @@
|
||||
import { ExpoConfig, getProjectConfigDescription } from '@expo/config';
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
import semver from 'semver';
|
||||
|
||||
import { ExpoGraphqlClient, createGraphqlClient } from './createGraphqlClient';
|
||||
import { findProjectRootAsync } from './findProjectDirAndVerifyProjectSetupAsync';
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
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';
|
||||
|
||||
@@ -128,29 +128,33 @@ export async function validateOrSetProjectIdAsync({
|
||||
);
|
||||
}
|
||||
|
||||
const actorUsername = getActorUsername(actor);
|
||||
if (!exp.owner && appForProjectId.ownerAccount.name !== actorUsername) {
|
||||
if (actorUsername) {
|
||||
throw new Error(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (${
|
||||
appForProjectId.ownerAccount.name
|
||||
}) does not match the logged in user (${actorUsername}) and the "owner" field is not specified. To ensure all libraries work correctly, "owner": "${
|
||||
appForProjectId.ownerAccount.name
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
// robot caller
|
||||
throw new Error(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (${
|
||||
appForProjectId.ownerAccount.name
|
||||
}) must be specified in "owner" field when using a robot access token. To ensure all libraries work correctly, "owner": "${
|
||||
appForProjectId.ownerAccount.name
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
const sdkVersion = exp.sdkVersion;
|
||||
// SDK 53 and above no longer require owner field in app config
|
||||
if (sdkVersion && semver.satisfies(sdkVersion, '< 53.0.0')) {
|
||||
const actorUsername = getActorUsername(actor);
|
||||
if (!exp.owner && appForProjectId.ownerAccount.name !== actorUsername) {
|
||||
if (actorUsername) {
|
||||
throw new Error(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (${
|
||||
appForProjectId.ownerAccount.name
|
||||
}) does not match the logged in user (${actorUsername}) and the "owner" field is not specified. To ensure all libraries work correctly, "owner": "${
|
||||
appForProjectId.ownerAccount.name
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
// robot caller
|
||||
throw new Error(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (${
|
||||
appForProjectId.ownerAccount.name
|
||||
}) must be specified in "owner" field when using a robot access token. To ensure all libraries work correctly, "owner": "${
|
||||
appForProjectId.ownerAccount.name
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +201,6 @@ export async function validateOrSetProjectIdAsync({
|
||||
{
|
||||
accountName: getAccountNameForEASProjectSync(exp, actor),
|
||||
projectName: exp.slug,
|
||||
privacy: toAppPrivacy(exp.privacy),
|
||||
},
|
||||
{
|
||||
nonInteractive: options.nonInteractive,
|
||||
|
||||
+2
-11
@@ -42,22 +42,13 @@ export async function loadServerSideEnvironmentVariablesAsync({
|
||||
|
||||
if (Object.keys(serverEnvVars).length > 0) {
|
||||
Log.log(
|
||||
`Environment variables loaded from the "${environment.toLowerCase()}" environment on EAS servers: ${Object.keys(
|
||||
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment.toLowerCase()}" environment on EAS: ${Object.keys(
|
||||
serverEnvVars
|
||||
).join(', ')}.`
|
||||
);
|
||||
} else {
|
||||
Log.log(
|
||||
`No readable environment variables found for the "${environment.toLowerCase()}" environment on EAS servers.`
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedEnvVars = environmentVariables.filter(({ name, value }) => name && !value);
|
||||
if (encryptedEnvVars.length > 0) {
|
||||
Log.warn(
|
||||
`Some environment variables defined in the "${environment.toLowerCase()}" environment on EAS servers are of "encrypted" type and cannot be read outside of the EAS servers (including EAS CLI): ${encryptedEnvVars
|
||||
.map(({ name }) => name)
|
||||
.join(', ')}. `
|
||||
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment.toLowerCase()}" environment on EAS.`
|
||||
);
|
||||
}
|
||||
Log.newLine();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { EnvironmentVariableEnvironment, EnvironmentVariableScope } 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
|
||||
@@ -52,18 +52,19 @@ export const EASVariableFormatFlag = {
|
||||
};
|
||||
|
||||
export const EASVariableVisibilityFlag = {
|
||||
visibility: Flags.enum<'plaintext' | 'sensitive' | 'encrypted'>({
|
||||
visibility: Flags.enum<'plaintext' | 'sensitive' | 'secret'>({
|
||||
description: 'Visibility of the variable',
|
||||
options: ['plaintext', 'sensitive', 'encrypted'],
|
||||
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',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -82,11 +83,10 @@ export const EasJsonOnlyFlag = {
|
||||
export const EasUpdateEnvironmentFlag = {
|
||||
environment: Flags.enum<EnvironmentVariableEnvironment | null>({
|
||||
description:
|
||||
'Environment to use the server-side defined EAS environment variables for during command execution.',
|
||||
'Environment to use for the server-side defined EAS environment variables during command execution.',
|
||||
options: mapToLowercase(Object.values(EnvironmentVariableEnvironment)),
|
||||
parse: upperCaseAsync,
|
||||
required: false,
|
||||
hidden: true,
|
||||
default: null,
|
||||
}),
|
||||
};
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile, EasJsonAccessor } from '@expo/eas-json';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
createBuildProfileAsync,
|
||||
doesBuildProfileExistAsync,
|
||||
ensureProjectConfiguredAsync,
|
||||
} from '../../build/configure';
|
||||
import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEnvVarsAsync';
|
||||
import { downloadAndRunAsync, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
|
||||
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { toAppPlatform } from '../../graphql/types/AppPlatform';
|
||||
import Log from '../../log';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { expoCommandAsync } from '../../utils/expoCli';
|
||||
import { createFingerprintAsync } from '../../utils/fingerprintCli';
|
||||
import { ProfileData, getProfilesAsync } from '../../utils/profiles';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
|
||||
const DEFAULT_EAS_BUILD_RUN_PROFILE_NAME = 'development-simulator';
|
||||
|
||||
export default class BuildDev extends EasCommand {
|
||||
static override hidden: true;
|
||||
|
||||
static override description =
|
||||
'run dev client simulator/emulator build with matching fingerprint or create a new one';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
|
||||
char: 'p',
|
||||
options: [Platform.IOS, Platform.ANDROID],
|
||||
}),
|
||||
profile: Flags.string({
|
||||
char: 'e',
|
||||
description: `Name of the build profile from eas.json. It must be a profile allowing to create emulator/simulator internal distribution dev client builds. The "${DEFAULT_EAS_BUILD_RUN_PROFILE_NAME}" build profile will be selected by default.`,
|
||||
helpValue: 'PROFILE_NAME',
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.DynamicProjectConfig,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.Vcs,
|
||||
...this.ContextOptions.Analytics,
|
||||
...this.ContextOptions.ProjectId,
|
||||
};
|
||||
|
||||
protected override async runAsync(): Promise<any> {
|
||||
const { flags } = await this.parse(BuildDev);
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
projectDir,
|
||||
analytics,
|
||||
vcsClient,
|
||||
projectId,
|
||||
} = await this.getContextAsync(BuildDev, {
|
||||
nonInteractive: false,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const platform = await this.selectPlatformAsync(flags.platform);
|
||||
if (process.platform !== 'darwin' && platform === Platform.IOS) {
|
||||
Errors.error('Running iOS builds in simulator is only supported on macOS.', { exit: 1 });
|
||||
}
|
||||
|
||||
await vcsClient.ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
|
||||
await ensureProjectConfiguredAsync({
|
||||
projectDir,
|
||||
nonInteractive: false,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
const buildProfile = await this.ensureValidBuildRunProfileExistsAsync({
|
||||
projectDir,
|
||||
platform,
|
||||
selectedBuildProfileName: flags.profile,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
const workflow = await resolveWorkflowAsync(projectDir, platform, vcsClient);
|
||||
|
||||
const { env } = await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
getProjectConfig: getDynamicPrivateProjectConfigAsync,
|
||||
opts: { env: buildProfile.profile.env },
|
||||
});
|
||||
|
||||
const fingerprint = await createFingerprintAsync(projectDir, {
|
||||
env,
|
||||
workflow,
|
||||
platforms: [platform],
|
||||
});
|
||||
if (!fingerprint) {
|
||||
Errors.error('Failed to calculate fingerprint', { exit: 1 });
|
||||
}
|
||||
Log.log(`✨ Calculated fingerprint hash: ${fingerprint.hash}`);
|
||||
Log.newLine();
|
||||
|
||||
const builds = await this.getBuildsAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
fingerprint,
|
||||
});
|
||||
if (builds.length !== 0) {
|
||||
const build = builds[0];
|
||||
Log.succeed(
|
||||
`🎯 Found successful build with matching fingerprint on EAS servers. Running it...`
|
||||
);
|
||||
|
||||
if (build.artifacts?.applicationArchiveUrl) {
|
||||
await downloadAndRunAsync(build);
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
return;
|
||||
} else {
|
||||
Log.warn('Artifacts for this build expired. New build will be started.');
|
||||
}
|
||||
}
|
||||
|
||||
Log.log('🚀 No successful build with matching fingerprint found. Starting a new build...');
|
||||
|
||||
const previousBuildsForSelectedProfile = await this.getBuildsAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
});
|
||||
if (
|
||||
previousBuildsForSelectedProfile.length > 0 &&
|
||||
previousBuildsForSelectedProfile[0].metrics?.buildDuration
|
||||
) {
|
||||
Log.log(
|
||||
`🕒 Previous build for "${buildProfile.profileName}" profile completed in ${Math.floor(
|
||||
previousBuildsForSelectedProfile[0].metrics.buildDuration / 60000
|
||||
)} minutes.`
|
||||
);
|
||||
}
|
||||
|
||||
await runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
projectDir,
|
||||
flags: {
|
||||
requestedPlatform:
|
||||
platform === Platform.ANDROID ? RequestedPlatform.Android : RequestedPlatform.Ios,
|
||||
nonInteractive: false,
|
||||
freezeCredentials: false,
|
||||
wait: true,
|
||||
clearCache: false,
|
||||
json: false,
|
||||
autoSubmit: false,
|
||||
localBuildOptions: {},
|
||||
repack: false,
|
||||
profile: flags.profile ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
},
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
downloadSimBuildAutoConfirm: true,
|
||||
envOverride: env,
|
||||
});
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
}
|
||||
|
||||
private async selectPlatformAsync(platform?: Platform): Promise<Platform> {
|
||||
if (platform) {
|
||||
return platform;
|
||||
}
|
||||
const { resolvedPlatform } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Select platform',
|
||||
name: 'resolvedPlatform',
|
||||
choices: [
|
||||
{ title: 'Android', value: Platform.ANDROID },
|
||||
{ title: 'iOS', value: Platform.IOS },
|
||||
],
|
||||
});
|
||||
return resolvedPlatform;
|
||||
}
|
||||
|
||||
private async validateBuildRunProfileAsync({
|
||||
platform,
|
||||
buildProfile,
|
||||
buildProfileName,
|
||||
}: {
|
||||
platform: Platform;
|
||||
buildProfile: BuildProfile;
|
||||
buildProfileName: string;
|
||||
}): Promise<void> {
|
||||
if (buildProfile.developmentClient !== true) {
|
||||
Errors.error(
|
||||
`Profile "${buildProfileName}" must specify "developmentClient: true" to create a dev client build. Select a different profile or update the profile in eas.json.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
if (buildProfile.distribution !== 'internal') {
|
||||
Errors.error(
|
||||
`Profile "${buildProfileName}" must specify "distribution: internal" in order to work with eas build:dev command. Select a different profile or update the profile in eas.json.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === Platform.IOS) {
|
||||
const iosProfile = buildProfile as BuildProfile<Platform.IOS>;
|
||||
if (iosProfile.simulator !== true && iosProfile.withoutCredentials !== true) {
|
||||
Errors.error(
|
||||
`Profile "${buildProfileName}" must specify "ios.simulator: true" or "withoutCredentials: true" to create an iOS simulator build. Select a different profile or update the profile in eas.json.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const androidProfile = buildProfile as BuildProfile<Platform.ANDROID>;
|
||||
if (
|
||||
androidProfile.distribution !== 'internal' &&
|
||||
androidProfile.withoutCredentials !== true
|
||||
) {
|
||||
Errors.error(
|
||||
`Profile "${buildProfileName}" must specify "distribution: internal" or "withoutCredentials: true" to create an Android emulator build. Select a different profile or update the profile in eas.json.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureValidBuildRunProfileExistsAsync({
|
||||
projectDir,
|
||||
platform,
|
||||
selectedBuildProfileName,
|
||||
vcsClient,
|
||||
}: {
|
||||
projectDir: string;
|
||||
platform: Platform;
|
||||
selectedBuildProfileName?: string;
|
||||
vcsClient: Client;
|
||||
}): Promise<ProfileData<'build'>> {
|
||||
if (
|
||||
!!selectedBuildProfileName ||
|
||||
(await doesBuildProfileExistAsync({
|
||||
projectDir,
|
||||
profileName: DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
}))
|
||||
) {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const [buildProfile] = await getProfilesAsync({
|
||||
type: 'build',
|
||||
easJsonAccessor,
|
||||
platforms: [platform],
|
||||
profileName: selectedBuildProfileName ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
projectDir,
|
||||
});
|
||||
await this.validateBuildRunProfileAsync({
|
||||
buildProfileName: selectedBuildProfileName ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
platform,
|
||||
buildProfile: buildProfile.profile,
|
||||
});
|
||||
} else {
|
||||
const createBuildProfile = await confirmAsync({
|
||||
message: `We want to go ahead and generate "${DEFAULT_EAS_BUILD_RUN_PROFILE_NAME}" build profile for you, that matches eas build:dev criteria. Do you want to proceed?`,
|
||||
});
|
||||
if (!createBuildProfile) {
|
||||
Errors.error(
|
||||
'Come back later or specify different build compliant with eas build:dev requirements by using "--profile" flag.',
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
await createBuildProfileAsync({
|
||||
projectDir,
|
||||
profileName: DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
profileContents: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
ios: {
|
||||
simulator: true,
|
||||
},
|
||||
environment: 'development',
|
||||
},
|
||||
nonInteractive: false,
|
||||
vcsClient,
|
||||
});
|
||||
}
|
||||
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const [buildProfile] = await getProfilesAsync({
|
||||
type: 'build',
|
||||
easJsonAccessor,
|
||||
platforms: [platform],
|
||||
profileName: selectedBuildProfileName ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
projectDir,
|
||||
});
|
||||
return buildProfile;
|
||||
}
|
||||
|
||||
private async getBuildsAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
fingerprint,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
platform: Platform;
|
||||
fingerprint?: { hash: string };
|
||||
}): Promise<BuildFragment[]> {
|
||||
return await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filter: {
|
||||
platform: toAppPlatform(platform),
|
||||
fingerprintHash: fingerprint?.hash,
|
||||
status: BuildStatus.Finished,
|
||||
simulator: platform === Platform.IOS ? true : undefined,
|
||||
distribution: platform === Platform.ANDROID ? DistributionType.Internal : undefined,
|
||||
developmentClient: true,
|
||||
},
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
}
|
||||
|
||||
private async startDevServerAsync({
|
||||
projectDir,
|
||||
platform,
|
||||
}: {
|
||||
projectDir: string;
|
||||
platform: Platform;
|
||||
}): Promise<void> {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`Starting development server: ${chalk.dim(
|
||||
`npx expo start --dev-client ${platform === Platform.IOS ? '--ios' : '--android'}`
|
||||
)}`
|
||||
);
|
||||
await expoCommandAsync(projectDir, [
|
||||
'start',
|
||||
'--dev-client',
|
||||
platform === Platform.IOS ? '--ios' : '--android',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -78,10 +78,12 @@ export default class Build extends EasCommand {
|
||||
description: 'Clear cache before the build',
|
||||
}),
|
||||
'auto-submit': Flags.boolean({
|
||||
char: 's',
|
||||
default: false,
|
||||
description:
|
||||
'Submit on build complete using the submit profile with the same name as the build profile',
|
||||
exclusive: ['auto-submit-with-profile'],
|
||||
aliases: ['submit'],
|
||||
}),
|
||||
'auto-submit-with-profile': Flags.string({
|
||||
description: 'Submit on build complete using the submit profile with provided name',
|
||||
@@ -158,15 +160,15 @@ export default class Build extends EasCommand {
|
||||
|
||||
const flagsWithPlatform = await this.ensurePlatformSelectedAsync(flags);
|
||||
|
||||
await runBuildAndSubmitAsync(
|
||||
await runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
projectDir,
|
||||
flagsWithPlatform,
|
||||
flags: flagsWithPlatform,
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync
|
||||
);
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
});
|
||||
}
|
||||
|
||||
private sanitizeFlags(
|
||||
|
||||
@@ -99,12 +99,12 @@ export default class BuildInspect extends EasCommand {
|
||||
await this.copyToOutputDirAsync(tmpWorkingdir, outputDirectory);
|
||||
} else {
|
||||
try {
|
||||
await runBuildAndSubmitAsync(
|
||||
await runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
projectDir,
|
||||
{
|
||||
flags: {
|
||||
nonInteractive: false,
|
||||
freezeCredentials: false,
|
||||
wait: true,
|
||||
@@ -124,8 +124,8 @@ export default class BuildInspect extends EasCommand {
|
||||
repack: false,
|
||||
},
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync
|
||||
);
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
});
|
||||
if (!flags.verbose) {
|
||||
Log.log(chalk.green('Build successful'));
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ import { runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
|
||||
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';
|
||||
import GitClient from '../../vcs/clients/git';
|
||||
|
||||
/**
|
||||
* This command will be run on the EAS Build workers, when building
|
||||
@@ -64,18 +63,24 @@ export default class BuildInternal extends EasCommand {
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(BuildInternal, {
|
||||
nonInteractive: true,
|
||||
vcsClientOverride: process.env.EAS_NO_VCS ? new NoVcsClient() : new GitNoCommitClient(),
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
if (vcsClient instanceof GitClient) {
|
||||
// `build:internal` is run on EAS workers and the repo may have been changed
|
||||
// by pre-install hooks or other scripts. We don't want to require committing changes
|
||||
// to continue the build.
|
||||
vcsClient.requireCommit = false;
|
||||
}
|
||||
|
||||
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
|
||||
|
||||
await runBuildAndSubmitAsync(
|
||||
await runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
projectDir,
|
||||
{
|
||||
flags: {
|
||||
requestedPlatform: flags.platform as RequestedPlatform,
|
||||
profile: flags.profile,
|
||||
nonInteractive: true,
|
||||
@@ -91,7 +96,7 @@ export default class BuildInternal extends EasCommand {
|
||||
repack: false,
|
||||
},
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync
|
||||
);
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import assert from 'assert';
|
||||
import { pathExists } from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { getLatestBuildAsync, listAndSelectBuildOnAppAsync } from '../../build/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
@@ -17,13 +16,12 @@ import Log from '../../log';
|
||||
import { appPlatformDisplayNames } from '../../platform';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { RunArchiveFlags, runAsync } from '../../run/run';
|
||||
import { RunArchiveFlags, getEasBuildRunCachedAppPath, runAsync } from '../../run/run';
|
||||
import { isRunnableOnSimulatorOrEmulator } from '../../run/utils';
|
||||
import {
|
||||
downloadAndMaybeExtractAppAsync,
|
||||
extractAppFromLocalArchiveAsync,
|
||||
} from '../../utils/download';
|
||||
import { getEasBuildRunCacheDirectoryPath } from '../../utils/paths';
|
||||
|
||||
interface RawRunFlags {
|
||||
latest?: boolean;
|
||||
@@ -245,17 +243,6 @@ async function maybeGetBuildAsync(
|
||||
}
|
||||
}
|
||||
|
||||
function getEasBuildRunCachedAppPath(
|
||||
projectId: string,
|
||||
buildId: string,
|
||||
platform: AppPlatform
|
||||
): string {
|
||||
return path.join(
|
||||
getEasBuildRunCacheDirectoryPath(),
|
||||
`${projectId}_${buildId}.${platform === AppPlatform.Ios ? 'app' : 'apk'}`
|
||||
);
|
||||
}
|
||||
|
||||
async function getPathToSimulatorBuildAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
|
||||
@@ -120,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;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default class ChannelDelete extends EasCommand {
|
||||
|
||||
let channelId, channelName;
|
||||
if (nameArg) {
|
||||
const { id, name } = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
const { id, name } = await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: nameArg,
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ export default class ChannelEdit extends EasCommand {
|
||||
}
|
||||
|
||||
const existingChannel = args.name
|
||||
? await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: args.name,
|
||||
})
|
||||
|
||||
@@ -86,7 +86,7 @@ export default class ChannelPause extends EasCommand {
|
||||
}
|
||||
|
||||
const existingChannel = args.name
|
||||
? await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: args.name,
|
||||
})
|
||||
|
||||
@@ -86,7 +86,7 @@ export default class ChannelResume extends EasCommand {
|
||||
}
|
||||
|
||||
const existingChannel = args.name
|
||||
? await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: args.name,
|
||||
})
|
||||
|
||||
+4
-7
@@ -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,7 +70,7 @@ 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,
|
||||
+37
-17
@@ -43,16 +43,18 @@ 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 {
|
||||
@@ -63,13 +65,10 @@ interface DeployInProgressParams {
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -90,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,
|
||||
};
|
||||
|
||||
@@ -111,7 +111,7 @@ 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,
|
||||
@@ -250,7 +250,7 @@ export default class WorkerDeploy extends EasCommand {
|
||||
let progress = ora('Preparing project').start();
|
||||
|
||||
try {
|
||||
const manifest = await WorkerAssets.createManifestAsync(
|
||||
const manifestResult = await WorkerAssets.createManifestAsync(
|
||||
{
|
||||
environment: flags.environment,
|
||||
projectDir,
|
||||
@@ -258,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,
|
||||
@@ -382,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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -411,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"`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,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}}`);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+21
-19
@@ -19,14 +19,14 @@ import {
|
||||
promptVariableTypeAsync,
|
||||
promptVariableValueAsync,
|
||||
} from '../../../utils/prompts';
|
||||
import EnvironmentVariableCreate from '../create';
|
||||
import EnvCreate from '../create';
|
||||
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../utils/prompts');
|
||||
|
||||
describe(EnvironmentVariableCreate, () => {
|
||||
describe(EnvCreate, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const variableId = 'testId';
|
||||
@@ -74,7 +74,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
|
||||
describe('in interactive mode', () => {
|
||||
it('creates a project variable', async () => {
|
||||
const command = new EnvironmentVariableCreate(
|
||||
const command = new EnvCreate(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
@@ -83,7 +83,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'encrypted',
|
||||
'secret',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
@@ -110,7 +110,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
});
|
||||
|
||||
it('updates an existing variable in the same environment', async () => {
|
||||
const command = new EnvironmentVariableCreate(
|
||||
const command = new EnvCreate(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
@@ -119,7 +119,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'encrypted',
|
||||
'secret',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
@@ -171,8 +171,8 @@ describe(EnvironmentVariableCreate, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a shared variable', async () => {
|
||||
const command = new EnvironmentVariableCreate(
|
||||
it('creates an account-wide variable', async () => {
|
||||
const command = new EnvCreate(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
@@ -181,7 +181,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
'--environment',
|
||||
'production',
|
||||
'--scope',
|
||||
'shared',
|
||||
'account',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
@@ -199,6 +199,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
isGlobal: true,
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
@@ -207,8 +208,8 @@ describe(EnvironmentVariableCreate, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if a shared variable already exists', async () => {
|
||||
const command = new EnvironmentVariableCreate(
|
||||
it('throws if an account-wide variable already exists', async () => {
|
||||
const command = new EnvCreate(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
@@ -217,7 +218,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
'--environment',
|
||||
'production',
|
||||
'--scope',
|
||||
'shared',
|
||||
'account',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
@@ -242,8 +243,8 @@ describe(EnvironmentVariableCreate, () => {
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('updates if a shared variable already exists and --force flag is set', async () => {
|
||||
const command = new EnvironmentVariableCreate(
|
||||
it('updates if an account-wide variable already exists and --force flag is set', async () => {
|
||||
const command = new EnvCreate(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
@@ -253,7 +254,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
'production',
|
||||
'--force',
|
||||
'--scope',
|
||||
'shared',
|
||||
'account',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
@@ -288,8 +289,8 @@ describe(EnvironmentVariableCreate, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a shared variable and links it', async () => {
|
||||
const command = new EnvironmentVariableCreate(
|
||||
it('creates an account-wide variable and links it', async () => {
|
||||
const command = new EnvCreate(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
@@ -300,7 +301,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
'--environment',
|
||||
'development',
|
||||
'--scope',
|
||||
'shared',
|
||||
'account',
|
||||
'--link',
|
||||
],
|
||||
mockConfig
|
||||
@@ -324,6 +325,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
EnvironmentVariableEnvironment.Development,
|
||||
],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
isGlobal: true,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testAccountId
|
||||
@@ -343,7 +345,7 @@ describe(EnvironmentVariableCreate, () => {
|
||||
});
|
||||
|
||||
it('prompts for missing arguments', async () => {
|
||||
const command = new EnvironmentVariableCreate([], mockConfig);
|
||||
const command = new EnvCreate([], mockConfig);
|
||||
|
||||
jest.mocked(promptVariableNameAsync).mockImplementation(async () => 'VarName');
|
||||
jest.mocked(promptVariableValueAsync).mockImplementation(async () => 'VarValue');
|
||||
+7
-10
@@ -8,14 +8,14 @@ import { EnvironmentVariableMutation } from '../../../graphql/mutations/Environm
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../../prompts';
|
||||
import EnvironmentVariableDelete from '../delete';
|
||||
import EnvDelete from '../delete';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe(EnvironmentVariableDelete, () => {
|
||||
describe(EnvDelete, () => {
|
||||
const projectId = 'test-project-id';
|
||||
const variableId = '1';
|
||||
const graphqlClient = {};
|
||||
@@ -40,7 +40,7 @@ describe(EnvironmentVariableDelete, () => {
|
||||
];
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableDelete(
|
||||
const command = new EnvDelete(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
@@ -66,7 +66,7 @@ describe(EnvironmentVariableDelete, () => {
|
||||
(promptAsync as jest.Mock).mockResolvedValue({ variable: mockVariables[0] });
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvironmentVariableDelete([], mockConfig);
|
||||
const command = new EnvDelete([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
@@ -82,10 +82,7 @@ describe(EnvironmentVariableDelete, () => {
|
||||
];
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableDelete(
|
||||
['--variable-name', 'NON_EXISTENT_VARIABLE'],
|
||||
mockConfig
|
||||
);
|
||||
const command = new EnvDelete(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
@@ -99,7 +96,7 @@ describe(EnvironmentVariableDelete, () => {
|
||||
];
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableDelete(['--variable-name', 'TEST_VARIABLE'], mockConfig);
|
||||
const command = new EnvDelete(['--variable-name', 'TEST_VARIABLE'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
@@ -116,7 +113,7 @@ describe(EnvironmentVariableDelete, () => {
|
||||
(promptAsync as jest.Mock).mockResolvedValue({ variable: mockVariables[0] });
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
const command = new EnvironmentVariableDelete(['--non-interactive'], mockConfig);
|
||||
const command = new EnvDelete(['--non-interactive'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
+5
-5
@@ -12,14 +12,14 @@ import {
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { promptVariableEnvironmentAsync, promptVariableNameAsync } from '../../../utils/prompts';
|
||||
import EnvironmentVariableGet from '../get';
|
||||
import EnvGet from '../get';
|
||||
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../utils/prompts');
|
||||
|
||||
describe(EnvironmentVariableGet, () => {
|
||||
describe(EnvGet, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const mockVariables: EnvironmentVariableFragment[] = [
|
||||
@@ -42,7 +42,7 @@ describe(EnvironmentVariableGet, () => {
|
||||
.mockResolvedValueOnce(mockVariables);
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
|
||||
const command = new EnvironmentVariableGet(['--variable-name', 'TEST_VAR_1'], mockConfig);
|
||||
const command = new EnvGet(['--variable-name', 'TEST_VAR_1'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
@@ -68,7 +68,7 @@ describe(EnvironmentVariableGet, () => {
|
||||
const errorMessage =
|
||||
"Variable name is required. Run the command with '--variable-name VARIABLE_NAME' flag";
|
||||
|
||||
const command = new EnvironmentVariableGet([], mockConfig);
|
||||
const command = new EnvGet([], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
@@ -85,7 +85,7 @@ describe(EnvironmentVariableGet, () => {
|
||||
.mockResolvedValueOnce(EnvironmentVariableEnvironment.Production);
|
||||
jest.mocked(promptVariableNameAsync).mockResolvedValueOnce('TEST_VAR_1');
|
||||
|
||||
const command = new EnvironmentVariableGet([], mockConfig);
|
||||
const command = new EnvGet([], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
+13
-19
@@ -11,7 +11,7 @@ import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
|
||||
import EnvironmentVariableLink from '../link';
|
||||
import EnvLink from '../link';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
@@ -19,7 +19,7 @@ jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe(EnvironmentVariableLink, () => {
|
||||
describe(EnvLink, () => {
|
||||
const projectId = 'test-project-id';
|
||||
const variableId = '1';
|
||||
const graphqlClient = {};
|
||||
@@ -39,7 +39,7 @@ describe(EnvironmentVariableLink, () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
});
|
||||
|
||||
it('links a shared variable to the current project in non-interactive mode', async () => {
|
||||
it('links an account-wide variable to the current project in non-interactive mode', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
@@ -53,7 +53,7 @@ describe(EnvironmentVariableLink, () => {
|
||||
mockVariables[0]
|
||||
);
|
||||
|
||||
const command = new EnvironmentVariableLink(
|
||||
const command = new EnvLink(
|
||||
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
@@ -75,7 +75,7 @@ describe(EnvironmentVariableLink, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('links a shared variable to the current project to a specified environment', async () => {
|
||||
it('links an account-wide variable to the current project to a specified environment', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
@@ -89,7 +89,7 @@ describe(EnvironmentVariableLink, () => {
|
||||
mockVariables[0]
|
||||
);
|
||||
|
||||
const command = new EnvironmentVariableLink(
|
||||
const command = new EnvLink(
|
||||
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
@@ -137,7 +137,7 @@ describe(EnvironmentVariableLink, () => {
|
||||
);
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvironmentVariableLink(
|
||||
const command = new EnvLink(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
@@ -186,7 +186,7 @@ describe(EnvironmentVariableLink, () => {
|
||||
(promptAsync as jest.Mock).mockResolvedValue({ environments: mockVariables[0].environments });
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvironmentVariableLink([], mockConfig);
|
||||
const command = new EnvLink([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
@@ -207,15 +207,12 @@ describe(EnvironmentVariableLink, () => {
|
||||
const mockVariables: never[] = [];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableLink(
|
||||
['--variable-name', 'NON_EXISTENT_VARIABLE'],
|
||||
mockConfig
|
||||
);
|
||||
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(
|
||||
"Shared variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -239,7 +236,7 @@ describe(EnvironmentVariableLink, () => {
|
||||
});
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvironmentVariableLink([], mockConfig);
|
||||
const command = new EnvLink([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
@@ -263,15 +260,12 @@ describe(EnvironmentVariableLink, () => {
|
||||
const mockVariables: never[] = [];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableLink(
|
||||
['--variable-name', 'NON_EXISTENT_VARIABLE'],
|
||||
mockConfig
|
||||
);
|
||||
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(
|
||||
"Shared variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
);
|
||||
});
|
||||
});
|
||||
+9
-12
@@ -13,7 +13,7 @@ import {
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import EnvironmentVariableList from '../list';
|
||||
import EnvList from '../list';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
@@ -44,7 +44,7 @@ const mockVariables: EnvironmentVariableFragment[] = [
|
||||
},
|
||||
];
|
||||
|
||||
describe(EnvironmentVariableList, () => {
|
||||
describe(EnvList, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = {} as unknown as Config;
|
||||
|
||||
@@ -56,7 +56,7 @@ describe(EnvironmentVariableList, () => {
|
||||
it('lists project environment variables successfully', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableList([], mockConfig);
|
||||
const command = new EnvList([], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
@@ -77,7 +77,7 @@ describe(EnvironmentVariableList, () => {
|
||||
it('lists project environment variables in specified environments', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableList(['--environment', 'production'], mockConfig);
|
||||
const command = new EnvList(['--environment', 'production'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
@@ -100,7 +100,7 @@ describe(EnvironmentVariableList, () => {
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
|
||||
.mockResolvedValueOnce(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableList(['--include-sensitive'], mockConfig);
|
||||
const command = new EnvList(['--include-sensitive'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
@@ -121,10 +121,10 @@ describe(EnvironmentVariableList, () => {
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
|
||||
});
|
||||
|
||||
it('lists shared environment variables successfully', async () => {
|
||||
it('lists account-wide environment variables successfully', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.sharedAsync).mockResolvedValueOnce(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableList(['--scope', 'shared'], mockConfig);
|
||||
const command = new EnvList(['--scope', 'account'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
@@ -142,15 +142,12 @@ describe(EnvironmentVariableList, () => {
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
|
||||
});
|
||||
|
||||
it('lists shared environment variables including sensitive values', async () => {
|
||||
it('lists account-wide environment variables including sensitive values', async () => {
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.sharedWithSensitiveAsync)
|
||||
.mockResolvedValueOnce(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableList(
|
||||
['--include-sensitive', '--scope', 'shared'],
|
||||
mockConfig
|
||||
);
|
||||
const command = new EnvList(['--include-sensitive', '--scope', 'account'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
+12
-18
@@ -11,7 +11,7 @@ import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
|
||||
import EnvironmentVariableUnlink from '../unlink';
|
||||
import EnvUnlink from '../unlink';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
@@ -19,7 +19,7 @@ jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe(EnvironmentVariableUnlink, () => {
|
||||
describe(EnvUnlink, () => {
|
||||
const projectId = 'test-project-id';
|
||||
const variableId = '1';
|
||||
const graphqlClient = {};
|
||||
@@ -39,7 +39,7 @@ describe(EnvironmentVariableUnlink, () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
});
|
||||
|
||||
it('unlinks a shared variable from the current project in non-interactive mode', async () => {
|
||||
it('unlinks an account-wide variable from the current project in non-interactive mode', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
@@ -54,7 +54,7 @@ describe(EnvironmentVariableUnlink, () => {
|
||||
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
|
||||
).mockResolvedValue(mockVariables[0]);
|
||||
|
||||
const command = new EnvironmentVariableUnlink(
|
||||
const command = new EnvUnlink(
|
||||
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
@@ -76,7 +76,7 @@ describe(EnvironmentVariableUnlink, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('unlinks a shared variable from the current project in a specified environment', async () => {
|
||||
it('unlinks an account-wide variable from the current project in a specified environment', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
@@ -91,7 +91,7 @@ describe(EnvironmentVariableUnlink, () => {
|
||||
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
|
||||
).mockResolvedValue(mockVariables[0]);
|
||||
|
||||
const command = new EnvironmentVariableUnlink(
|
||||
const command = new EnvUnlink(
|
||||
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
@@ -141,7 +141,7 @@ describe(EnvironmentVariableUnlink, () => {
|
||||
});
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvironmentVariableUnlink([], mockConfig);
|
||||
const command = new EnvUnlink([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
@@ -162,15 +162,12 @@ describe(EnvironmentVariableUnlink, () => {
|
||||
const mockVariables: never[] = [];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableUnlink(
|
||||
['--variable-name', 'NON_EXISTENT_VARIABLE'],
|
||||
mockConfig
|
||||
);
|
||||
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(
|
||||
"Shared variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -194,7 +191,7 @@ describe(EnvironmentVariableUnlink, () => {
|
||||
});
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvironmentVariableUnlink([], mockConfig);
|
||||
const command = new EnvUnlink([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
@@ -218,15 +215,12 @@ describe(EnvironmentVariableUnlink, () => {
|
||||
const mockVariables: never[] = [];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableUnlink(
|
||||
['--variable-name', 'NON_EXISTENT_VARIABLE'],
|
||||
mockConfig
|
||||
);
|
||||
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(
|
||||
"Shared variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
);
|
||||
});
|
||||
});
|
||||
+8
-11
@@ -12,7 +12,7 @@ import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../../prompts';
|
||||
import EnvironmentVariableUpdate from '../update';
|
||||
import EnvUpdate from '../update';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
@@ -20,7 +20,7 @@ jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe(EnvironmentVariableUpdate, () => {
|
||||
describe(EnvUpdate, () => {
|
||||
const projectId = 'test-project-id';
|
||||
const variableId = '1';
|
||||
const graphqlClient = {};
|
||||
@@ -47,7 +47,7 @@ describe(EnvironmentVariableUpdate, () => {
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
|
||||
|
||||
const command = new EnvironmentVariableUpdate(
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
@@ -81,7 +81,7 @@ describe(EnvironmentVariableUpdate, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('updates a shared variable by selected name in non-interactive mode', async () => {
|
||||
it('updates an account-wide variable by selected name in non-interactive mode', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
@@ -93,7 +93,7 @@ describe(EnvironmentVariableUpdate, () => {
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
|
||||
|
||||
const command = new EnvironmentVariableUpdate(
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
@@ -107,7 +107,7 @@ describe(EnvironmentVariableUpdate, () => {
|
||||
'--environment',
|
||||
'production',
|
||||
'--scope',
|
||||
'shared',
|
||||
'account',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
@@ -135,7 +135,7 @@ describe(EnvironmentVariableUpdate, () => {
|
||||
(promptAsync as jest.Mock).mockResolvedValue({ name: mockVariables[0].name });
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvironmentVariableUpdate([], mockConfig);
|
||||
const command = new EnvUpdate([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
@@ -154,10 +154,7 @@ describe(EnvironmentVariableUpdate, () => {
|
||||
const mockVariables: never[] = [];
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvironmentVariableUpdate(
|
||||
['--variable-name', 'NON_EXISTENT_VARIABLE'],
|
||||
mockConfig
|
||||
);
|
||||
const command = new EnvUpdate(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
+3
@@ -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 [1m--variable-name VARIABLE_NAME[22m flag to fix the issue"`;
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`EnvironmentVariableDelete 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 [1m--variable-name VARIABLE_NAME[22m flag to fix the issue"`;
|
||||
+44
-36
@@ -5,9 +5,10 @@ import path from 'path';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
EASMultiEnvironmentFlag,
|
||||
EASNonInteractiveFlag,
|
||||
EASVariableScopeFlag,
|
||||
EASVariableVisibilityFlag,
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
@@ -34,23 +35,32 @@ import {
|
||||
} from '../../utils/prompts';
|
||||
import { isEnvironment, performForEnvironmentsAsync } from '../../utils/variableUtils';
|
||||
|
||||
type CreateFlags = {
|
||||
interface RawCreateFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
link?: boolean;
|
||||
force?: boolean;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'encrypted';
|
||||
scope?: EnvironmentVariableScope;
|
||||
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 = [
|
||||
{
|
||||
@@ -69,7 +79,8 @@ 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',
|
||||
@@ -80,7 +91,7 @@ export default class EnvironmentVariableCreate extends EasCommand {
|
||||
options: ['string', 'file'],
|
||||
}),
|
||||
...EASVariableVisibilityFlag,
|
||||
...EASVariableScopeFlag,
|
||||
...EASEnvironmentVariableScopeFlag,
|
||||
...EASMultiEnvironmentFlag,
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
@@ -92,9 +103,9 @@ export default class EnvironmentVariableCreate extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvironmentVariableCreate);
|
||||
const { args, flags } = await this.parse(EnvCreate);
|
||||
|
||||
const validatedFlags = this.validateFlags(flags);
|
||||
const validatedFlags = this.sanitizeFlags(flags);
|
||||
|
||||
const {
|
||||
name,
|
||||
@@ -112,7 +123,7 @@ export default class EnvironmentVariableCreate extends EasCommand {
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvironmentVariableCreate, {
|
||||
} = await this.getContextAsync(EnvCreate, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
@@ -147,12 +158,12 @@ export default class EnvironmentVariableCreate extends EasCommand {
|
||||
await this.promptForOverwriteAsync({
|
||||
nonInteractive,
|
||||
force,
|
||||
message: `Shared variable with ${name} name already exists on this account.`,
|
||||
message: `Account-wide variable with ${name} name already exists on this account.`,
|
||||
suggestion: 'Do you want to unlink it first?',
|
||||
});
|
||||
|
||||
Log.withTick(
|
||||
`Unlinking shared variable ${chalk.bold(name)} on project ${chalk.bold(
|
||||
`Unlinking account-wide variable ${chalk.bold(name)} on project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)}.`
|
||||
);
|
||||
@@ -216,8 +227,8 @@ export default class EnvironmentVariableCreate extends EasCommand {
|
||||
overwrite = true;
|
||||
} else {
|
||||
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.`
|
||||
`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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -239,6 +250,7 @@ export default class EnvironmentVariableCreate extends EasCommand {
|
||||
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
|
||||
@@ -256,7 +268,7 @@ export default class EnvironmentVariableCreate extends EasCommand {
|
||||
|
||||
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
|
||||
)}.`
|
||||
);
|
||||
@@ -271,7 +283,9 @@ export default class EnvironmentVariableCreate extends EasCommand {
|
||||
});
|
||||
|
||||
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
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -390,7 +404,6 @@ export default class EnvironmentVariableCreate extends EasCommand {
|
||||
visibility: newVisibility,
|
||||
link: rest.link ?? false,
|
||||
force: rest.force ?? false,
|
||||
scope: rest.scope ?? EnvironmentVariableScope.Project,
|
||||
'non-interactive': nonInteractive,
|
||||
type: newType,
|
||||
fileName,
|
||||
@@ -398,24 +411,19 @@ export default class EnvironmentVariableCreate extends EasCommand {
|
||||
};
|
||||
}
|
||||
|
||||
private validateFlags(flags: CreateFlags & { type?: string; visibility?: string }): CreateFlags {
|
||||
if (flags.scope !== EnvironmentVariableScope.Shared && flags.link) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+26
-15
@@ -4,8 +4,9 @@ import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
EASNonInteractiveFlag,
|
||||
EASVariableScopeFlag,
|
||||
EasEnvironmentFlagParameters,
|
||||
} from '../../commandUtils/flags';
|
||||
import { EnvironmentVariableEnvironment, EnvironmentVariableScope } from '../../graphql/generated';
|
||||
@@ -15,17 +16,22 @@ import Log from '../../log';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../prompts';
|
||||
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
type DeleteFlags = {
|
||||
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 = {
|
||||
'variable-name': Flags.string({
|
||||
@@ -35,7 +41,7 @@ export default class EnvironmentVariableDelete extends EasCommand {
|
||||
...EasEnvironmentFlagParameters,
|
||||
description: 'Current environment of the variable to delete',
|
||||
}),
|
||||
...EASVariableScopeFlag,
|
||||
...EASEnvironmentVariableScopeFlag,
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
@@ -54,17 +60,17 @@ export default class EnvironmentVariableDelete extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvironmentVariableDelete);
|
||||
const { args, flags } = await this.parse(EnvDelete);
|
||||
const {
|
||||
'variable-name': name,
|
||||
'variable-environment': environment,
|
||||
'non-interactive': nonInteractive,
|
||||
scope,
|
||||
} = this.validateInputs(flags, args);
|
||||
} = this.sanitizeInputs(flags, args);
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvironmentVariableDelete, {
|
||||
} = await this.getContextAsync(EnvDelete, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
@@ -139,8 +145,8 @@ export default class EnvironmentVariableDelete extends EasCommand {
|
||||
Log.withTick(`️Deleted variable ${selectedVariable.name}".`);
|
||||
}
|
||||
|
||||
private validateInputs(
|
||||
flags: DeleteFlags,
|
||||
private sanitizeInputs(
|
||||
flags: RawDeleteFlags,
|
||||
{ environment }: { environment?: string }
|
||||
): DeleteFlags {
|
||||
if (flags['non-interactive']) {
|
||||
@@ -153,6 +159,11 @@ export default class EnvironmentVariableDelete extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const scope =
|
||||
flags.scope === 'account'
|
||||
? EnvironmentVariableScope.Shared
|
||||
: EnvironmentVariableScope.Project;
|
||||
|
||||
if (environment) {
|
||||
environment = environment.toUpperCase();
|
||||
|
||||
@@ -161,9 +172,9 @@ export default class EnvironmentVariableDelete extends EasCommand {
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
);
|
||||
}
|
||||
return { ...flags, 'variable-environment': environment };
|
||||
return { ...flags, 'variable-environment': environment, scope };
|
||||
}
|
||||
|
||||
return flags;
|
||||
return { ...flags, scope };
|
||||
}
|
||||
}
|
||||
+71
-31
@@ -28,9 +28,7 @@ interface RawFlags {
|
||||
|
||||
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.ProjectId,
|
||||
@@ -55,9 +53,10 @@ export default class EnvExec extends EasCommand {
|
||||
},
|
||||
];
|
||||
|
||||
private isNonInteractive: boolean = false;
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(EnvExec);
|
||||
|
||||
const parsedFlags = this.sanitizeFlagsAndArgs(flags, args);
|
||||
|
||||
const {
|
||||
@@ -67,6 +66,8 @@ export default class EnvExec extends EasCommand {
|
||||
nonInteractive: parsedFlags.nonInteractive,
|
||||
});
|
||||
|
||||
this.isNonInteractive = parsedFlags.nonInteractive;
|
||||
|
||||
const environment =
|
||||
parsedFlags.environment ??
|
||||
(await promptVariableEnvironmentAsync({ nonInteractive: parsedFlags.nonInteractive }));
|
||||
@@ -74,12 +75,20 @@ export default class EnvExec extends EasCommand {
|
||||
graphqlClient,
|
||||
projectId,
|
||||
environment,
|
||||
nonInteractive: parsedFlags.nonInteractive,
|
||||
});
|
||||
|
||||
await this.runCommandWithEnvVarsAsync({
|
||||
command: parsedFlags.command,
|
||||
environmentVariables,
|
||||
});
|
||||
if (parsedFlags.nonInteractive) {
|
||||
await this.runCommandNonInteractiveWithEnvVarsAsync({
|
||||
command: parsedFlags.command,
|
||||
environmentVariables,
|
||||
});
|
||||
} else {
|
||||
await this.runCommandWithEnvVarsAsync({
|
||||
command: parsedFlags.command,
|
||||
environmentVariables,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeFlagsAndArgs(
|
||||
@@ -98,13 +107,48 @@ export default class EnvExec extends EasCommand {
|
||||
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: rawFlags['non-interactive'],
|
||||
environment,
|
||||
command: bash_command,
|
||||
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,
|
||||
@@ -113,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,
|
||||
@@ -149,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, {
|
||||
@@ -160,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) {
|
||||
|
||||
+26
-14
@@ -4,9 +4,10 @@ import chalk from 'chalk';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
EASNonInteractiveFlag,
|
||||
EASVariableFormatFlag,
|
||||
EASVariableScopeFlag,
|
||||
EasEnvironmentFlagParameters,
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
@@ -20,18 +21,24 @@ import Log from '../../log';
|
||||
import { promptVariableEnvironmentAsync, promptVariableNameAsync } from '../../utils/prompts';
|
||||
import { formatVariable, formatVariableValue, isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
type GetFlags = {
|
||||
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.ProjectId,
|
||||
@@ -56,12 +63,12 @@ export default class EnvironmentVariableGet extends EasCommand {
|
||||
description: 'Current environment of the variable',
|
||||
}),
|
||||
...EASVariableFormatFlag,
|
||||
...EASVariableScopeFlag,
|
||||
...EASEnvironmentVariableScopeFlag,
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvironmentVariableGet);
|
||||
const { args, flags } = await this.parse(EnvGet);
|
||||
|
||||
let {
|
||||
'variable-environment': environment,
|
||||
@@ -69,12 +76,12 @@ export default class EnvironmentVariableGet extends EasCommand {
|
||||
'non-interactive': nonInteractive,
|
||||
format,
|
||||
scope,
|
||||
} = this.validateInputs(flags, args);
|
||||
} = this.sanitizeInputs(flags, args);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvironmentVariableGet, {
|
||||
} = await this.getContextAsync(EnvGet, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
@@ -126,7 +133,7 @@ export default class EnvironmentVariableGet extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private validateInputs(flags: GetFlags, { environment }: { environment?: string }): GetFlags {
|
||||
private sanitizeInputs(flags: RawGetFlags, { environment }: { environment?: string }): GetFlags {
|
||||
if (flags['non-interactive']) {
|
||||
if (!flags['variable-name']) {
|
||||
throw new Error('Variable name is required. Run the command with --variable-name flag.');
|
||||
@@ -144,6 +151,10 @@ export default class EnvironmentVariableGet extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const scope =
|
||||
flags.scope === 'account'
|
||||
? EnvironmentVariableScope.Shared
|
||||
: EnvironmentVariableScope.Project;
|
||||
if (environment) {
|
||||
environment = environment.toUpperCase();
|
||||
if (!isEnvironment(environment)) {
|
||||
@@ -152,11 +163,12 @@ export default class EnvironmentVariableGet extends EasCommand {
|
||||
);
|
||||
}
|
||||
return {
|
||||
'variable-environment': environment,
|
||||
...flags,
|
||||
'variable-environment': environment,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
return flags;
|
||||
return { ...flags, scope };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-10
@@ -23,9 +23,10 @@ type LinkFlags = {
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
};
|
||||
|
||||
export default class EnvironmentVariableLink extends EasCommand {
|
||||
static override description = 'link a shared environment variable to the current project';
|
||||
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 = {
|
||||
@@ -55,7 +56,7 @@ export default class EnvironmentVariableLink extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvironmentVariableLink);
|
||||
const { args, flags } = await this.parse(EnvLink);
|
||||
|
||||
let {
|
||||
'variable-name': name,
|
||||
@@ -67,7 +68,7 @@ export default class EnvironmentVariableLink extends EasCommand {
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvironmentVariableLink, {
|
||||
} = await this.getContextAsync(EnvLink, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
@@ -87,7 +88,7 @@ export default class EnvironmentVariableLink extends EasCommand {
|
||||
);
|
||||
}
|
||||
selectedVariable = await selectAsync(
|
||||
'Select shared variable',
|
||||
'Select account-wide variable',
|
||||
variables.map(variable => ({
|
||||
title: formatVariableName(variable),
|
||||
value: variable,
|
||||
@@ -96,7 +97,7 @@ export default class EnvironmentVariableLink extends EasCommand {
|
||||
}
|
||||
|
||||
if (!selectedVariable) {
|
||||
throw new Error(`Shared variable ${name} doesn't exist`);
|
||||
throw new Error(`Account-wide variable ${name} doesn't exist`);
|
||||
}
|
||||
|
||||
let explicitSelect = false;
|
||||
@@ -172,7 +173,7 @@ export default class EnvironmentVariableLink extends EasCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
private validateInputs(flags: LinkFlags, { environment }: Record<string, string>): LinkFlags {
|
||||
private validateInputs(flags: LinkFlags, { environment }: { environment?: string }): LinkFlags {
|
||||
environment = environment?.toUpperCase();
|
||||
|
||||
if (environment && !isEnvironment(environment)) {
|
||||
@@ -196,9 +197,7 @@ export default class EnvironmentVariableLink extends EasCommand {
|
||||
}
|
||||
|
||||
return {
|
||||
'variable-name': flags['variable-name'],
|
||||
'variable-environment': flags['variable-environment'],
|
||||
'non-interactive': flags['non-interactive'],
|
||||
...flags,
|
||||
environment: environments,
|
||||
};
|
||||
}
|
||||
|
||||
+28
-16
@@ -4,9 +4,10 @@ import chalk from 'chalk';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
EASMultiEnvironmentFlag,
|
||||
EASVariableFormatFlag,
|
||||
EASVariableScopeFlag,
|
||||
} from '../../commandUtils/flags';
|
||||
import { EnvironmentVariableEnvironment, EnvironmentVariableScope } from '../../graphql/generated';
|
||||
import {
|
||||
@@ -66,19 +67,26 @@ async function getVariablesForScopeAsync(
|
||||
});
|
||||
}
|
||||
|
||||
type ListFlags = {
|
||||
scope: EnvironmentVariableScope;
|
||||
interface RawListFlags {
|
||||
scope: EASEnvironmentVariableScopeFlagValue;
|
||||
format: string;
|
||||
environment: EnvironmentVariableEnvironment[] | undefined;
|
||||
'include-sensitive': boolean;
|
||||
'include-file-content': boolean;
|
||||
'non-interactive'?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default class EnvironmentValueList extends EasCommand {
|
||||
static override description = 'list environment variables for the current project';
|
||||
interface ListFlags {
|
||||
scope: EnvironmentVariableScope;
|
||||
format: string;
|
||||
environment: EnvironmentVariableEnvironment[] | undefined;
|
||||
'include-sensitive': boolean;
|
||||
'include-file-content': boolean;
|
||||
'non-interactive': boolean;
|
||||
}
|
||||
|
||||
static override hidden = true;
|
||||
export default class EnvList extends EasCommand {
|
||||
static override description = 'list environment variables for the current project or account';
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
@@ -96,7 +104,7 @@ export default class EnvironmentValueList extends EasCommand {
|
||||
}),
|
||||
...EASMultiEnvironmentFlag,
|
||||
...EASVariableFormatFlag,
|
||||
...EASVariableScopeFlag,
|
||||
...EASEnvironmentVariableScopeFlag,
|
||||
};
|
||||
|
||||
static override args = [
|
||||
@@ -109,7 +117,7 @@ export default class EnvironmentValueList extends EasCommand {
|
||||
];
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvironmentValueList);
|
||||
const { args, flags } = await this.parse(EnvList);
|
||||
|
||||
let {
|
||||
format,
|
||||
@@ -118,12 +126,12 @@ export default class EnvironmentValueList extends EasCommand {
|
||||
'include-sensitive': includeSensitive,
|
||||
'include-file-content': includeFileContent,
|
||||
'non-interactive': nonInteractive,
|
||||
} = this.validateInputs(flags, args);
|
||||
} = this.sanitizeInputs(flags, args);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvironmentValueList, {
|
||||
} = await this.getContextAsync(EnvList, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
@@ -156,7 +164,7 @@ export default class EnvironmentValueList extends EasCommand {
|
||||
}
|
||||
} else {
|
||||
if (scope === EnvironmentVariableScope.Shared) {
|
||||
Log.log(chalk.bold('Shared variables for this account:'));
|
||||
Log.log(chalk.bold('Account-wide variables for this account:'));
|
||||
} else {
|
||||
Log.log(chalk.bold(`Variables for this project:`));
|
||||
}
|
||||
@@ -167,10 +175,10 @@ export default class EnvironmentValueList extends EasCommand {
|
||||
});
|
||||
}
|
||||
|
||||
private validateInputs(
|
||||
flags: ListFlags,
|
||||
{ environment }: Record<string, string>
|
||||
): ListFlags & { 'non-interactive': boolean } {
|
||||
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'.");
|
||||
}
|
||||
@@ -185,6 +193,10 @@ export default class EnvironmentValueList extends EasCommand {
|
||||
...flags,
|
||||
'non-interactive': flags['non-interactive'] ?? false,
|
||||
environment: environments,
|
||||
scope:
|
||||
flags.scope === 'account'
|
||||
? EnvironmentVariableScope.Shared
|
||||
: EnvironmentVariableScope.Project,
|
||||
};
|
||||
}
|
||||
}
|
||||
+12
-13
@@ -15,10 +15,9 @@ 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.ProjectId,
|
||||
@@ -48,7 +47,7 @@ export default class EnvironmentVariablePull extends EasCommand {
|
||||
let {
|
||||
args: { environment: argEnvironment },
|
||||
flags: { environment: flagEnvironment, path: targetPath, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(EnvironmentVariablePull);
|
||||
} = await this.parse(EnvPull);
|
||||
|
||||
let environment = flagEnvironment?.toUpperCase() ?? argEnvironment?.toUpperCase();
|
||||
|
||||
@@ -64,7 +63,7 @@ export default class EnvironmentVariablePull extends EasCommand {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir,
|
||||
} = await this.getContextAsync(EnvironmentVariablePull, {
|
||||
} = await this.getContextAsync(EnvPull, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
@@ -117,7 +116,7 @@ export default class EnvironmentVariablePull extends EasCommand {
|
||||
return `${variable.name}=${currentEnvLocal[variable.name]}`;
|
||||
}
|
||||
skippedSecretVariables.push(variable.name);
|
||||
return `# ${variable.name}=***** (secret variables are not available for reading)`;
|
||||
return `# ${variable.name}=***** (secret)`;
|
||||
}
|
||||
if (variable.type === EnvironmentSecretType.FileBase64 && variable.valueWithFileContent) {
|
||||
const filePath = path.join(envDir, variable.name);
|
||||
@@ -131,20 +130,20 @@ export default class EnvironmentVariablePull extends EasCommand {
|
||||
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')}`);
|
||||
Log.log(`Reused local values for following secrets: ${overridenSecretVariables.join('\n')}.`);
|
||||
}
|
||||
|
||||
if (skippedSecretVariables.length > 0) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`The following variables have the encrypted visibility and can not be read outside of EAS servers. Set their values manually in .env.local: ${skippedSecretVariables.join(
|
||||
'\n'
|
||||
)}`
|
||||
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(
|
||||
', '
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-8
@@ -21,10 +21,9 @@ import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
|
||||
import { isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
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.ProjectId,
|
||||
@@ -49,14 +48,14 @@ export default class EnvironmentVariablePush extends EasCommand {
|
||||
];
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvironmentVariablePush);
|
||||
const { args, flags } = await this.parse(EnvPush);
|
||||
|
||||
let { environment: environments, path: envPath } = this.parseFlagsAndArgs(flags, args);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvironmentVariablePush, {
|
||||
} = await this.getContextAsync(EnvPush, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
@@ -106,12 +105,12 @@ export default class EnvironmentVariablePush extends EasCommand {
|
||||
const existingDifferentSharedVariablesNames = existingDifferentSharedVariables.map(
|
||||
variable => variable.name
|
||||
);
|
||||
Log.error('Shared variables cannot be overwritten by eas env:push command.');
|
||||
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}`);
|
||||
});
|
||||
throw new Error('Shared variables cannot be overwritten by eas env:push command');
|
||||
throw new Error('Account-wide variables cannot be overwritten by eas env:push command');
|
||||
}
|
||||
|
||||
if (existingDifferentVariables.length > 0) {
|
||||
|
||||
+8
-6
@@ -18,9 +18,11 @@ type UnlinkFlags = {
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
};
|
||||
|
||||
export default class EnvironmentVariableUnlink extends EasCommand {
|
||||
static override description = 'unlink a shared environment variable to the current project';
|
||||
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 = {
|
||||
@@ -46,7 +48,7 @@ export default class EnvironmentVariableUnlink extends EasCommand {
|
||||
];
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvironmentVariableUnlink);
|
||||
const { args, flags } = await this.parse(EnvUnlink);
|
||||
|
||||
let {
|
||||
'variable-name': name,
|
||||
@@ -57,7 +59,7 @@ export default class EnvironmentVariableUnlink extends EasCommand {
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvironmentVariableUnlink, {
|
||||
} = await this.getContextAsync(EnvUnlink, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
@@ -74,7 +76,7 @@ export default class EnvironmentVariableUnlink extends EasCommand {
|
||||
throw new Error("Multiple variables found, please select one using '--variable-name'");
|
||||
}
|
||||
selectedVariable = await selectAsync(
|
||||
'Select shared variable',
|
||||
'Select account-wide variable',
|
||||
variables.map(variable => ({
|
||||
title: formatVariableName(variable),
|
||||
value: variable,
|
||||
@@ -83,7 +85,7 @@ export default class EnvironmentVariableUnlink extends EasCommand {
|
||||
}
|
||||
|
||||
if (!selectedVariable) {
|
||||
throw new Error(`Shared variable ${name} doesn't exist`);
|
||||
throw new Error(`Account-wide variable ${name} doesn't exist`);
|
||||
}
|
||||
|
||||
let explicitSelect = false;
|
||||
|
||||
+35
-19
@@ -6,9 +6,10 @@ import path from 'path';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
EASMultiEnvironmentFlag,
|
||||
EASNonInteractiveFlag,
|
||||
EASVariableScopeFlag,
|
||||
EASVariableVisibilityFlag,
|
||||
EasEnvironmentFlagParameters,
|
||||
} from '../../commandUtils/flags';
|
||||
@@ -37,23 +38,32 @@ import {
|
||||
} from '../../utils/prompts';
|
||||
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
type UpdateFlags = {
|
||||
interface RawUpdateFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
scope?: EnvironmentVariableScope;
|
||||
scope: EASEnvironmentVariableScopeFlagValue;
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
visibility?: 'plaintext' | 'sensitive' | 'encrypted';
|
||||
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({
|
||||
@@ -74,7 +84,7 @@ export default class EnvironmentVariableUpdate extends EasCommand {
|
||||
options: ['string', 'file'],
|
||||
}),
|
||||
...EASVariableVisibilityFlag,
|
||||
...EASVariableScopeFlag,
|
||||
...EASEnvironmentVariableScopeFlag,
|
||||
...EASMultiEnvironmentFlag,
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
@@ -95,7 +105,7 @@ export default class EnvironmentVariableUpdate extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvironmentVariableUpdate);
|
||||
const { args, flags } = await this.parse(EnvUpdate);
|
||||
const {
|
||||
name,
|
||||
value: rawValue,
|
||||
@@ -106,12 +116,12 @@ export default class EnvironmentVariableUpdate extends EasCommand {
|
||||
environment: environments,
|
||||
type,
|
||||
visibility,
|
||||
} = this.validateFlags(flags, args);
|
||||
} = this.sanitizeInputs(flags, args);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvironmentVariableUpdate, {
|
||||
} = await this.getContextAsync(EnvUpdate, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
@@ -177,6 +187,7 @@ export default class EnvironmentVariableUpdate extends EasCommand {
|
||||
visibility,
|
||||
'non-interactive': nonInteractive,
|
||||
type,
|
||||
scope,
|
||||
});
|
||||
|
||||
const variable = await EnvironmentVariableMutation.updateAsync(graphqlClient, {
|
||||
@@ -194,8 +205,8 @@ export default class EnvironmentVariableUpdate extends EasCommand {
|
||||
|
||||
Log.withTick(`Updated variable ${chalk.bold(selectedVariable.name)} ${suffix}.`);
|
||||
}
|
||||
private validateFlags(
|
||||
flags: UpdateFlags,
|
||||
private sanitizeInputs(
|
||||
flags: RawUpdateFlags,
|
||||
{ environment }: { environment?: string }
|
||||
): UpdateFlags {
|
||||
if (flags['non-interactive']) {
|
||||
@@ -209,6 +220,11 @@ export default class EnvironmentVariableUpdate extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const scope =
|
||||
flags.scope === 'account'
|
||||
? EnvironmentVariableScope.Shared
|
||||
: EnvironmentVariableScope.Project;
|
||||
|
||||
if (environment) {
|
||||
environment = environment.toUpperCase();
|
||||
if (!isEnvironment(environment)) {
|
||||
@@ -217,12 +233,13 @@ export default class EnvironmentVariableUpdate extends EasCommand {
|
||||
);
|
||||
}
|
||||
return {
|
||||
'variable-environment': environment,
|
||||
...flags,
|
||||
'variable-environment': environment,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
return flags;
|
||||
return { ...flags, scope };
|
||||
}
|
||||
|
||||
private async promptForMissingFlagsAsync(
|
||||
@@ -328,15 +345,14 @@ export default class EnvironmentVariableUpdate extends EasCommand {
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
name,
|
||||
value,
|
||||
environment: environments,
|
||||
visibility: newVisibility,
|
||||
scope: rest.scope ?? EnvironmentVariableScope.Project,
|
||||
'non-interactive': nonInteractive,
|
||||
type: newType,
|
||||
fileName,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { Flags } from '@oclif/core';
|
||||
import openBrowserAsync from 'better-opn';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getExpoWebsiteBaseUrl } from '../../api';
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
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,
|
||||
BuildFragment,
|
||||
BuildStatus,
|
||||
FingerprintFragment,
|
||||
UpdateFragment,
|
||||
} from '../../graphql/generated';
|
||||
import { FingerprintMutation } from '../../graphql/mutations/FingerprintMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { FingerprintQuery } from '../../graphql/queries/FingerprintQuery';
|
||||
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
|
||||
import Log, { learnMore } 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 { promptAsync, selectAsync } from '../../prompts';
|
||||
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
|
||||
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, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
|
||||
enum FingerprintOriginType {
|
||||
Build = 'build',
|
||||
Update = 'update',
|
||||
Hash = 'hash',
|
||||
Project = 'project',
|
||||
}
|
||||
|
||||
type FingerprintOrigin = {
|
||||
type: FingerprintOriginType;
|
||||
build?: BuildFragment;
|
||||
update?: UpdateFragment;
|
||||
};
|
||||
|
||||
export default class FingerprintCompare extends EasCommand {
|
||||
static override description = 'compare fingerprints of the current project, builds, and updates';
|
||||
static override strict = false;
|
||||
|
||||
static override examples = [
|
||||
'$ eas fingerprint:compare \t # Compare fingerprints in interactive mode',
|
||||
'$ eas fingerprint:compare <FINGERPRINT-HASH> \t # Compare fingerprint against local directory',
|
||||
'$ eas fingerprint:compare <FINGERPRINT-HASH-1> <FINGERPRINT-HASH-2> \t # Compare provided fingerprints',
|
||||
'$ eas fingerprint:compare --build-id <BUILD-ID> \t # Compare fingerprint from build against local directory',
|
||||
'$ eas fingerprint:compare --build-id <BUILD-ID-1> --build-id <BUILD-ID-2>\t # Compare fingerprint from a build against another build',
|
||||
'$ eas fingerprint:compare --build-id <BUILD-ID> --update-id <UPDATE-ID>\t # Compare fingerprint from build against fingerprint from update',
|
||||
'$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> \t # Compare fingerprint from update against provided fingerprint',
|
||||
];
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'hash1',
|
||||
description:
|
||||
"If provided alone, HASH1 is compared against the current project's fingerprint.",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'hash2',
|
||||
description: 'If two hashes are provided, HASH1 is compared against HASH2.',
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
'build-id': Flags.string({
|
||||
aliases: ['buildId'],
|
||||
description: 'Compare the fingerprint with the build with the specified ID',
|
||||
multiple: true,
|
||||
}),
|
||||
'update-id': Flags.string({
|
||||
aliases: ['updateId'],
|
||||
description: 'Compare the fingerprint with the update with the specified ID',
|
||||
multiple: true,
|
||||
}),
|
||||
open: Flags.boolean({
|
||||
description: 'Open the fingerprint comparison in the browser',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Vcs,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(FingerprintCompare);
|
||||
const { hash1, hash2 } = args;
|
||||
const {
|
||||
json,
|
||||
'non-interactive': nonInteractive,
|
||||
'build-id': buildIds,
|
||||
'update-id': updateIds,
|
||||
open,
|
||||
} = flags;
|
||||
const [buildId1, buildId2] = buildIds ?? [];
|
||||
const [updateId1, updateId2] = updateIds ?? [];
|
||||
|
||||
const {
|
||||
projectId,
|
||||
privateProjectConfig: { projectDir },
|
||||
loggedIn: { graphqlClient },
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(FingerprintCompare, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const firstFingerprintInfo = await getFingerprintInfoAsync(
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
vcsClient,
|
||||
{
|
||||
nonInteractive,
|
||||
buildId: buildId1,
|
||||
updateId: updateId1,
|
||||
hash: hash1,
|
||||
}
|
||||
);
|
||||
const { fingerprint: firstFingerprint, origin: firstFingerprintOrigin } = firstFingerprintInfo;
|
||||
|
||||
const isFirstFingerprintSpecifiedByFlagOrArg = hash1 || buildId1 || updateId1;
|
||||
const isSecondFingerprintSpecifiedByFlagOrArg = hash2 || buildId2 || updateId2;
|
||||
const secondFingerprintInfo = await getFingerprintInfoAsync(
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
vcsClient,
|
||||
{
|
||||
nonInteractive,
|
||||
buildId: buildId2,
|
||||
updateId: updateId2,
|
||||
hash: hash2,
|
||||
useProjectFingerprint:
|
||||
isFirstFingerprintSpecifiedByFlagOrArg && !isSecondFingerprintSpecifiedByFlagOrArg,
|
||||
},
|
||||
firstFingerprintInfo
|
||||
);
|
||||
const { fingerprint: secondFingerprint, origin: secondFingerprintOrigin } =
|
||||
secondFingerprintInfo;
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ fingerprint1: firstFingerprint, fingerprint2: secondFingerprint });
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstFingerprint.hash === secondFingerprint.hash) {
|
||||
Log.log(
|
||||
`✅ ${capitalizeFirstLetter(
|
||||
prettyPrintFingerprint(firstFingerprint, firstFingerprintOrigin)
|
||||
)} matches ${prettyPrintFingerprint(secondFingerprint, secondFingerprintOrigin)}`
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
Log.log(
|
||||
`🔄 ${capitalizeFirstLetter(
|
||||
prettyPrintFingerprint(firstFingerprint, firstFingerprintOrigin)
|
||||
)} differs from ${prettyPrintFingerprint(secondFingerprint, secondFingerprintOrigin)}`
|
||||
);
|
||||
}
|
||||
|
||||
const fingerprintDiffs = diffFingerprint(projectDir, firstFingerprint, secondFingerprint);
|
||||
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);
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
const fingerprintCompareUrl = new URL(
|
||||
`/accounts/${project.ownerAccount.name}/projects/${project.slug}/fingerprints/compare/${firstFingerprintInfo.fingerprint.hash}/${secondFingerprintInfo.fingerprint.hash}`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
);
|
||||
|
||||
if (!open) {
|
||||
Log.newLine();
|
||||
Log.withInfo(
|
||||
`💡 Use the --open flag to view the comparison in the browser. ${learnMore(
|
||||
fingerprintCompareUrl.toString()
|
||||
)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
await openBrowserAsync(fingerprintCompareUrl.toString());
|
||||
}
|
||||
}
|
||||
|
||||
async function getFingerprintInfoAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectDir: string,
|
||||
projectId: string,
|
||||
vcsClient: Client,
|
||||
{
|
||||
buildId,
|
||||
updateId,
|
||||
hash,
|
||||
useProjectFingerprint,
|
||||
nonInteractive,
|
||||
}: {
|
||||
buildId?: string;
|
||||
updateId?: string;
|
||||
hash: string;
|
||||
useProjectFingerprint?: boolean;
|
||||
nonInteractive: boolean;
|
||||
},
|
||||
firstFingerprintInfo?: {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
}
|
||||
): Promise<{ fingerprint: Fingerprint; origin: FingerprintOrigin }> {
|
||||
if (hash) {
|
||||
return await getFingerprintInfoFromHashAsync(graphqlClient, projectId, hash);
|
||||
} else if (updateId) {
|
||||
return await getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
nonInteractive,
|
||||
updateId
|
||||
);
|
||||
} else if (buildId) {
|
||||
return await getFingerprintInfoFromBuildIdAsync(graphqlClient, buildId);
|
||||
} else if (useProjectFingerprint) {
|
||||
if (!firstFingerprintInfo) {
|
||||
throw new Error(
|
||||
'First fingerprint must be provided in order to compare against the project.'
|
||||
);
|
||||
}
|
||||
return await getFingerprintInfoFromLocalProjectAsync(
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
vcsClient,
|
||||
firstFingerprintInfo
|
||||
);
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'Insufficent arguments provided for fingerprint comparison in non-interactive mode'
|
||||
);
|
||||
}
|
||||
return await getFingerprintInfoInteractiveAsync(
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
vcsClient,
|
||||
firstFingerprintInfo
|
||||
);
|
||||
}
|
||||
|
||||
async function getFingerprintInfoInteractiveAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectDir: string,
|
||||
projectId: string,
|
||||
vcsClient: Client,
|
||||
firstFingerprintInfo?: {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
}
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
const prompt = firstFingerprintInfo
|
||||
? 'Select the second fingerprint to compare against'
|
||||
: 'Select a reference fingerprint for comparison';
|
||||
const originType = await selectAsync<FingerprintOriginType>(prompt, [
|
||||
...(firstFingerprintInfo
|
||||
? [{ title: 'Current project fingerprint', value: FingerprintOriginType.Project }]
|
||||
: []),
|
||||
{ title: 'Build fingerprint', value: FingerprintOriginType.Build },
|
||||
{ title: 'Update fingerprint', value: FingerprintOriginType.Update },
|
||||
{ title: 'Enter a fingerprint hash manually', value: FingerprintOriginType.Hash },
|
||||
]);
|
||||
if (originType === FingerprintOriginType.Project) {
|
||||
if (!firstFingerprintInfo) {
|
||||
throw new Error(
|
||||
'First fingerprint must be provided in order to compare against the project.'
|
||||
);
|
||||
}
|
||||
return await getFingerprintInfoFromLocalProjectAsync(
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
vcsClient,
|
||||
firstFingerprintInfo
|
||||
);
|
||||
} else if (originType === FingerprintOriginType.Build) {
|
||||
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
|
||||
const buildId = await selectBuildToCompareAsync(graphqlClient, projectId, displayName, {
|
||||
filters: { hasFingerprint: true },
|
||||
});
|
||||
if (!buildId) {
|
||||
throw new Error('Must select build with fingerprint for comparison.');
|
||||
}
|
||||
return await getFingerprintInfoFromBuildIdAsync(graphqlClient, buildId);
|
||||
} else if (originType === FingerprintOriginType.Update) {
|
||||
const selectedBranch = await selectBranchOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
promptTitle: 'On which branch would you like search for an update?',
|
||||
displayTextForListItem: updateBranch => ({
|
||||
title: updateBranch.name,
|
||||
}),
|
||||
paginatedQueryOptions: {
|
||||
json: false,
|
||||
nonInteractive: false,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedUpdateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
|
||||
projectId,
|
||||
branchName: selectedBranch.name,
|
||||
paginatedQueryOptions: {
|
||||
json: false,
|
||||
nonInteractive: false,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
const updateGroupId = selectedUpdateGroup[0].group;
|
||||
return await getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
false,
|
||||
updateGroupId
|
||||
);
|
||||
} else if (originType === FingerprintOriginType.Hash) {
|
||||
const { hash } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'hash',
|
||||
message: 'Provide the fingerprint hash',
|
||||
validate: (value: string) => !!value.trim(),
|
||||
hint: '0000000000000000000000000000000000000000',
|
||||
});
|
||||
return await getFingerprintInfoFromHashAsync(graphqlClient, projectId, hash);
|
||||
} else {
|
||||
throw new Error(`Unsupported fingerprint origin type: ${originType}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getFingerprintInfoFromLocalProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectDir: string,
|
||||
projectId: string,
|
||||
vcsClient: Client,
|
||||
firstFingerprintInfo: {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
}
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
const firstFingerprintPlatforms = firstFingerprintInfo.platforms;
|
||||
if (!firstFingerprintPlatforms) {
|
||||
throw new Error(
|
||||
`Cannot compare the local directory against the provided fingerprint hash "${firstFingerprintInfo.fingerprint.hash}" because the associated platform could not be determined. Ensure the fingerprint is linked to a build or update to identify the platform.`
|
||||
);
|
||||
}
|
||||
|
||||
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient);
|
||||
const optionsFromWorkflow = getFingerprintOptionsFromWorkflow(
|
||||
firstFingerprintPlatforms,
|
||||
workflows
|
||||
);
|
||||
|
||||
const projectFingerprint = await createFingerprintAsync(projectDir, {
|
||||
...optionsFromWorkflow,
|
||||
platforms: firstFingerprintPlatforms.map(appPlatformToString),
|
||||
debug: true,
|
||||
env: undefined,
|
||||
});
|
||||
if (!projectFingerprint) {
|
||||
throw new Error('Project fingerprints can only be computed for projects with SDK 52 or higher');
|
||||
}
|
||||
|
||||
const uploadedFingerprint = await maybeUploadFingerprintAsync({
|
||||
hash: projectFingerprint.hash,
|
||||
fingerprint: {
|
||||
fingerprintSources: projectFingerprint.sources,
|
||||
isDebugFingerprintSource: Log.isDebug,
|
||||
},
|
||||
graphqlClient,
|
||||
});
|
||||
await FingerprintMutation.createFingerprintAsync(graphqlClient, projectId, {
|
||||
hash: uploadedFingerprint.hash,
|
||||
source: uploadedFingerprint.fingerprintSource,
|
||||
});
|
||||
|
||||
return { fingerprint: projectFingerprint, origin: { type: FingerprintOriginType.Project } };
|
||||
}
|
||||
|
||||
async function getFingerprintFromUpdateFragmentAsync(
|
||||
updateWithFingerprint: UpdateFragment
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
if (!updateWithFingerprint.fingerprint) {
|
||||
throw new Error(`Fingerprint for update ${updateWithFingerprint.id} was not computed.`);
|
||||
} else if (!updateWithFingerprint.fingerprint.debugInfoUrl) {
|
||||
throw new Error(`Fingerprint source for update ${updateWithFingerprint.id} was not computed.`);
|
||||
}
|
||||
|
||||
return {
|
||||
fingerprint: await getFingerprintFromFingerprintFragmentAsync(
|
||||
updateWithFingerprint.fingerprint
|
||||
),
|
||||
platforms: [stringToAppPlatform(updateWithFingerprint.platform)],
|
||||
origin: {
|
||||
type: FingerprintOriginType.Update,
|
||||
update: updateWithFingerprint,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getFingerprintInfoFromHashAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
hash: string
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
const fingerprintFragment = await getFingerprintFragmentFromHashAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
hash
|
||||
);
|
||||
const fingerprint = await getFingerprintFromFingerprintFragmentAsync(fingerprintFragment);
|
||||
let platforms;
|
||||
const fingerprintBuilds = fingerprintFragment.builds?.edges.map(edge => edge.node) ?? [];
|
||||
const fingerprintUpdates = fingerprintFragment.updates?.edges.map(edge => edge.node) ?? [];
|
||||
if (fingerprintBuilds.length > 0) {
|
||||
platforms = [fingerprintBuilds[0].platform];
|
||||
} else if (fingerprintUpdates.length > 0) {
|
||||
platforms = [stringToAppPlatform(fingerprintUpdates[0].platform)];
|
||||
}
|
||||
return {
|
||||
fingerprint,
|
||||
platforms,
|
||||
origin: {
|
||||
type: FingerprintOriginType.Hash,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
nonInteractive: boolean,
|
||||
updateGroupIdOrUpdateId: string
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
// Some people may pass in update group id instead of update id, so add interactive support for that
|
||||
try {
|
||||
const maybeUpdateGroupId = updateGroupIdOrUpdateId;
|
||||
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
|
||||
groupId: maybeUpdateGroupId,
|
||||
});
|
||||
if (updateGroup.length === 1) {
|
||||
const update = updateGroup[0];
|
||||
return await getFingerprintFromUpdateFragmentAsync(update);
|
||||
}
|
||||
if (nonInteractive) {
|
||||
const project = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
const updateUrl =
|
||||
getExpoWebsiteBaseUrl() +
|
||||
`/accounts/${project.ownerAccount.name}/projects/${project.slug}/updates/${maybeUpdateGroupId}`;
|
||||
throw new Error(
|
||||
`Please pass in your update ID from ${updateUrl} or use interactive mode to select the update ID.`
|
||||
);
|
||||
}
|
||||
const update = await selectAsync<UpdateFragment>(
|
||||
'Select a platform to compute the fingerprint from',
|
||||
updateGroup.map(update => ({
|
||||
title: update.platform,
|
||||
value: update,
|
||||
}))
|
||||
);
|
||||
return await getFingerprintFromUpdateFragmentAsync(update);
|
||||
} catch (error: any) {
|
||||
if (!error?.message.includes('Could not find any updates with group ID')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const updateId = updateGroupIdOrUpdateId;
|
||||
const updateWithFingerprint = await UpdateQuery.viewByUpdateAsync(graphqlClient, {
|
||||
updateId,
|
||||
});
|
||||
return await getFingerprintFromUpdateFragmentAsync(updateWithFingerprint);
|
||||
}
|
||||
|
||||
async function getFingerprintInfoFromBuildIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
buildId: string
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
const buildWithFingerprint = await BuildQuery.withFingerprintByIdAsync(graphqlClient, buildId);
|
||||
if (!buildWithFingerprint.fingerprint) {
|
||||
throw new Error(`Fingerprint for build ${buildId} was not computed.`);
|
||||
} else if (!buildWithFingerprint.fingerprint.debugInfoUrl) {
|
||||
throw new Error(`Fingerprint source for build ${buildId} was not computed.`);
|
||||
}
|
||||
return {
|
||||
fingerprint: await getFingerprintFromFingerprintFragmentAsync(buildWithFingerprint.fingerprint),
|
||||
platforms: [buildWithFingerprint.platform],
|
||||
origin: {
|
||||
type: FingerprintOriginType.Build,
|
||||
build: buildWithFingerprint,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getFingerprintFragmentFromHashAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
hash: string
|
||||
): Promise<FingerprintFragment> {
|
||||
const fingerprint = await FingerprintQuery.byHashAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
hash,
|
||||
});
|
||||
if (!fingerprint) {
|
||||
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
|
||||
throw new Error(`Fingerprint with hash ${hash} was not uploaded for ${displayName}.`);
|
||||
}
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
async function getFingerprintFromFingerprintFragmentAsync(
|
||||
fingerprintFragment: Pick<FingerprintFragment, 'debugInfoUrl' | 'hash'>
|
||||
): Promise<Fingerprint> {
|
||||
const fingerprintDebugUrl = fingerprintFragment.debugInfoUrl;
|
||||
if (!fingerprintDebugUrl) {
|
||||
throw new Error(
|
||||
`The source for fingerprint hash ${fingerprintFragment.hash} was not computed.`
|
||||
);
|
||||
}
|
||||
const fingerprintResponse = await fetch(fingerprintDebugUrl);
|
||||
return (await fingerprintResponse.json()) as Fingerprint;
|
||||
}
|
||||
|
||||
function getFingerprintOptionsFromWorkflow(
|
||||
platforms: AppPlatform[],
|
||||
workflowsByPlatform: Record<Platform, Workflow>
|
||||
): { workflow?: Workflow; ignorePaths?: string[] } {
|
||||
if (platforms.length === 0) {
|
||||
throw new Error('Could not determine platform from fingerprint sources');
|
||||
}
|
||||
|
||||
// Single platform case
|
||||
if (platforms.length === 1) {
|
||||
const platform = platforms[0];
|
||||
return { workflow: workflowsByPlatform[appPlatformToPlatform(platform)] };
|
||||
}
|
||||
|
||||
// Multiple platforms case
|
||||
const workflows = platforms.map(platform => workflowsByPlatform[appPlatformToPlatform(platform)]);
|
||||
|
||||
// If all workflows are the same, return the common workflow
|
||||
const [firstWorkflow, ...restWorkflows] = workflows;
|
||||
if (restWorkflows.every(workflow => workflow === firstWorkflow)) {
|
||||
return { workflow: firstWorkflow };
|
||||
}
|
||||
|
||||
// Generate ignorePaths for mixed workflows
|
||||
const ignorePaths = platforms
|
||||
.filter(platform => workflowsByPlatform[appPlatformToPlatform(platform)] === Workflow.MANAGED)
|
||||
.map(platform => `${appPlatformToString(platform)}/**/*`);
|
||||
|
||||
return { ignorePaths };
|
||||
}
|
||||
|
||||
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 app config',
|
||||
'package:react-native': 'React Native package.json',
|
||||
'rncoreAutolinkingConfig:ios': 'React Native Community autolinking config (iOS)',
|
||||
'rncoreAutolinkingConfig:android': 'React Native Community autolinking config (Android)',
|
||||
};
|
||||
|
||||
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 prettyPrintFingerprint(fingerprint: Fingerprint, origin: FingerprintOrigin): string {
|
||||
if (origin.type === FingerprintOriginType.Project) {
|
||||
return `fingerprint ${fingerprint.hash} from local directory`;
|
||||
} else if (origin.type === FingerprintOriginType.Update) {
|
||||
return `fingerprint ${fingerprint.hash} from ${
|
||||
origin.update?.platform ? stringToAppPlatform(origin.update?.platform) : ''
|
||||
} ${origin.type}`;
|
||||
} else if (origin.type === FingerprintOriginType.Build) {
|
||||
return `fingerprint ${fingerprint.hash} from ${origin.build?.platform} ${origin.type}`;
|
||||
}
|
||||
return `fingerprint ${fingerprint.hash}`;
|
||||
}
|
||||
|
||||
function capitalizeFirstLetter(string: string): string {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringToAppPlatform(platform: string): AppPlatform {
|
||||
switch (platform) {
|
||||
case 'android':
|
||||
return AppPlatform.Android;
|
||||
case 'ios':
|
||||
return AppPlatform.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;
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,13 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { saveProjectIdToAppConfigAsync } from '../../commandUtils/context/contextUtils/getProjectIdAsync';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { AppPrivacy, Role } from '../../graphql/generated';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import { AppMutation } from '../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { toAppPrivacy } from '../../project/projectUtils';
|
||||
import { Choice, confirmAsync, promptAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
|
||||
@@ -337,7 +336,6 @@ export default class ProjectInit extends EasCommand {
|
||||
createdProjectId = await AppMutation.createAppAsync(graphqlClient, {
|
||||
accountId: account.id,
|
||||
projectName,
|
||||
privacy: toAppPrivacy(exp.privacy) ?? AppPrivacy.Public,
|
||||
});
|
||||
spinner.succeed(`Created ${chalk.bold(projectLink)}`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -162,7 +162,10 @@ export default class Onboarding extends EasCommand {
|
||||
cloneMethod,
|
||||
});
|
||||
|
||||
const vcsClient = new GitClient(finalTargetProjectDirectory);
|
||||
const vcsClient = new GitClient({
|
||||
maybeCwdOverride: finalTargetProjectDirectory,
|
||||
requireCommit: false,
|
||||
});
|
||||
if (!app.githubRepository) {
|
||||
await fs.remove(path.join(finalTargetProjectDirectory, '.git'));
|
||||
await runCommandAsync({
|
||||
@@ -285,12 +288,12 @@ export default class Onboarding extends EasCommand {
|
||||
} else {
|
||||
Log.log('🚀 Now we are going to trigger your first build');
|
||||
Log.log();
|
||||
const { buildIds } = await runBuildAndSubmitAsync(
|
||||
const { buildIds } = await runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
finalTargetProjectDirectory,
|
||||
{
|
||||
projectDir: finalTargetProjectDirectory,
|
||||
flags: {
|
||||
nonInteractive: true,
|
||||
requestedPlatform:
|
||||
platform === Platform.ANDROID ? RequestedPlatform.Android : RequestedPlatform.Ios,
|
||||
@@ -307,8 +310,9 @@ export default class Onboarding extends EasCommand {
|
||||
repack: true,
|
||||
},
|
||||
actor,
|
||||
getDynamicProjectConfigFn
|
||||
);
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
getDynamicPrivateProjectConfigAsync: getDynamicProjectConfigFn,
|
||||
});
|
||||
const buildId = buildIds[0];
|
||||
Log.log();
|
||||
Log.log('🚀 You can now go back to the website to continue:');
|
||||
|
||||
@@ -25,6 +25,7 @@ import { promptAsync, selectAsync } from '../../prompts';
|
||||
export default class EnvironmentSecretCreate extends EasCommand {
|
||||
static override description =
|
||||
'create an environment secret on the current project or owner account';
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
scope: Flags.enum({
|
||||
@@ -55,6 +56,9 @@ export default class EnvironmentSecretCreate extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
Log.warn('This command is deprecated. Use eas env:create instead.');
|
||||
Log.newLine();
|
||||
|
||||
let {
|
||||
flags: {
|
||||
name,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { promptAsync, toggleConfirmAsync } from '../../prompts';
|
||||
|
||||
export default class EnvironmentSecretDelete extends EasCommand {
|
||||
static override description = 'delete an environment secret by ID';
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
@@ -27,6 +28,9 @@ export default class EnvironmentSecretDelete extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
Log.warn('This command is deprecated. Use eas env:delete instead.');
|
||||
Log.newLine();
|
||||
|
||||
let {
|
||||
flags: { id, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(EnvironmentSecretDelete);
|
||||
|
||||
@@ -11,6 +11,7 @@ import formatFields from '../../utils/formatFields';
|
||||
|
||||
export default class EnvironmentSecretList extends EasCommand {
|
||||
static override description = 'list environment secrets available for your current app';
|
||||
static override hidden = true;
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
@@ -18,6 +19,9 @@ export default class EnvironmentSecretList extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
Log.warn('This command is deprecated. Use eas env:list instead.');
|
||||
Log.newLine();
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
|
||||
@@ -24,6 +24,7 @@ import intersection from '../../utils/expodash/intersection';
|
||||
|
||||
export default class EnvironmentSecretPush extends EasCommand {
|
||||
static override description = 'read environment secrets from env file and store on the server';
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
scope: Flags.enum({
|
||||
@@ -47,6 +48,9 @@ export default class EnvironmentSecretPush extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
Log.warn('This command is deprecated. Use eas env:push instead.');
|
||||
Log.newLine();
|
||||
|
||||
const {
|
||||
flags: { scope, force, 'env-file': maybeEnvFilePath, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(EnvironmentSecretPush);
|
||||
|
||||
@@ -18,8 +18,7 @@ import AndroidSubmitCommand from '../../submit/android/AndroidSubmitCommand';
|
||||
import { SubmissionContext, createSubmissionContextAsync } from '../../submit/context';
|
||||
import IosSubmitCommand from '../../submit/ios/IosSubmitCommand';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import GitNoCommitClient from '../../vcs/clients/gitNoCommit';
|
||||
import NoVcsClient from '../../vcs/clients/noVcs';
|
||||
import GitClient from '../../vcs/clients/git';
|
||||
|
||||
/**
|
||||
* This command will be run on the EAS workers.
|
||||
@@ -65,10 +64,16 @@ export default class SubmitInternal extends EasCommand {
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(SubmitInternal, {
|
||||
nonInteractive: true,
|
||||
vcsClientOverride: process.env.EAS_NO_VCS ? new NoVcsClient() : new GitNoCommitClient(),
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
if (vcsClient instanceof GitClient) {
|
||||
// `build:internal` is run on EAS workers and the repo may have been changed
|
||||
// by pre-install hooks or other scripts. We don't want to require committing changes
|
||||
// to continue the build.
|
||||
vcsClient.requireCommit = false;
|
||||
}
|
||||
|
||||
const submissionProfile = await EasJsonUtils.getSubmitProfileAsync(
|
||||
EasJsonAccessor.fromProjectPath(projectDir),
|
||||
flags.platform,
|
||||
|
||||
@@ -88,7 +88,10 @@ describe(UpdatePublish.name, () => {
|
||||
const { platforms, runtimeVersion } = mockTestExport();
|
||||
|
||||
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
|
||||
branchId: 'branch123',
|
||||
branch: {
|
||||
id: 'branch123',
|
||||
name: 'wat',
|
||||
},
|
||||
createdBranch: false,
|
||||
});
|
||||
|
||||
@@ -111,7 +114,10 @@ describe(UpdatePublish.name, () => {
|
||||
.mocked(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync)
|
||||
.mockResolvedValue({ branchId: 'branch123', branchName: 'branchFromChannel' });
|
||||
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
|
||||
branchId: 'branch123',
|
||||
branch: {
|
||||
id: 'branch123',
|
||||
name: 'wat',
|
||||
},
|
||||
createdBranch: false,
|
||||
});
|
||||
|
||||
@@ -146,7 +152,10 @@ describe(UpdatePublish.name, () => {
|
||||
|
||||
// Mock an existing branch, so we don't create a new one
|
||||
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
|
||||
branchId: 'branch123',
|
||||
branch: {
|
||||
id: 'branch123',
|
||||
name: 'wat',
|
||||
},
|
||||
createdBranch: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AppJSONConfig, ExpoConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import { Updates } from '@expo/config-plugins';
|
||||
import { vol } from 'memfs';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
@@ -82,16 +81,23 @@ describe(UpdateRollBackToEmbedded.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a roll back to embedded with --non-interactive, --branch, and --message', async () => {
|
||||
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
|
||||
it('creates a roll back to embedded with --non-interactive, --branch, --message, and --runtime-version', async () => {
|
||||
const flags = [
|
||||
'--non-interactive',
|
||||
'--branch=branch123',
|
||||
'--message=abc',
|
||||
'--runtime-version=exposdk:47.0.0',
|
||||
];
|
||||
|
||||
mockTestProject();
|
||||
const platforms = ['android', 'ios'];
|
||||
const runtimeVersion = 'exposdk:47.0.0';
|
||||
jest.mocked(Updates.getRuntimeVersionAsync).mockResolvedValue(runtimeVersion);
|
||||
|
||||
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
|
||||
branchId: 'branch123',
|
||||
branch: {
|
||||
id: 'branch123',
|
||||
name: 'wat',
|
||||
},
|
||||
createdBranch: false,
|
||||
});
|
||||
|
||||
@@ -104,19 +110,26 @@ describe(UpdateRollBackToEmbedded.name, () => {
|
||||
expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a roll back to embedded with --non-interactive, --channel, and --message', async () => {
|
||||
const flags = ['--non-interactive', '--channel=channel123', '--message=abc'];
|
||||
it('creates a roll back to embedded with --non-interactive, --channel, --message, and --runtime-version', async () => {
|
||||
const flags = [
|
||||
'--non-interactive',
|
||||
'--channel=channel123',
|
||||
'--message=abc',
|
||||
'--runtime-version=exposdk:47.0.0',
|
||||
];
|
||||
|
||||
const { projectId } = mockTestProject();
|
||||
const platforms = ['android', 'ios'];
|
||||
const runtimeVersion = 'exposdk:47.0.0';
|
||||
jest.mocked(Updates.getRuntimeVersionAsync).mockResolvedValue(runtimeVersion);
|
||||
|
||||
jest
|
||||
.mocked(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync)
|
||||
.mockResolvedValue({ branchId: updateStub.branch.id, branchName: 'branchFromChannel' });
|
||||
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
|
||||
branchId: 'branch123',
|
||||
branch: {
|
||||
id: 'branch123',
|
||||
name: 'wat',
|
||||
},
|
||||
createdBranch: false,
|
||||
});
|
||||
|
||||
@@ -142,18 +155,25 @@ describe(UpdateRollBackToEmbedded.name, () => {
|
||||
});
|
||||
|
||||
it('creates a roll back to embedded with the public expo config', async () => {
|
||||
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
|
||||
const flags = [
|
||||
'--non-interactive',
|
||||
'--branch=branch123',
|
||||
'--message=abc',
|
||||
'--runtime-version=exposdk:47.0.0',
|
||||
];
|
||||
|
||||
// Add configuration to the project that should not be included in the update
|
||||
mockTestProject();
|
||||
|
||||
const platforms = ['ios'];
|
||||
const runtimeVersion = 'exposdk:47.0.0';
|
||||
jest.mocked(Updates.getRuntimeVersionAsync).mockResolvedValue(runtimeVersion);
|
||||
|
||||
// Mock an existing branch, so we don't create a new one
|
||||
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
|
||||
branchId: 'branch123',
|
||||
branch: {
|
||||
id: 'branch123',
|
||||
name: 'wat',
|
||||
},
|
||||
createdBranch: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
|
||||
import fetch from '../../fetch';
|
||||
import {
|
||||
EnvironmentVariableEnvironment,
|
||||
FingerprintInfoGroup,
|
||||
PublishUpdateGroupInput,
|
||||
StatuspageServiceName,
|
||||
UpdateInfoGroup,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
getRuntimeVersionInfoObjectsAsync,
|
||||
getUpdateMessageForCommandAsync,
|
||||
isUploadedAssetCountAboveWarningThreshold,
|
||||
maybeCalculateFingerprintForRuntimeVersionInfoObjectsWithoutExpoUpdatesAsync,
|
||||
platformDisplayNames,
|
||||
resolveInputDirectoryAsync,
|
||||
uploadAssetsAsync,
|
||||
@@ -235,6 +237,10 @@ export default class UpdatePublish extends EasCommand {
|
||||
jsonFlag,
|
||||
});
|
||||
|
||||
const maybeServerEnv = environment
|
||||
? { ...(await getServerSideEnvironmentVariablesAsync()), EXPO_NO_DOTENV: '1' }
|
||||
: {};
|
||||
|
||||
// build bundle and upload assets for a new publish
|
||||
if (!skipBundler) {
|
||||
const bundleSpinner = ora().start('Exporting...');
|
||||
@@ -245,11 +251,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
exp,
|
||||
platformFlag: requestedPlatform,
|
||||
clearCache,
|
||||
extraEnv: {
|
||||
...(environment
|
||||
? { ...(await getServerSideEnvironmentVariablesAsync()), EXPO_NO_DOTENV: '1' }
|
||||
: {}),
|
||||
},
|
||||
extraEnv: maybeServerEnv,
|
||||
});
|
||||
bundleSpinner.succeed('Exported bundle(s)');
|
||||
} catch (e) {
|
||||
@@ -391,34 +393,45 @@ export default class UpdatePublish extends EasCommand {
|
||||
...workflows,
|
||||
web: Workflow.UNKNOWN,
|
||||
},
|
||||
env: undefined,
|
||||
env: maybeServerEnv,
|
||||
});
|
||||
const runtimeToPlatformsAndFingerprintInfoMapping =
|
||||
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects(
|
||||
runtimeVersionInfoObjects
|
||||
);
|
||||
|
||||
const { branchId } = await ensureBranchExistsAsync(graphqlClient, {
|
||||
const { branch } = await ensureBranchExistsAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName,
|
||||
});
|
||||
|
||||
const runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping = await Promise.all(
|
||||
runtimeToPlatformsAndFingerprintInfoMapping.map(async info => {
|
||||
return {
|
||||
...info,
|
||||
fingerprintSource: info.fingerprint
|
||||
? (
|
||||
await maybeUploadFingerprintAsync({
|
||||
hash: info.runtimeVersion,
|
||||
fingerprint: info.fingerprint,
|
||||
graphqlClient,
|
||||
})
|
||||
).fingerprintSource ?? null
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
const runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMappingFromExpoUpdates =
|
||||
await Promise.all(
|
||||
runtimeToPlatformsAndFingerprintInfoMapping.map(async info => {
|
||||
return {
|
||||
...info,
|
||||
expoUpdatesRuntimeFingerprintSource: info.expoUpdatesRuntimeFingerprint
|
||||
? (
|
||||
await maybeUploadFingerprintAsync({
|
||||
hash: info.runtimeVersion,
|
||||
fingerprint: info.expoUpdatesRuntimeFingerprint,
|
||||
graphqlClient,
|
||||
})
|
||||
).fingerprintSource ?? null
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping =
|
||||
await maybeCalculateFingerprintForRuntimeVersionInfoObjectsWithoutExpoUpdatesAsync({
|
||||
projectDir,
|
||||
graphqlClient,
|
||||
runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping:
|
||||
runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMappingFromExpoUpdates,
|
||||
workflowsByPlatform: workflows,
|
||||
env: undefined,
|
||||
});
|
||||
|
||||
const runtimeVersionToRolloutInfoGroup =
|
||||
rolloutPercentage !== undefined
|
||||
@@ -436,7 +449,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
// Sort the updates into different groups based on their platform specific runtime versions
|
||||
const updateGroups: PublishUpdateGroupInput[] =
|
||||
runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping.map(
|
||||
({ runtimeVersion, platforms, fingerprintSource }) => {
|
||||
({ runtimeVersion, platforms, fingerprintInfoGroup }) => {
|
||||
const localUpdateInfoGroup = Object.fromEntries(
|
||||
platforms.map(platform => [
|
||||
platform,
|
||||
@@ -455,19 +468,30 @@ export default class UpdatePublish extends EasCommand {
|
||||
])
|
||||
)
|
||||
: null;
|
||||
const transformedFingerprintInfoGroup = Object.entries(fingerprintInfoGroup).reduce(
|
||||
(prev, [platform, fingerprintInfo]) => {
|
||||
return {
|
||||
...prev,
|
||||
[platform]: {
|
||||
...fingerprintInfo,
|
||||
fingerprintSource: transformFingerprintSource(fingerprintInfo.fingerprintSource),
|
||||
},
|
||||
};
|
||||
},
|
||||
{} as FingerprintInfoGroup
|
||||
);
|
||||
|
||||
return {
|
||||
branchId,
|
||||
branchId: branch.id,
|
||||
updateInfoGroup: localUpdateInfoGroup,
|
||||
rolloutInfoGroup: localRolloutInfoGroup,
|
||||
runtimeFingerprintSource: fingerprintSource
|
||||
? transformFingerprintSource(fingerprintSource)
|
||||
: null,
|
||||
fingerprintInfoGroup: transformedFingerprintInfoGroup,
|
||||
runtimeVersion,
|
||||
message: updateMessage,
|
||||
gitCommitHash,
|
||||
isGitWorkingTreeDirty,
|
||||
awaitingCodeSigningInfo: !!codeSigningInfo,
|
||||
environment: environment ?? null,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Platform as PublishPlatform } from '@expo/config';
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
@@ -12,10 +11,12 @@ import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
|
||||
import fetch from '../../fetch';
|
||||
import {
|
||||
PublishUpdateGroupInput,
|
||||
RuntimeFragment,
|
||||
StatuspageServiceName,
|
||||
UpdatePublishMutation,
|
||||
} from '../../graphql/generated';
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
import { RuntimeQuery } from '../../graphql/queries/RuntimeQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
@@ -29,10 +30,8 @@ import {
|
||||
defaultPublishPlatforms,
|
||||
getBranchNameForCommandAsync,
|
||||
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
|
||||
getRuntimeVersionInfoObjectsAsync,
|
||||
getUpdateMessageForCommandAsync,
|
||||
} from '../../project/publish';
|
||||
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
|
||||
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
|
||||
import {
|
||||
@@ -45,12 +44,13 @@ import {
|
||||
import uniqBy from '../../utils/expodash/uniqBy';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { Connection, QueryParams, selectPaginatedAsync } from '../../utils/relay';
|
||||
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
|
||||
|
||||
type RawUpdateFlags = {
|
||||
auto: boolean;
|
||||
branch?: string;
|
||||
channel?: string;
|
||||
'runtime-version'?: string;
|
||||
message?: string;
|
||||
platform: string;
|
||||
'private-key-path'?: string;
|
||||
@@ -59,10 +59,10 @@ type RawUpdateFlags = {
|
||||
};
|
||||
|
||||
type UpdateFlags = {
|
||||
auto: boolean;
|
||||
platform: RequestedPlatform;
|
||||
branchName?: string;
|
||||
channelName?: string;
|
||||
runtimeVersion?: string;
|
||||
updateMessage?: string;
|
||||
privateKeyPath?: string;
|
||||
json: boolean;
|
||||
@@ -81,6 +81,10 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
description: 'Channel that the published rollback to embedded update should affect',
|
||||
required: false,
|
||||
}),
|
||||
'runtime-version': Flags.string({
|
||||
description: 'Runtime version that the rollback to embedded update should target',
|
||||
required: false,
|
||||
}),
|
||||
message: Flags.string({
|
||||
description: 'A short message describing the rollback to embedded update',
|
||||
required: false,
|
||||
@@ -95,11 +99,6 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
default: 'all',
|
||||
required: false,
|
||||
}),
|
||||
auto: Flags.boolean({
|
||||
description:
|
||||
'Use the current git branch and commit message for the EAS branch and update message',
|
||||
default: false,
|
||||
}),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
|
||||
required: false,
|
||||
@@ -117,10 +116,10 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
const { flags: rawFlags } = await this.parse(UpdateRollBackToEmbedded);
|
||||
const paginatedQueryOptions = getPaginatedQueryOptions(rawFlags);
|
||||
const {
|
||||
auto: autoFlag,
|
||||
platform: platformFlag,
|
||||
channelName: channelNameArg,
|
||||
updateMessage: updateMessageArg,
|
||||
runtimeVersion: runtimeVersionArg,
|
||||
privateKeyPath,
|
||||
json: jsonFlag,
|
||||
nonInteractive,
|
||||
@@ -171,14 +170,14 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
projectId,
|
||||
channelNameArg,
|
||||
branchNameArg,
|
||||
autoFlag,
|
||||
autoFlag: false,
|
||||
nonInteractive,
|
||||
paginatedQueryOptions,
|
||||
});
|
||||
|
||||
const updateMessage = await getUpdateMessageForCommandAsync(vcsClient, {
|
||||
updateMessageArg,
|
||||
autoFlag,
|
||||
autoFlag: false,
|
||||
nonInteractive,
|
||||
jsonFlag,
|
||||
});
|
||||
@@ -186,28 +185,33 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
const realizedPlatforms: UpdatePublishPlatform[] =
|
||||
platformFlag === 'all' ? defaultPublishPlatforms : [platformFlag];
|
||||
|
||||
const { branchId } = await ensureBranchExistsAsync(graphqlClient, {
|
||||
const { branch } = await ensureBranchExistsAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName,
|
||||
});
|
||||
|
||||
const gitCommitHash = await vcsClient.getCommitHashAsync();
|
||||
const isGitWorkingTreeDirty = await vcsClient.hasUncommittedChangesAsync();
|
||||
const selectedRuntime =
|
||||
runtimeVersionArg ??
|
||||
(
|
||||
await UpdateRollBackToEmbedded.selectRuntimeAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName,
|
||||
})
|
||||
)?.version;
|
||||
if (!selectedRuntime) {
|
||||
Errors.error('Must select a runtime or provide the --runtimeVersion flag', { exit: 1 });
|
||||
}
|
||||
|
||||
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient);
|
||||
const runtimeVersionInfoObjects = await getRuntimeVersionInfoObjectsAsync({
|
||||
exp,
|
||||
platforms: realizedPlatforms,
|
||||
projectDir,
|
||||
workflows: {
|
||||
...workflows,
|
||||
web: Workflow.UNKNOWN,
|
||||
},
|
||||
env: undefined,
|
||||
});
|
||||
const runtimeToPlatformsAndFingerprintInfoMapping =
|
||||
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects(
|
||||
runtimeVersionInfoObjects
|
||||
realizedPlatforms.map(platform => ({
|
||||
platform,
|
||||
runtimeVersionInfo: {
|
||||
runtimeVersion: selectedRuntime,
|
||||
expoUpdatesRuntimeFingerprint: null,
|
||||
expoUpdatesRuntimeFingerprintHash: null,
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'];
|
||||
@@ -215,10 +219,8 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
try {
|
||||
newUpdates = await this.publishRollbacksAsync({
|
||||
graphqlClient,
|
||||
isGitWorkingTreeDirty,
|
||||
gitCommitHash,
|
||||
updateMessage,
|
||||
branchId,
|
||||
branchId: branch.id,
|
||||
codeSigningInfo,
|
||||
runtimeToPlatformsAndFingerprintInfoMapping,
|
||||
realizedPlatforms,
|
||||
@@ -232,13 +234,6 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
|
||||
} else {
|
||||
if (new Set(newUpdates.map(update => update.group)).size > 1) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
'👉 Since multiple runtime versions are defined, multiple update groups have been published.'
|
||||
);
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
for (const runtime of uniqBy(
|
||||
@@ -276,14 +271,6 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
: []),
|
||||
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
|
||||
{ label: 'Message', value: updateMessage ?? '' },
|
||||
...(gitCommitHash
|
||||
? [
|
||||
{
|
||||
label: 'Commit',
|
||||
value: `${gitCommitHash}${isGitWorkingTreeDirty ? '*' : ''}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ label: 'EAS Dashboard', value: updateGroupLink },
|
||||
])
|
||||
);
|
||||
@@ -294,8 +281,6 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
|
||||
private async publishRollbacksAsync({
|
||||
graphqlClient,
|
||||
isGitWorkingTreeDirty,
|
||||
gitCommitHash,
|
||||
updateMessage,
|
||||
branchId,
|
||||
codeSigningInfo,
|
||||
@@ -303,8 +288,6 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
realizedPlatforms,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
isGitWorkingTreeDirty: boolean | undefined;
|
||||
gitCommitHash: string | undefined;
|
||||
updateMessage: string | undefined;
|
||||
branchId: string;
|
||||
codeSigningInfo: CodeSigningInfo | undefined;
|
||||
@@ -329,8 +312,6 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
rollBackToEmbeddedInfoGroup: localRollbackInfoGroup,
|
||||
runtimeVersion,
|
||||
message: updateMessage,
|
||||
gitCommitHash,
|
||||
isGitWorkingTreeDirty,
|
||||
awaitingCodeSigningInfo: !!codeSigningInfo,
|
||||
};
|
||||
}
|
||||
@@ -381,22 +362,63 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
return newUpdates;
|
||||
}
|
||||
|
||||
private static async selectRuntimeAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
batchSize = 5,
|
||||
}: {
|
||||
appId: string;
|
||||
branchName: string;
|
||||
batchSize?: number;
|
||||
}
|
||||
): Promise<RuntimeFragment | null> {
|
||||
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
|
||||
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
|
||||
appId,
|
||||
name: branchName,
|
||||
first: queryParams.first,
|
||||
after: queryParams.after,
|
||||
last: queryParams.last,
|
||||
before: queryParams.before,
|
||||
});
|
||||
};
|
||||
const getTitleAsync = async (runtime: RuntimeFragment): Promise<string> => {
|
||||
return runtime.version;
|
||||
};
|
||||
return await selectPaginatedAsync({
|
||||
queryAsync,
|
||||
getTitleAsync,
|
||||
printedType: 'target runtime',
|
||||
pageSize: batchSize,
|
||||
});
|
||||
}
|
||||
|
||||
private sanitizeFlags(flags: RawUpdateFlags): UpdateFlags {
|
||||
const nonInteractive = flags['non-interactive'] ?? false;
|
||||
|
||||
const { auto, branch: branchName, channel: channelName, message: updateMessage } = flags;
|
||||
if (nonInteractive && !auto && !(updateMessage && (branchName || channelName))) {
|
||||
const {
|
||||
branch: branchName,
|
||||
channel: channelName,
|
||||
message: updateMessage,
|
||||
'runtime-version': runtimeVersion,
|
||||
} = flags;
|
||||
if (nonInteractive && !(updateMessage && (branchName || channelName))) {
|
||||
Errors.error(
|
||||
'--branch and --message, or --channel and --message are required when updating in non-interactive mode unless --auto is specified',
|
||||
'--branch and --message, or --channel and --message are required in non-interactive mode',
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
if (nonInteractive && !runtimeVersion) {
|
||||
Errors.error('--runtimeVersion is required in non-interactive mode', { exit: 1 });
|
||||
}
|
||||
|
||||
return {
|
||||
auto,
|
||||
branchName,
|
||||
channelName,
|
||||
updateMessage,
|
||||
runtimeVersion,
|
||||
platform: flags.platform as RequestedPlatform,
|
||||
privateKeyPath: flags['private-key-path'],
|
||||
nonInteractive,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs/promises';
|
||||
import fsExtra from 'fs-extra';
|
||||
import path from 'path';
|
||||
import prompts from 'prompts';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { WorkflowFile } from '../../utils/workflowFile';
|
||||
|
||||
const DEFAULT_WORKFLOW_NAME = 'workflow.yml';
|
||||
const HELLO_WORLD_TEMPLATE = `name: Hello World
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
|
||||
jobs:
|
||||
hello_world:
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- run: echo "Hello, World"
|
||||
`;
|
||||
|
||||
export class WorkflowCreate extends EasCommand {
|
||||
static override description = 'create a new workflow configuration YAML file';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'name',
|
||||
description: 'Name of the workflow file (must end with .yml or .yaml)',
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { name: argFileName },
|
||||
flags,
|
||||
} = await this.parse(WorkflowCreate);
|
||||
|
||||
const { projectDir } = await this.getContextAsync(WorkflowCreate, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
let fileName = argFileName;
|
||||
|
||||
if (!fileName) {
|
||||
const response = await prompts({
|
||||
type: 'text',
|
||||
name: 'fileName',
|
||||
message: 'What would you like to name your workflow file?',
|
||||
initial: DEFAULT_WORKFLOW_NAME,
|
||||
validate: value => {
|
||||
try {
|
||||
WorkflowFile.validateYamlExtension(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : 'Invalid file name';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.fileName) {
|
||||
Log.warn('Workflow creation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fileName = response.fileName;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureWorkflowsDirectoryExistsAsync({ projectDir });
|
||||
await this.createWorkflowFileAsync({ fileName, projectDir });
|
||||
} catch (error) {
|
||||
Log.error('Failed to create workflow file.');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureWorkflowsDirectoryExistsAsync({
|
||||
projectDir,
|
||||
}: {
|
||||
projectDir: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await fs.access(path.join(projectDir, '.eas', 'workflows'));
|
||||
} catch {
|
||||
await fs.mkdir(path.join(projectDir, '.eas', 'workflows'), { recursive: true });
|
||||
Log.withTick(`Created directory ${chalk.bold(path.join(projectDir, '.eas', 'workflows'))}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async createWorkflowFileAsync({
|
||||
fileName,
|
||||
projectDir,
|
||||
}: {
|
||||
fileName: string;
|
||||
projectDir: string;
|
||||
}): Promise<void> {
|
||||
WorkflowFile.validateYamlExtension(fileName);
|
||||
|
||||
const filePath = path.join(projectDir, '.eas', 'workflows', fileName);
|
||||
|
||||
if (await fsExtra.pathExists(filePath)) {
|
||||
throw new Error(`Workflow file already exists: ${filePath}`);
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, HELLO_WORLD_TEMPLATE);
|
||||
Log.withTick(`Created ${chalk.bold(filePath)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
import fs from 'node:fs';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { getWorkflowRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { WorkflowProjectSourceType } from '../../graphql/generated';
|
||||
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
|
||||
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
|
||||
import Log, { link } from '../../log';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { uploadAccountScopedEasJsonAsync } from '../../project/uploadAccountScopedEasJsonAsync';
|
||||
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { WorkflowFile } from '../../utils/workflowFile';
|
||||
|
||||
export default class WorkflowRun extends EasCommand {
|
||||
static override description = 'Run an EAS workflow';
|
||||
|
||||
// TODO(@sjchmiela): Keep command hidden until workflows are live
|
||||
static override hidden = true;
|
||||
static override state = 'beta';
|
||||
|
||||
static override args = [{ name: 'file', description: 'Path to the workflow file to run' }];
|
||||
|
||||
static override flags = {
|
||||
@@ -34,8 +32,6 @@ export default class WorkflowRun extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(WorkflowRun);
|
||||
|
||||
Log.warn('Workflows are in beta and subject to breaking changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -48,7 +44,12 @@ export default class WorkflowRun extends EasCommand {
|
||||
|
||||
let yamlConfig: string;
|
||||
try {
|
||||
yamlConfig = await fs.promises.readFile(path.join(projectDir, args.file), 'utf8');
|
||||
const workflowFileContents = await WorkflowFile.readWorkflowFileContentsAsync({
|
||||
projectDir,
|
||||
filePath: args.file,
|
||||
});
|
||||
Log.log(`Using workflow file from ${workflowFileContents.filePath}`);
|
||||
yamlConfig = workflowFileContents.yamlConfig;
|
||||
} catch (err) {
|
||||
Log.error('Failed to read workflow file.');
|
||||
|
||||
@@ -61,8 +62,26 @@ export default class WorkflowRun extends EasCommand {
|
||||
} = await getDynamicPrivateProjectConfigAsync();
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
try {
|
||||
await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
yamlConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CombinedError) {
|
||||
WorkflowFile.maybePrintWorkflowFileValidationErrors({
|
||||
error,
|
||||
accountName: account.name,
|
||||
projectName,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let projectArchiveBucketKey: string;
|
||||
let easJsonBucketKey: string;
|
||||
let packageJsonBucketKey: string;
|
||||
|
||||
try {
|
||||
({ projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
|
||||
@@ -70,10 +89,17 @@ export default class WorkflowRun extends EasCommand {
|
||||
vcsClient,
|
||||
accountId: account.id,
|
||||
}));
|
||||
({ easJsonBucketKey } = await uploadAccountScopedEasJsonAsync({
|
||||
({ fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
projectDir,
|
||||
filePath: path.join(projectDir, 'eas.json'),
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
}));
|
||||
({ fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: path.join(projectDir, 'package.json'),
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
}));
|
||||
} catch (err) {
|
||||
Log.error('Failed to upload project sources.');
|
||||
@@ -95,6 +121,7 @@ export default class WorkflowRun extends EasCommand {
|
||||
type: WorkflowProjectSourceType.Gcs,
|
||||
projectArchiveBucketKey,
|
||||
easJsonBucketKey,
|
||||
packageJsonBucketKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -102,7 +129,7 @@ export default class WorkflowRun extends EasCommand {
|
||||
|
||||
Log.newLine();
|
||||
Log.succeed(
|
||||
`Workflow started successfully. ${link(
|
||||
`Workflow run started successfully. See logs: ${link(
|
||||
getWorkflowRunUrl(account.name, projectName, workflowRunId)
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import * as YAML from 'yaml';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { WorkflowFile } from '../../utils/workflowFile';
|
||||
|
||||
export class WorkflowValidate extends EasCommand {
|
||||
static override description = 'validate a workflow configuration yaml file';
|
||||
static override hidden = true;
|
||||
|
||||
static override args = [
|
||||
{
|
||||
@@ -18,81 +20,83 @@ export class WorkflowValidate extends EasCommand {
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.DynamicProjectConfig,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { path: filePath },
|
||||
flags,
|
||||
} = await this.parse(WorkflowValidate);
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir,
|
||||
} = await this.getContextAsync(WorkflowValidate, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const {
|
||||
projectId,
|
||||
exp: { slug: projectName },
|
||||
} = await getDynamicPrivateProjectConfigAsync();
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
const spinner = ora().start('Validating the workflow YAML file…');
|
||||
|
||||
try {
|
||||
await validateYAMLAsync(filePath);
|
||||
const workflowFileContents = await WorkflowFile.readWorkflowFileContentsAsync({
|
||||
projectDir,
|
||||
filePath,
|
||||
});
|
||||
Log.log(`Using workflow file from ${workflowFileContents.filePath}`);
|
||||
|
||||
const parsedYaml = YAML.parse(workflowFileContents.yamlConfig);
|
||||
|
||||
// Check if the parsed result is empty or null
|
||||
if (
|
||||
parsedYaml === null ||
|
||||
parsedYaml === undefined ||
|
||||
(typeof parsedYaml === 'object' && Object.keys(parsedYaml).length === 0)
|
||||
) {
|
||||
throw new Error('YAML file is empty or contains only comments.');
|
||||
}
|
||||
|
||||
await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
yamlConfig: workflowFileContents.yamlConfig,
|
||||
});
|
||||
|
||||
spinner.succeed('Workflow configuration YAML is valid.');
|
||||
} catch (error) {
|
||||
spinner.fail('Workflow configuration YAML is not valid.');
|
||||
|
||||
if (error instanceof YAML.YAMLParseError) {
|
||||
Log.error(`YAML syntax error: ${error.message}`);
|
||||
} else if (error instanceof CombinedError) {
|
||||
WorkflowFile.maybePrintWorkflowFileValidationErrors({
|
||||
error,
|
||||
accountName: account.name,
|
||||
projectName,
|
||||
});
|
||||
|
||||
throw error;
|
||||
} else if (error instanceof Error) {
|
||||
Log.error(error.message);
|
||||
} else {
|
||||
Log.error(`Unexpected error: ${String(error)}`);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkIfFileExistsAsync(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
throw new Error(`File does not exist: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkIfValidYAMLFileExtension(filePath: string): void {
|
||||
const fileExtension = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (fileExtension !== '.yml' && fileExtension !== '.yaml') {
|
||||
throw new Error('File must have a .yml or .yaml extension');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateYAMLAsync(filePath: string): Promise<void> {
|
||||
await checkIfFileExistsAsync(filePath);
|
||||
checkIfValidYAMLFileExtension(filePath);
|
||||
|
||||
try {
|
||||
const fileContents = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// First check if the file is empty or only contains whitespace
|
||||
if (!fileContents.trim()) {
|
||||
throw new Error('YAML file is empty or contains only comments.');
|
||||
}
|
||||
|
||||
const parsedYaml = YAML.parse(fileContents);
|
||||
|
||||
// Check if the parsed result is empty or null
|
||||
if (
|
||||
parsedYaml === null ||
|
||||
parsedYaml === undefined ||
|
||||
(typeof parsedYaml === 'object' && Object.keys(parsedYaml).length === 0)
|
||||
) {
|
||||
throw new Error('YAML file is empty or contains only comments.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'YAML file is empty or contains only comments.'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof YAML.YAMLParseError || error instanceof Error) {
|
||||
throw new Error(`YAML parsing error: ${error.message}`);
|
||||
} else {
|
||||
throw new Error(`YAML parsing error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface AndroidCredentials {
|
||||
|
||||
interface Options {
|
||||
app: AppLookupParams;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default class AndroidCredentialsProvider {
|
||||
@@ -37,7 +38,7 @@ export default class AndroidCredentialsProvider {
|
||||
}
|
||||
|
||||
private async getRemoteAsync(): Promise<AndroidCredentials> {
|
||||
const setupBuildCredentialsAction = new SetUpBuildCredentials({ app: this.options.app });
|
||||
const setupBuildCredentialsAction = new SetUpBuildCredentials(this.options);
|
||||
const buildCredentials = await setupBuildCredentialsAction.runAsync(this.ctx);
|
||||
return this.toAndroidCredentials(buildCredentials);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export class CredentialsContext {
|
||||
public readonly analytics: Analytics;
|
||||
public readonly vcsClient: Client;
|
||||
public readonly easJsonCliConfig?: EasJson['cli'];
|
||||
public readonly usesBroadcastPushNotifications: boolean;
|
||||
|
||||
private shouldAskAuthenticateAppStore: boolean = true;
|
||||
|
||||
@@ -61,6 +62,8 @@ export class CredentialsContext {
|
||||
this.nonInteractive = options.nonInteractive ?? false;
|
||||
this.projectInfo = options.projectInfo;
|
||||
this.freezeCredentials = options.freezeCredentials ?? false;
|
||||
this.usesBroadcastPushNotifications =
|
||||
options.projectInfo?.exp.ios?.usesBroadcastPushNotifications ?? false;
|
||||
}
|
||||
|
||||
get hasProjectContext(): boolean {
|
||||
|
||||
@@ -35,7 +35,7 @@ export class SetUpTargetBuildCredentials {
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
projectName: app.projectName,
|
||||
},
|
||||
{ entitlements }
|
||||
{ entitlements, usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications }
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
||||
+283
-73
@@ -45,7 +45,9 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
await syncCapabilitiesForEntitlementsAsync(bundleId, entitlements);
|
||||
await syncCapabilitiesForEntitlementsAsync(bundleId, entitlements, {
|
||||
usesBroadcastPushNotifications: false,
|
||||
});
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
{
|
||||
@@ -62,63 +64,70 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(bundleId, {
|
||||
'com.apple.developer.healthkit': true,
|
||||
// 'com.apple.developer.healthkit.access': [],
|
||||
'com.apple.developer.in-app-payments': ['merchant.com.example.development'],
|
||||
'com.apple.developer.icloud-container-identifiers': [],
|
||||
'com.apple.developer.ClassKit-environment': 'development',
|
||||
'com.apple.developer.default-data-protection':
|
||||
'NSFileProtectionCompleteUntilFirstUserAuthentication',
|
||||
'com.apple.developer.networking.networkextension': ['dns-proxy'],
|
||||
'com.apple.developer.networking.vpn.api': ['allow-vpn'],
|
||||
'com.apple.developer.networking.HotspotConfiguration': true,
|
||||
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
||||
'com.apple.developer.homekit': true,
|
||||
'com.apple.developer.networking.multipath': true,
|
||||
'com.apple.external-accessory.wireless-configuration': true,
|
||||
'inter-app-audio': true,
|
||||
'com.apple.developer.pass-type-identifiers': ['$(TeamIdentifierPrefix)*'],
|
||||
'com.apple.developer.user-fonts': ['app-usage'],
|
||||
'com.apple.developer.devicecheck.appattest-environment': 'development',
|
||||
'com.apple.developer.nfc.readersession.formats': ['NDEF', 'TAG'],
|
||||
'com.apple.developer.applesignin': ['Default'],
|
||||
'com.apple.developer.siri': true,
|
||||
'com.apple.developer.networking.wifi-info': true,
|
||||
'com.apple.developer.usernotifications.communication': true,
|
||||
'com.apple.developer.usernotifications.time-sensitive': true,
|
||||
'com.apple.developer.group-session': true,
|
||||
'com.apple.developer.family-controls': true,
|
||||
'com.apple.developer.authentication-services.autofill-credential-provider': true,
|
||||
// 'com.apple.developer.game-center': true,
|
||||
'com.apple.security.application-groups': [
|
||||
'group.CY-A5149AC2-49FC-11E7-B3F3-0335A16FFB8D.com.cydia.Extender',
|
||||
],
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
// 'com.apple.developer.healthkit.access': [],
|
||||
'com.apple.developer.in-app-payments': ['merchant.com.example.development'],
|
||||
'com.apple.developer.icloud-container-identifiers': [],
|
||||
'com.apple.developer.ClassKit-environment': 'development',
|
||||
'com.apple.developer.default-data-protection':
|
||||
'NSFileProtectionCompleteUntilFirstUserAuthentication',
|
||||
'com.apple.developer.networking.networkextension': ['dns-proxy'],
|
||||
'com.apple.developer.networking.vpn.api': ['allow-vpn'],
|
||||
'com.apple.developer.networking.HotspotConfiguration': true,
|
||||
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
||||
'com.apple.developer.homekit': true,
|
||||
'com.apple.developer.networking.multipath': true,
|
||||
'com.apple.external-accessory.wireless-configuration': true,
|
||||
'inter-app-audio': true,
|
||||
'com.apple.developer.pass-type-identifiers': ['$(TeamIdentifierPrefix)*'],
|
||||
'com.apple.developer.user-fonts': ['app-usage'],
|
||||
'com.apple.developer.devicecheck.appattest-environment': 'development',
|
||||
'com.apple.developer.nfc.readersession.formats': ['NDEF', 'TAG'],
|
||||
'com.apple.developer.applesignin': ['Default'],
|
||||
'com.apple.developer.siri': true,
|
||||
'com.apple.developer.networking.wifi-info': true,
|
||||
'com.apple.developer.usernotifications.communication': true,
|
||||
'com.apple.developer.usernotifications.time-sensitive': true,
|
||||
'com.apple.developer.group-session': true,
|
||||
'com.apple.developer.family-controls': true,
|
||||
'com.apple.developer.authentication-services.autofill-credential-provider': true,
|
||||
// 'com.apple.developer.game-center': true,
|
||||
'com.apple.security.application-groups': [
|
||||
'group.CY-A5149AC2-49FC-11E7-B3F3-0335A16FFB8D.com.cydia.Extender',
|
||||
],
|
||||
|
||||
'com.apple.developer.fileprovider.testing-mode': true,
|
||||
'com.apple.developer.healthkit.recalibrate-estimates': true,
|
||||
'com.apple.developer.maps': true,
|
||||
'com.apple.developer.user-management': true,
|
||||
'com.apple.developer.networking.custom-protocol': true,
|
||||
'com.apple.developer.system-extension.install': true,
|
||||
'com.apple.developer.push-to-talk': true,
|
||||
'com.apple.developer.driverkit.transport.usb': true,
|
||||
'com.apple.developer.kernel.increased-memory-limit': true,
|
||||
'com.apple.developer.driverkit.communicates-with-drivers': true,
|
||||
'com.apple.developer.media-device-discovery-extension': true,
|
||||
'com.apple.developer.driverkit.allow-third-party-userclients': true,
|
||||
'com.apple.developer.weatherkit': true,
|
||||
'com.apple.developer.on-demand-install-capable': true,
|
||||
'com.apple.developer.driverkit.family.scsicontroller': true,
|
||||
'com.apple.developer.driverkit.family.serial': true,
|
||||
'com.apple.developer.driverkit.family.networking': true,
|
||||
'com.apple.developer.driverkit.family.hid.eventservice': true,
|
||||
'com.apple.developer.driverkit.family.hid.device': true,
|
||||
'com.apple.developer.driverkit': true,
|
||||
'com.apple.developer.driverkit.transport.hid': true,
|
||||
'com.apple.developer.driverkit.family.audio': true,
|
||||
'com.apple.developer.shared-with-you': true,
|
||||
});
|
||||
'com.apple.developer.fileprovider.testing-mode': true,
|
||||
'com.apple.developer.healthkit.recalibrate-estimates': true,
|
||||
'com.apple.developer.maps': true,
|
||||
'com.apple.developer.user-management': true,
|
||||
'com.apple.developer.networking.custom-protocol': true,
|
||||
'com.apple.developer.system-extension.install': true,
|
||||
'com.apple.developer.push-to-talk': true,
|
||||
'com.apple.developer.driverkit.transport.usb': true,
|
||||
'com.apple.developer.kernel.increased-memory-limit': true,
|
||||
'com.apple.developer.driverkit.communicates-with-drivers': true,
|
||||
'com.apple.developer.media-device-discovery-extension': true,
|
||||
'com.apple.developer.driverkit.allow-third-party-userclients': true,
|
||||
'com.apple.developer.weatherkit': true,
|
||||
'com.apple.developer.on-demand-install-capable': true,
|
||||
'com.apple.developer.driverkit.family.scsicontroller': true,
|
||||
'com.apple.developer.driverkit.family.serial': true,
|
||||
'com.apple.developer.driverkit.family.networking': true,
|
||||
'com.apple.developer.driverkit.family.hid.eventservice': true,
|
||||
'com.apple.developer.driverkit.family.hid.device': true,
|
||||
'com.apple.developer.driverkit': true,
|
||||
'com.apple.developer.driverkit.transport.hid': true,
|
||||
'com.apple.developer.driverkit.family.audio': true,
|
||||
'com.apple.developer.shared-with-you': true,
|
||||
'aps-environment': 'production',
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
{ capabilityType: 'HEALTHKIT', option: 'ON' },
|
||||
@@ -251,6 +260,10 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
capabilityType: 'SHARED_WITH_YOU',
|
||||
option: 'ON',
|
||||
},
|
||||
{
|
||||
capabilityType: 'PUSH_NOTIFICATIONS',
|
||||
option: 'ON',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results.enabled).toMatchInlineSnapshot(`
|
||||
@@ -304,6 +317,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
"DriverKit Transport HID (development)",
|
||||
"DriverKit Family Audio (development)",
|
||||
"Shared with You",
|
||||
"Push Notifications",
|
||||
]
|
||||
`);
|
||||
expect(results.disabled).toStrictEqual([]);
|
||||
@@ -318,9 +332,15 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(bundleId, {
|
||||
'com.apple.developer.healthkit': true,
|
||||
});
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toBeCalled();
|
||||
|
||||
@@ -344,9 +364,15 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
} as any;
|
||||
|
||||
await expect(
|
||||
syncCapabilitiesForEntitlementsAsync(bundleId, {
|
||||
'com.apple.developer.healthkit': true,
|
||||
})
|
||||
syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
)
|
||||
).rejects.toThrowError(
|
||||
`https://developer-mdn.apple.com/account/resources/identifiers/bundleId/edit/XXX333`
|
||||
);
|
||||
@@ -361,9 +387,15 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(bundleId, {
|
||||
'com.apple.developer.healthkit': true,
|
||||
});
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
{ capabilityType: 'HEALTHKIT', option: 'ON' },
|
||||
@@ -382,9 +414,15 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(bundleId, {
|
||||
'com.apple.developer.healthkit': true,
|
||||
});
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
{ capabilityType: 'HEALTHKIT', option: 'ON' },
|
||||
@@ -406,7 +444,13 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(bundleId, {});
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toBeCalled();
|
||||
|
||||
@@ -423,9 +467,15 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(bundleId, {
|
||||
'com.apple.developer.healthkit': true,
|
||||
});
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
{ capabilityType: 'HEALTHKIT', option: 'ON' },
|
||||
@@ -434,4 +484,164 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
expect(results.enabled).toStrictEqual(['HealthKit']);
|
||||
expect(results.disabled).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('enables push notifications capability with broadcast option when the capability is disabled and usesBroadcastPushNotifications is true', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => []),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'aps-environment': 'production',
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'PUSH_NOTIFICATION_FEATURE_BROADCAST' },
|
||||
]);
|
||||
|
||||
expect(results.enabled).toStrictEqual(['Push Notifications']);
|
||||
expect(results.disabled).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('updates push notifications capability with broadcast option when the capability is enabled without settings and usesBroadcastPushNotifications is true', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', { settings: null }),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'aps-environment': 'production',
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'PUSH_NOTIFICATION_FEATURE_BROADCAST' },
|
||||
]);
|
||||
|
||||
expect(results.enabled).toStrictEqual(['Push Notifications']);
|
||||
expect(results.disabled).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('disables push notifications capability', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', { settings: null }),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{},
|
||||
{
|
||||
usesBroadcastPushNotifications: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'OFF' },
|
||||
]);
|
||||
|
||||
expect(results.disabled).toStrictEqual(['Push Notifications']);
|
||||
expect(results.enabled).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('does nothing when push notifications capability is enabled with broadcast options and usesBroadcastPushNotifications is true', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
|
||||
settings: [],
|
||||
}),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'aps-environment': 'production',
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toBeCalled();
|
||||
|
||||
expect(results.disabled).toStrictEqual([]);
|
||||
expect(results.enabled).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('does nothing when push notifications capability is enabled without broadcast options and usesBroadcastPushNotifications is false', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
|
||||
settings: null,
|
||||
}),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'aps-environment': 'production',
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toBeCalled();
|
||||
|
||||
expect(results.disabled).toStrictEqual([]);
|
||||
expect(results.enabled).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('updates push notifications capability without broadcast options when it is enabled and usesBroadcastPushNotifications is false', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
|
||||
settings: [],
|
||||
}),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'aps-environment': 'production',
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'ON' },
|
||||
]);
|
||||
|
||||
expect(results.disabled).toStrictEqual([]);
|
||||
expect(results.enabled).toStrictEqual(['Push Notifications']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Session } from '@expo/apple-utils';
|
||||
import nock from 'nock';
|
||||
|
||||
import { createAppAsync } from '../ensureAppExists';
|
||||
|
||||
const FIXTURE_SUCCESS = {
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: '6741087677',
|
||||
attributes: {
|
||||
name: 'expo (xxx)',
|
||||
bundleId: 'com.bacon.jan27.x',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const FIXTURE_INVALID_NAME = {
|
||||
errors: [
|
||||
{
|
||||
id: 'b3e7ca18-e4ce-4e55-83ce-8fff35dbaeca',
|
||||
status: '409',
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.INVALID_CHARACTERS',
|
||||
title: 'An attribute value has invalid characters.',
|
||||
detail:
|
||||
'App Name contains certain Unicode symbols, emoticons, diacritics, special characters, or private use characters that are not permitted.',
|
||||
source: {
|
||||
pointer: '/included/1/name',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const FIXTURE_ALREADY_USED_ON_ACCOUNT = {
|
||||
errors: [
|
||||
{
|
||||
id: 'b91aefc5-0e94-48d9-8613-5b1a464a20f0',
|
||||
status: '409',
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE.SAME_ACCOUNT',
|
||||
title:
|
||||
'The provided entity includes an attribute with a value that has already been used on this account.',
|
||||
detail:
|
||||
'The app name you entered is already being used for another app in your account. If you would like to use the name for this app you will need to submit an update to your other app to change the name, or remove it from App Store Connect.',
|
||||
source: {
|
||||
pointer: '/included/1/name',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const FIXTURE_ALREADY_USED_ON_ANOTHER_ACCOUNT = {
|
||||
errors: [
|
||||
{
|
||||
id: '72b960f2-9e51-4f19-8d83-7cc08d42fec4',
|
||||
status: '409',
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE.DIFFERENT_ACCOUNT',
|
||||
title:
|
||||
'The provided entity includes an attribute with a value that has already been used on a different account.',
|
||||
detail:
|
||||
'The App Name you entered is already being used. If you have trademark rights to this name and would like it released for your use, submit a claim.',
|
||||
source: {
|
||||
pointer: '/included/1/name',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_CONTEXT = {
|
||||
providerId: 1337,
|
||||
teamId: 'test-team-id',
|
||||
token: 'test-token',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Mock setup cookies API calls.
|
||||
nock('https://appstoreconnect.apple.com')
|
||||
.get(`/olympus/v1/session`)
|
||||
.reply(200, {
|
||||
provider: {
|
||||
providerId: 1337,
|
||||
publicProviderId: 'xxx-xxx-xxx-xxx-xxx',
|
||||
name: 'Evan Bacon',
|
||||
contentTypes: ['SOFTWARE'],
|
||||
subType: 'INDIVIDUAL',
|
||||
},
|
||||
});
|
||||
|
||||
await Session.fetchCurrentSessionInfoAsync();
|
||||
});
|
||||
|
||||
function getNameFromBody(body: any): any {
|
||||
return body.included.find((item: any) => item.id === '${new-appInfoLocalization-id}')?.attributes
|
||||
?.name;
|
||||
}
|
||||
|
||||
it('asserts invalid name cases', async () => {
|
||||
const scope = nock('https://appstoreconnect.apple.com')
|
||||
.post(`/iris/v1/apps`, body => {
|
||||
expect(getNameFromBody(body)).toBe('Expo 🚀');
|
||||
|
||||
return true;
|
||||
})
|
||||
.reply(409, FIXTURE_INVALID_NAME);
|
||||
|
||||
// Already used on same account
|
||||
nock('https://appstoreconnect.apple.com')
|
||||
.post(`/iris/v1/apps`, body => {
|
||||
expect(getNameFromBody(body)).toBe('Expo -');
|
||||
return true;
|
||||
})
|
||||
.reply(409, FIXTURE_ALREADY_USED_ON_ACCOUNT);
|
||||
|
||||
// Already used on different account
|
||||
nock('https://appstoreconnect.apple.com')
|
||||
.post(`/iris/v1/apps`, body => {
|
||||
expect(getNameFromBody(body)).toMatch(/Expo - \([\w\d]+\)/);
|
||||
return true;
|
||||
})
|
||||
.reply(409, FIXTURE_ALREADY_USED_ON_ANOTHER_ACCOUNT);
|
||||
|
||||
// Success
|
||||
nock('https://appstoreconnect.apple.com')
|
||||
.post(`/iris/v1/apps`, body => {
|
||||
expect(getNameFromBody(body)).toMatch(/Expo - \([\w\d]+\)/);
|
||||
return true;
|
||||
})
|
||||
.reply(200, FIXTURE_SUCCESS);
|
||||
|
||||
await createAppAsync(MOCK_CONTEXT, {
|
||||
bundleId: 'com.bacon.jan27.x',
|
||||
name: 'Expo 🚀',
|
||||
companyName: 'expo',
|
||||
});
|
||||
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('works on first try', async () => {
|
||||
nock('https://appstoreconnect.apple.com').post(`/iris/v1/apps`).reply(200, FIXTURE_SUCCESS);
|
||||
|
||||
await createAppAsync(MOCK_CONTEXT, {
|
||||
bundleId: 'com.bacon.jan27.x',
|
||||
name: 'Expo',
|
||||
companyName: 'expo',
|
||||
});
|
||||
});
|
||||
|
||||
it('doubles up entropy', async () => {
|
||||
nock('https://appstoreconnect.apple.com')
|
||||
.post(`/iris/v1/apps`)
|
||||
.reply(409, FIXTURE_ALREADY_USED_ON_ANOTHER_ACCOUNT);
|
||||
|
||||
nock('https://appstoreconnect.apple.com')
|
||||
.post(`/iris/v1/apps`)
|
||||
.reply(409, FIXTURE_ALREADY_USED_ON_ANOTHER_ACCOUNT);
|
||||
|
||||
nock('https://appstoreconnect.apple.com')
|
||||
.post(`/iris/v1/apps`, body => {
|
||||
expect(getNameFromBody(body)).toMatch(/Expo \([\w\d]+\) \([\w\d]+\)/);
|
||||
return true;
|
||||
})
|
||||
.reply(200, FIXTURE_SUCCESS);
|
||||
|
||||
await createAppAsync(MOCK_CONTEXT, {
|
||||
bundleId: 'com.bacon.jan27.x',
|
||||
name: 'Expo',
|
||||
companyName: 'expo',
|
||||
});
|
||||
});
|
||||
+152
-1
@@ -1,11 +1,26 @@
|
||||
import { JsonFileCache } from '@expo/apple-utils';
|
||||
import * as fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { promptAsync } from '../../../../prompts';
|
||||
import { AppleTeamType } from '../authenticateTypes';
|
||||
import { resolveAppleTeamAsync, resolveAscApiKeyAsync } from '../resolveCredentials';
|
||||
import * as Keychain from '../keychain';
|
||||
import {
|
||||
resolveAppleTeamAsync,
|
||||
resolveAscApiKeyAsync,
|
||||
resolveUserCredentialsAsync,
|
||||
} from '../resolveCredentials';
|
||||
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/apple-utils', () => {
|
||||
return {
|
||||
getCacheAsync: jest.fn(),
|
||||
usernameCachePath: jest.fn(),
|
||||
...jest.requireActual('@expo/apple-utils'),
|
||||
};
|
||||
});
|
||||
jest.mock('../keychain');
|
||||
|
||||
const testAscApiKey = {
|
||||
keyP8: 'test-key-p8',
|
||||
@@ -89,3 +104,139 @@ describe(resolveAppleTeamAsync, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveUserCredentialsAsync, () => {
|
||||
let KeychainMock: any;
|
||||
beforeAll(() => {
|
||||
JsonFileCache.getCacheAsync = jest.fn().mockImplementation(async (filePath: string) => {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return content ? JSON.parse(content) : null;
|
||||
});
|
||||
KeychainMock = jest.mocked(Keychain);
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
KeychainMock.EXPO_NO_KEYCHAIN = '';
|
||||
});
|
||||
|
||||
it('uses credentials resolved from options', async () => {
|
||||
const result = await resolveUserCredentialsAsync({
|
||||
username: 'fakeUsername',
|
||||
password: 'fakePassword',
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
username: 'fakeUsername',
|
||||
password: 'fakePassword',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses credentials resolved from environment', async () => {
|
||||
process.env.EXPO_APPLE_ID = 'fakeUsername';
|
||||
process.env.EXPO_APPLE_PASSWORD = 'fakePassword';
|
||||
|
||||
const result = await resolveUserCredentialsAsync({});
|
||||
expect(result).toMatchObject({
|
||||
username: 'fakeUsername',
|
||||
password: 'fakePassword',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses credentials from prompt, does not update cache if they match', async () => {
|
||||
vol.fromJSON({
|
||||
'./.app-store/auth/username.json': `
|
||||
{
|
||||
"username": "fakeUsername"
|
||||
}
|
||||
`,
|
||||
});
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ username: 'fakeUsername' });
|
||||
JsonFileCache.usernameCachePath = jest.fn().mockReturnValue('./.app-store/auth/username.json');
|
||||
const cacheAsyncSpy = jest.spyOn(JsonFileCache, 'cacheAsync');
|
||||
|
||||
const result = await resolveUserCredentialsAsync({});
|
||||
expect(result).toMatchObject({
|
||||
username: 'fakeUsername',
|
||||
});
|
||||
expect(cacheAsyncSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses credentials from prompt, updates cache if they don't match", async () => {
|
||||
vol.fromJSON({
|
||||
'./.app-store/auth/username.json': `
|
||||
{
|
||||
"username": "fakeUsername"
|
||||
}
|
||||
`,
|
||||
});
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ username: 'newFakeUsername' });
|
||||
JsonFileCache.usernameCachePath = jest.fn().mockReturnValue('./.app-store/auth/username.json');
|
||||
const cacheAsyncSpy = jest.spyOn(JsonFileCache, 'cacheAsync');
|
||||
|
||||
expect(JSON.parse(await fs.readFile('./.app-store/auth/username.json', 'utf-8'))).toMatchObject(
|
||||
{ username: 'fakeUsername' }
|
||||
);
|
||||
const result = await resolveUserCredentialsAsync({});
|
||||
expect(result).toMatchObject({
|
||||
username: 'newFakeUsername',
|
||||
});
|
||||
expect(cacheAsyncSpy).toHaveBeenCalledWith('./.app-store/auth/username.json', {
|
||||
username: 'newFakeUsername',
|
||||
});
|
||||
expect(JSON.parse(await fs.readFile('./.app-store/auth/username.json', 'utf-8'))).toMatchObject(
|
||||
{ username: 'newFakeUsername' }
|
||||
);
|
||||
});
|
||||
|
||||
it("uses credentials from prompt, doesn't read or update cache if EXPO_NO_KEYCHAIN is set", async () => {
|
||||
vol.fromJSON({
|
||||
'./.app-store/auth/username.json': `
|
||||
{
|
||||
"username": "fakeUsername"
|
||||
}
|
||||
`,
|
||||
});
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ username: 'newFakeUsername' });
|
||||
JsonFileCache.usernameCachePath = jest.fn().mockReturnValue('./.app-store/auth/username.json');
|
||||
KeychainMock.EXPO_NO_KEYCHAIN = 'true';
|
||||
const getCacheAsyncSpy = jest.spyOn(JsonFileCache, 'getCacheAsync');
|
||||
const cacheAsyncSpy = jest.spyOn(JsonFileCache, 'cacheAsync');
|
||||
|
||||
expect(JSON.parse(await fs.readFile('./.app-store/auth/username.json', 'utf-8'))).toMatchObject(
|
||||
{ username: 'fakeUsername' }
|
||||
);
|
||||
const result = await resolveUserCredentialsAsync({});
|
||||
expect(result).toMatchObject({
|
||||
username: 'newFakeUsername',
|
||||
});
|
||||
expect(getCacheAsyncSpy).not.toHaveBeenCalled();
|
||||
expect(cacheAsyncSpy).not.toHaveBeenCalled();
|
||||
await expect(fs.access('./.app-store/auth/username.json')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('clears credentials from prompt if malformed before using and updating cache', async () => {
|
||||
vol.fromJSON({
|
||||
'./.app-store/auth/username.json': `
|
||||
{
|
||||
"username": "fakeUsername"
|
||||
}
|
||||
`,
|
||||
});
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ username: '\x17newFake\x01Username\x1F' });
|
||||
JsonFileCache.usernameCachePath = jest.fn().mockReturnValue('./.app-store/auth/username.json');
|
||||
const cacheAsyncSpy = jest.spyOn(JsonFileCache, 'cacheAsync');
|
||||
|
||||
expect(JSON.parse(await fs.readFile('./.app-store/auth/username.json', 'utf-8'))).toMatchObject(
|
||||
{ username: 'fakeUsername' }
|
||||
);
|
||||
const result = await resolveUserCredentialsAsync({});
|
||||
expect(result).toMatchObject({
|
||||
username: 'newFakeUsername',
|
||||
});
|
||||
expect(cacheAsyncSpy).toHaveBeenCalledWith('./.app-store/auth/username.json', {
|
||||
username: 'newFakeUsername',
|
||||
});
|
||||
expect(JSON.parse(await fs.readFile('./.app-store/auth/username.json', 'utf-8'))).toMatchObject(
|
||||
{ username: 'newFakeUsername' }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CapabilityType,
|
||||
CapabilityTypeDataProtectionOption,
|
||||
CapabilityTypeOption,
|
||||
CapabilityTypePushNotificationsOption,
|
||||
CloudContainer,
|
||||
MerchantId,
|
||||
} from '@expo/apple-utils';
|
||||
@@ -19,7 +20,10 @@ export const EXPO_NO_CAPABILITY_SYNC = getenv.boolish('EXPO_NO_CAPABILITY_SYNC',
|
||||
|
||||
type GetOptionsMethod<T extends CapabilityType = any> = (
|
||||
entitlement: JSONValue,
|
||||
entitlementsJson: JSONObject
|
||||
entitlementsJson: JSONObject,
|
||||
additionalOptions: {
|
||||
usesBroadcastPushNotifications?: boolean;
|
||||
}
|
||||
) => CapabilityOptionMap[T];
|
||||
|
||||
const validateBooleanOptions = (options: any): boolean => {
|
||||
@@ -78,7 +82,10 @@ const getDefinedOptions: GetOptionsMethod = entitlement => {
|
||||
*/
|
||||
export async function syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId: BundleId,
|
||||
entitlements: JSONObject = {}
|
||||
entitlements: JSONObject = {},
|
||||
additionalOptions: {
|
||||
usesBroadcastPushNotifications: boolean;
|
||||
}
|
||||
): Promise<{ enabled: string[]; disabled: string[] }> {
|
||||
if (EXPO_NO_CAPABILITY_SYNC) {
|
||||
return { enabled: [], disabled: [] };
|
||||
@@ -91,7 +98,8 @@ export async function syncCapabilitiesForEntitlementsAsync(
|
||||
|
||||
const { enabledCapabilityNames, request, remainingCapabilities } = getCapabilitiesToEnable(
|
||||
currentCapabilities,
|
||||
entitlements
|
||||
entitlements,
|
||||
additionalOptions
|
||||
);
|
||||
|
||||
const { disabledCapabilityNames, request: modifiedRequest } = getCapabilitiesToDisable(
|
||||
@@ -125,9 +133,25 @@ interface CapabilitiesRequest {
|
||||
option: any;
|
||||
}
|
||||
|
||||
function shouldSkipPushNotificationsCapabilityUpdate(
|
||||
existing: BundleIdCapability,
|
||||
additionalOptions: {
|
||||
usesBroadcastPushNotifications: boolean;
|
||||
}
|
||||
): boolean {
|
||||
// For push notifications, we should always update the capabaility if
|
||||
// - settings are not defined in the existing capability, but usesBroadcastPushNotifications is enabled (we want to add settings for this capability)
|
||||
// - settings are defined in the existing capability, but usesBroadcastPushNotifications is disabled (we want to remove settings for this capability)
|
||||
const noSettingsAttributes = existing.attributes.settings == null;
|
||||
return !noSettingsAttributes === additionalOptions.usesBroadcastPushNotifications;
|
||||
}
|
||||
|
||||
function getCapabilitiesToEnable(
|
||||
currentCapabilities: BundleIdCapability[],
|
||||
entitlements: JSONObject
|
||||
entitlements: JSONObject,
|
||||
additionalOptions: {
|
||||
usesBroadcastPushNotifications: boolean;
|
||||
}
|
||||
): {
|
||||
enabledCapabilityNames: string[];
|
||||
request: CapabilitiesRequest[];
|
||||
@@ -156,7 +180,17 @@ function getCapabilitiesToEnable(
|
||||
// Only skip if the existing capability is a simple boolean value,
|
||||
// if it has more complex settings then we should always update it.
|
||||
// If the `existing.attributes.settings` object is defined, then we can determine that it has extra configuration.
|
||||
if (existing && existing?.attributes.settings == null) {
|
||||
// For push notifications, we should always update the capabaility if
|
||||
// - settings are not defined in the existing capability, but usesBroadcastPushNotifications is enabled (we want to add settings for this capability)
|
||||
// - settings are defined in the existing capability, but usesBroadcastPushNotifications is disabled (we want to remove settings for this capability)
|
||||
const isPushNotificationsCapability =
|
||||
staticCapabilityInfo.capability === CapabilityType.PUSH_NOTIFICATIONS;
|
||||
if (
|
||||
existing &&
|
||||
((!isPushNotificationsCapability && existing.attributes.settings == null) ||
|
||||
(isPushNotificationsCapability &&
|
||||
shouldSkipPushNotificationsCapabilityUpdate(existing, additionalOptions)))
|
||||
) {
|
||||
// Remove the item from the list of capabilities so we don't disable it.
|
||||
remainingCapabilities.splice(existingIndex, 1);
|
||||
if (Log.isDebug) {
|
||||
@@ -173,7 +207,7 @@ function getCapabilitiesToEnable(
|
||||
|
||||
enabledCapabilityNames.push(staticCapabilityInfo.name);
|
||||
|
||||
const option = staticCapabilityInfo.getOptions(value, entitlements);
|
||||
const option = staticCapabilityInfo.getOptions(value, entitlements, additionalOptions);
|
||||
|
||||
request.push({
|
||||
capabilityType: staticCapabilityInfo.capability,
|
||||
@@ -488,7 +522,13 @@ export const CapabilityMapping: CapabilityClassifier[] = [
|
||||
entitlement: 'aps-environment',
|
||||
capability: CapabilityType.PUSH_NOTIFICATIONS,
|
||||
validateOptions: validateDevProdString,
|
||||
getOptions: getDefinedOptions,
|
||||
getOptions(entitlement, _entitlementsJson, { usesBroadcastPushNotifications }) {
|
||||
const option = entitlement ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
|
||||
if (option === CapabilityTypeOption.ON && usesBroadcastPushNotifications) {
|
||||
return CapabilityTypePushNotificationsOption.PUSH_NOTIFICATION_FEATURE_BROADCAST;
|
||||
}
|
||||
return option;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Wallet',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { App, BundleId } from '@expo/apple-utils';
|
||||
import { App, BundleId, RequestContext } from '@expo/apple-utils';
|
||||
import { JSONObject } from '@expo/json-file';
|
||||
import chalk from 'chalk';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { getRequestContext, isUserAuthCtx } from './authenticate';
|
||||
import { AuthCtx, UserAuthCtx } from './authenticateTypes';
|
||||
@@ -12,6 +13,7 @@ import { ora } from '../../../ora';
|
||||
|
||||
export interface IosCapabilitiesOptions {
|
||||
entitlements: JSONObject;
|
||||
usesBroadcastPushNotifications: boolean;
|
||||
}
|
||||
|
||||
export interface AppLookupParams {
|
||||
@@ -93,7 +95,7 @@ export async function ensureBundleIdExistsWithNameAsync(
|
||||
|
||||
export async function syncCapabilitiesAsync(
|
||||
bundleId: BundleId,
|
||||
{ entitlements }: IosCapabilitiesOptions
|
||||
{ entitlements, ...rest }: IosCapabilitiesOptions
|
||||
): Promise<void> {
|
||||
const spinner = ora(`Syncing capabilities`).start();
|
||||
|
||||
@@ -105,7 +107,8 @@ export async function syncCapabilitiesAsync(
|
||||
try {
|
||||
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
entitlements
|
||||
entitlements,
|
||||
rest
|
||||
);
|
||||
const results =
|
||||
[buildMessage('Enabled', enabled), buildMessage('Disabled', disabled)]
|
||||
@@ -119,7 +122,7 @@ export async function syncCapabilitiesAsync(
|
||||
}
|
||||
|
||||
// Always run this after syncing the capabilities...
|
||||
await syncCapabilityIdentifiersAsync(bundleId, { entitlements });
|
||||
await syncCapabilityIdentifiersAsync(bundleId, { entitlements, ...rest });
|
||||
}
|
||||
|
||||
const buildMessage = (title: string, items: string[]): string =>
|
||||
@@ -186,7 +189,7 @@ export async function ensureAppExistsAsync(
|
||||
/**
|
||||
* **Does not support App Store Connect API (CI).**
|
||||
*/
|
||||
app = await App.createAsync(context, {
|
||||
app = await createAppAsync(context, {
|
||||
bundleId: bundleIdentifier,
|
||||
name,
|
||||
primaryLocale: language,
|
||||
@@ -213,3 +216,120 @@ export async function ensureAppExistsAsync(
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
function sanitizeName(name: string): string {
|
||||
return (
|
||||
name
|
||||
// Replace emojis with a `-`
|
||||
.replace(/[\p{Emoji}]/gu, '-')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAppAsync(
|
||||
context: RequestContext,
|
||||
props: {
|
||||
bundleId: string;
|
||||
name: string;
|
||||
primaryLocale?: string;
|
||||
companyName?: string;
|
||||
sku?: string;
|
||||
},
|
||||
retryCount = 0
|
||||
): Promise<App> {
|
||||
try {
|
||||
/**
|
||||
* **Does not support App Store Connect API (CI).**
|
||||
*/
|
||||
return await App.createAsync(context, props);
|
||||
} catch (error) {
|
||||
if (retryCount >= 5) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const handleDuplicateNameErrorAsync = async (): Promise<App> => {
|
||||
const generatedName = props.name + ` (${randomBytes(3).toString('hex')})`;
|
||||
Log.warn(
|
||||
`App name "${props.name}" is already taken. Using generated name "${generatedName}" which can be changed later from https://appstoreconnect.apple.com.`
|
||||
);
|
||||
// Sanitize the name and try again.
|
||||
return await createAppAsync(
|
||||
context,
|
||||
{
|
||||
...props,
|
||||
name: generatedName,
|
||||
},
|
||||
retryCount + 1
|
||||
);
|
||||
};
|
||||
|
||||
if (isAppleError(error)) {
|
||||
// New error class that is thrown when the name is already taken but belongs to you.
|
||||
if (
|
||||
error.data.errors.some(
|
||||
e =>
|
||||
e.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE.SAME_ACCOUNT' ||
|
||||
e.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE.DIFFERENT_ACCOUNT'
|
||||
)
|
||||
) {
|
||||
return await handleDuplicateNameErrorAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if ('code' in error && typeof error.code === 'string') {
|
||||
if (
|
||||
// Name is invalid
|
||||
error.code === 'APP_CREATE_NAME_INVALID'
|
||||
// UnexpectedAppleResponse: An attribute value has invalid characters. - App Name contains certain Unicode symbols, emoticons, diacritics, special characters, or private use characters that are not permitted.
|
||||
// Name is taken
|
||||
) {
|
||||
const sanitizedName = sanitizeName(props.name);
|
||||
if (sanitizedName === props.name) {
|
||||
throw error;
|
||||
}
|
||||
Log.warn(
|
||||
`App name "${props.name}" contains invalid characters. Using sanitized name "${sanitizedName}" which can be changed later from https://appstoreconnect.apple.com.`
|
||||
);
|
||||
// Sanitize the name and try again.
|
||||
return await createAppAsync(
|
||||
context,
|
||||
{
|
||||
...props,
|
||||
name: sanitizedName,
|
||||
},
|
||||
retryCount + 1
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
// UnexpectedAppleResponse: The provided entity includes an attribute with a value that has already been used on a different account. - The App Name you entered is already being used. If you have trademark rights to
|
||||
// this name and would like it released for your use, submit a claim.
|
||||
error.code === 'APP_CREATE_NAME_UNAVAILABLE'
|
||||
) {
|
||||
return await handleDuplicateNameErrorAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAppleError(error: any): error is {
|
||||
data: {
|
||||
errors: {
|
||||
id: string;
|
||||
status: string;
|
||||
/** 'ENTITY_ERROR.ATTRIBUTE.INVALID.INVALID_CHARACTERS' */
|
||||
code: string;
|
||||
/** 'An attribute value has invalid characters.' */
|
||||
title: string;
|
||||
/** 'App Name contains certain Unicode symbols, emoticons, diacritics, special characters, or private use characters that are not permitted.' */
|
||||
detail: string;
|
||||
}[];
|
||||
};
|
||||
} {
|
||||
return 'data' in error && 'errors' in error.data && Array.isArray(error.data.errors);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { App, BetaGroup, Session, User, UserRole } from '@expo/apple-utils';
|
||||
|
||||
import { isAppleError } from './ensureAppExists';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
|
||||
// The name of the internal TestFlight group, this should probably never change.
|
||||
const AUTO_GROUP_NAME = 'Team (Expo)';
|
||||
|
||||
/**
|
||||
* Ensure a TestFlight internal group with access to all builds exists for the app and has all admin users invited to it.
|
||||
* This allows users to instantly access their builds from TestFlight after it finishes processing.
|
||||
*/
|
||||
export async function ensureTestFlightGroupExistsAsync(app: App): Promise<void> {
|
||||
if (process.env.EAS_NO_AUTO_TESTFLIGHT_SETUP) {
|
||||
Log.debug('EAS_NO_AUTO_TESTFLIGHT_SETUP is set, skipping TestFlight setup');
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = await app.getBetaGroupsAsync({
|
||||
query: {
|
||||
includes: ['betaTesters'],
|
||||
},
|
||||
});
|
||||
|
||||
if (groups.length > 0) {
|
||||
Log.debug(`Found ${groups.length} TestFlight groups`);
|
||||
Log.debug('Skipping creating a new TestFlight group');
|
||||
return;
|
||||
}
|
||||
|
||||
const group = await ensureInternalGroupAsync({
|
||||
app,
|
||||
groups,
|
||||
});
|
||||
const users = await User.getAsync(app.context);
|
||||
const admins = users.filter(user => user.attributes.roles?.includes(UserRole.ADMIN));
|
||||
|
||||
await addAllUsersToInternalGroupAsync(group, admins);
|
||||
}
|
||||
|
||||
async function ensureInternalGroupAsync({
|
||||
groups,
|
||||
app,
|
||||
}: {
|
||||
groups: BetaGroup[];
|
||||
app: App;
|
||||
}): Promise<BetaGroup> {
|
||||
let betaGroup = groups.find(group => group.attributes.name === AUTO_GROUP_NAME);
|
||||
if (!betaGroup) {
|
||||
const spinner = ora().start('Creating TestFlight group...');
|
||||
|
||||
try {
|
||||
// Apple throw an error if you create the group too quickly after creating the app. We'll retry a few times.
|
||||
await pollRetryAsync(
|
||||
async () => {
|
||||
betaGroup = await app.createBetaGroupAsync({
|
||||
name: AUTO_GROUP_NAME,
|
||||
isInternalGroup: true,
|
||||
// Automatically add latest builds to the group without needing to run the command.
|
||||
hasAccessToAllBuilds: true,
|
||||
});
|
||||
},
|
||||
{
|
||||
shouldRetry(error) {
|
||||
if (isAppleError(error)) {
|
||||
spinner.text = `TestFlight not ready, retrying in 25 seconds...`;
|
||||
|
||||
return error.data.errors.some(
|
||||
error => error.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}
|
||||
);
|
||||
spinner.succeed(`TestFlight group created: ${AUTO_GROUP_NAME}`);
|
||||
} catch (error: any) {
|
||||
spinner.fail('Failed to create TestFlight group...');
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!betaGroup) {
|
||||
throw new Error('Failed to create internal TestFlight group');
|
||||
}
|
||||
|
||||
// `hasAccessToAllBuilds` is a newer feature that allows the group to automatically have access to all builds. This cannot be patched so we need to recreate the group.
|
||||
if (!betaGroup.attributes.hasAccessToAllBuilds) {
|
||||
if (
|
||||
await confirmAsync({
|
||||
message: 'Regenerate internal TestFlight group to allow automatic access to all builds?',
|
||||
})
|
||||
) {
|
||||
await BetaGroup.deleteAsync(app.context, { id: betaGroup.id });
|
||||
return await ensureInternalGroupAsync({
|
||||
app,
|
||||
groups: await app.getBetaGroupsAsync({
|
||||
query: {
|
||||
includes: ['betaTesters'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return betaGroup;
|
||||
}
|
||||
|
||||
async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]): Promise<void> {
|
||||
let emails = users
|
||||
.filter(user => user.attributes.email)
|
||||
.map(user => ({
|
||||
email: user.attributes.email!,
|
||||
firstName: user.attributes.firstName ?? '',
|
||||
lastName: user.attributes.lastName ?? '',
|
||||
}));
|
||||
|
||||
const { betaTesters } = group.attributes;
|
||||
const existingEmails = betaTesters?.map(tester => tester.attributes.email).filter(Boolean) ?? [];
|
||||
// Filter out existing beta testers.
|
||||
if (betaTesters) {
|
||||
emails = emails.filter(
|
||||
user => !existingEmails.find(existingEmail => existingEmail === user.email)
|
||||
);
|
||||
}
|
||||
|
||||
// No new users to add to the internal group.
|
||||
if (!emails.length) {
|
||||
// No need to log which users are here on subsequent runs as devs already know the drill at this point.
|
||||
Log.debug(`All current admins are already added to the group: ${group.attributes.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug(`Adding ${emails.length} users to internal group: ${group.attributes.name}`);
|
||||
Log.debug(`Users: ${emails.map(user => user.email).join(', ')}`);
|
||||
|
||||
const data = await group.createBulkBetaTesterAssignmentsAsync(emails);
|
||||
|
||||
const success = data.attributes.betaTesters.every(tester => {
|
||||
if (tester.assignmentResult === 'FAILED') {
|
||||
if (tester.errors && Array.isArray(tester.errors) && tester.errors.length) {
|
||||
if (
|
||||
tester.errors.length === 1 &&
|
||||
tester.errors[0].key === 'Halliday.tester.already.exists'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
for (const error of tester.errors) {
|
||||
Log.error(
|
||||
`Error adding user ${tester.email} to TestFlight group "${group.attributes.name}": ${error.key}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (tester.assignmentResult === 'NOT_QUALIFIED_FOR_INTERNAL_GROUP') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
const groupUrl = await getTestFlightGroupUrlAsync(group);
|
||||
|
||||
Log.error(
|
||||
`Unable to add all admins to TestFlight group "${
|
||||
group.attributes.name
|
||||
}". You can add them manually in App Store Connect. ${groupUrl ?? ''}`
|
||||
);
|
||||
} else {
|
||||
Log.log(
|
||||
`TestFlight access enabled for: ` +
|
||||
data.attributes.betaTesters
|
||||
.map(tester => tester.email)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
);
|
||||
// TODO: When we have more TestFlight functionality, we can link to it from here.
|
||||
}
|
||||
}
|
||||
|
||||
async function getTestFlightGroupUrlAsync(group: BetaGroup): Promise<string | null> {
|
||||
if (group.context.providerId) {
|
||||
try {
|
||||
const session = await Session.getSessionForProviderIdAsync(group.context.providerId);
|
||||
|
||||
return `https://appstoreconnect.apple.com/teams/${session.provider.publicProviderId}/apps/6741088859/testflight/groups/${group.id}`;
|
||||
} catch (error) {
|
||||
// Avoid crashing if we can't get the session.
|
||||
Log.debug('Failed to get session for provider ID', error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function pollRetryAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
{
|
||||
shouldRetry,
|
||||
retries = 10,
|
||||
// 25 seconds was the minium interval I calculated when measuring against 5 second intervals.
|
||||
interval = 25000,
|
||||
}: { shouldRetry?: (error: Error) => boolean; retries?: number; interval?: number } = {}
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
if (shouldRetry && !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
lastError = error;
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw lastError;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ function resolveProfileType(
|
||||
return resolveProfileTypeIos(profileClass, isEnterprise);
|
||||
case ApplePlatform.TV_OS:
|
||||
return resolveProfileTypeAppleTv(profileClass, isEnterprise);
|
||||
case ApplePlatform.VISION_OS:
|
||||
case ApplePlatform.MAC_OS:
|
||||
throw new Error(`${applePlatform} profiles are not supported`);
|
||||
}
|
||||
|
||||
@@ -208,13 +208,18 @@ async function promptUsernameAsync(): Promise<string> {
|
||||
// the default value for quicker authentication.
|
||||
const lastAppleId = await getCachedUsernameAsync();
|
||||
|
||||
const { username } = await promptAsync({
|
||||
let { username } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'username',
|
||||
message: `Apple ID:`,
|
||||
validate: (val: string) => val !== '',
|
||||
initial: lastAppleId ?? undefined,
|
||||
});
|
||||
// https://github.com/expo/eas-cli/issues/2254
|
||||
// user got an invalid unprintable character in their username, which was saved to cache and used to prefill the prompt
|
||||
// which made them accept the prefilled value not knowing it contains the character and fail to log in
|
||||
// this replace makes sure no such characters are used or cached
|
||||
username = username.replace(/[\x00-\x1F]/gi, '');
|
||||
|
||||
if (username && username !== lastAppleId) {
|
||||
await cacheUsernameAsync(username);
|
||||
|
||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user