mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6920ebb0ba | ||
|
|
55f35949e1 | ||
|
|
ccb998a221 | ||
|
|
30bc27b4c4 | ||
|
|
da3c95ccaf | ||
|
|
bbcbe1dceb | ||
|
|
f15beea454 | ||
|
|
16e384be76 | ||
|
|
e74e6d8fc5 | ||
|
|
b6f4603107 | ||
|
|
0e7d7f765a | ||
|
|
33b4d8d719 | ||
|
|
0d765748e3 | ||
|
|
93953f1424 | ||
|
|
e9b67db05c | ||
|
|
09bf912b9e | ||
|
|
e701bacdaa | ||
|
|
cc22542031 | ||
|
|
6c53262b5b | ||
|
|
7d7d4995d8 | ||
|
|
977546c8c9 | ||
|
|
53dcd4cb43 | ||
|
|
9e18e738eb | ||
|
|
252c7187d5 | ||
|
|
90852b9557 | ||
|
|
0df16236a2 | ||
|
|
ecaf5a4318 | ||
|
|
86e5e1a7b1 | ||
|
|
ab10094071 | ||
|
|
2d536d73fe | ||
|
|
80ea8ee50b | ||
|
|
b49c5f47d1 | ||
|
|
a9da78946b | ||
|
|
b6d9accea4 | ||
|
|
4611a65bce | ||
|
|
db29b3fc18 | ||
|
|
2534a46bd3 | ||
|
|
7def4b502e | ||
|
|
4ea26d7ca3 | ||
|
|
c4c8075b1f | ||
|
|
266545d2a0 | ||
|
|
ddd7046513 | ||
|
|
7a44eeb22b | ||
|
|
5bcf8263cf | ||
|
|
f380ba0106 | ||
|
|
1a6d81f68e | ||
|
|
522818113b | ||
|
|
f93d347665 | ||
|
|
e96cee1c3b | ||
|
|
4d46e0a8ac | ||
|
|
80e35c7524 | ||
|
|
186c26bc99 | ||
|
|
be6a7f54f9 | ||
|
|
fff7aac5b6 | ||
|
|
0d90fbd3ff | ||
|
|
e71779049e | ||
|
|
7da909e3c1 | ||
|
|
e439fd14a9 | ||
|
|
ffed1b446a | ||
|
|
7b65412ec6 | ||
|
|
f1cbdcc094 | ||
|
|
ec5573efa6 | ||
|
|
242da62271 | ||
|
|
23d1221ef9 | ||
|
|
5402d8c74f | ||
|
|
176026645c | ||
|
|
b0d4ed6c0e | ||
|
|
2f7ac71921 | ||
|
|
b599315a85 | ||
|
|
e86d8aa77a | ||
|
|
ab4ac87d52 | ||
|
|
af98fb1621 | ||
|
|
0ce7c1b3b4 | ||
|
|
2bdaa0d428 | ||
|
|
2486a4de97 | ||
|
|
6137b215e9 | ||
|
|
265ba16594 | ||
|
|
a7bc3d0c98 | ||
|
|
ee618880d7 | ||
|
|
2c95c8c310 | ||
|
|
b90e37d9fc | ||
|
|
34563070f4 | ||
|
|
66b269f4bf | ||
|
|
168cb4d4a0 | ||
|
|
91b531da43 | ||
|
|
2e0d567361 | ||
|
|
81e6280cff | ||
|
|
e2fda7b9d3 | ||
|
|
ede30807f5 | ||
|
|
9f354013fa | ||
|
|
30552f134c | ||
|
|
e4beba9a21 | ||
|
|
b71ab7ec84 | ||
|
|
596cfc372b | ||
|
|
d5f6c5680c | ||
|
|
28c508ff62 | ||
|
|
97ff6f406d | ||
|
|
bd39b296fa | ||
|
|
bfbe1e930b | ||
|
|
3fe45176cc | ||
|
|
bffa6fbf03 | ||
|
|
161808bc37 | ||
|
|
ac42b15acd | ||
|
|
47d21de4eb | ||
|
|
e12ffa9ebc | ||
|
|
2944023986 | ||
|
|
49c1e7670d | ||
|
|
b157b901e3 | ||
|
|
1a1f88f202 | ||
|
|
d02d70da91 | ||
|
|
c37f2957bb | ||
|
|
a32f5fe57a | ||
|
|
b7f6d36a63 | ||
|
|
2975a025ac | ||
|
|
f4e396dde2 | ||
|
|
ef54911323 | ||
|
|
18b1074232 | ||
|
|
8468e0c5ea | ||
|
|
436d5c9ec9 | ||
|
|
1f88f7e1a3 | ||
|
|
09682754f8 | ||
|
|
8e3d84b1e5 | ||
|
|
8bb2631192 | ||
|
|
38a7fb6266 | ||
|
|
0ac2fb77a1 | ||
|
|
f12774b539 | ||
|
|
d50bdedcec | ||
|
|
8d443e5992 | ||
|
|
2b5fb6de53 | ||
|
|
dc86f27199 | ||
|
|
d97ed80867 | ||
|
|
3601b448fe | ||
|
|
68aab13d51 | ||
|
|
6cef13d756 | ||
|
|
1d45941f5d | ||
|
|
5e5badf11f | ||
|
|
840c65946b | ||
|
|
fdbd786126 | ||
|
|
d5365275e0 | ||
|
|
886e48d3ee | ||
|
|
342c78a4f6 | ||
|
|
a483d94e26 | ||
|
|
b40412a7a1 | ||
|
|
d652e04f7b | ||
|
|
85326d1104 | ||
|
|
28c087138b | ||
|
|
8badf14ec3 | ||
|
|
4f1db29ef8 | ||
|
|
2354dc1b71 | ||
|
|
f106bfa6d7 | ||
|
|
1a7edbea06 | ||
|
|
5bfaee9e4d | ||
|
|
0cb2309d7d | ||
|
|
0415a1d97f | ||
|
|
249c60f4d1 | ||
|
|
c357cc378c | ||
|
|
f7c0d731b6 | ||
|
|
e852e3edac | ||
|
|
2a283b74a3 | ||
|
|
c749bc2372 | ||
|
|
9c60f7190e | ||
|
|
8126b2276e | ||
|
|
e1de21015f | ||
|
|
61e505f1f8 | ||
|
|
2e3611161e | ||
|
|
95f5eaaacf | ||
|
|
87a3ea99e2 | ||
|
|
ddab65a55d | ||
|
|
0cf8f8ce0d | ||
|
|
2b531ec70c | ||
|
|
6cd7966904 | ||
|
|
ad72ab990f | ||
|
|
b53412fc00 | ||
|
|
cbb2de9e11 | ||
|
|
593c42f601 | ||
|
|
84b10b9dad | ||
|
|
0f286061e4 | ||
|
|
c8aad01480 | ||
|
|
d70a6bee69 | ||
|
|
67799b5c54 | ||
|
|
05b8eef622 | ||
|
|
50e56b5bc4 | ||
|
|
30ab4e62ec | ||
|
|
a1d901294b | ||
|
|
eed818cfe5 | ||
|
|
db0b6bfcf5 | ||
|
|
da81c1c6df | ||
|
|
42d3296f07 | ||
|
|
f9b4b1a6e8 | ||
|
|
d03603a75f | ||
|
|
ffa9e5ef66 | ||
|
|
e7f3cf9c74 | ||
|
|
e82a4a0a57 | ||
|
|
175f07fac9 | ||
|
|
2869744a5d | ||
|
|
99f898a483 | ||
|
|
1448db96e6 | ||
|
|
e02036a46a | ||
|
|
09f33263cb | ||
|
|
412b1ec74c | ||
|
|
9a1538fb4d | ||
|
|
0ad9b31ee3 | ||
|
|
4cbb7bb2f2 | ||
|
|
ae109cd8c6 | ||
|
|
fbaba774a6 | ||
|
|
38bc8062a4 | ||
|
|
d41d184b55 | ||
|
|
d6d32a7a7d | ||
|
|
15a9532fa7 | ||
|
|
3b9fb724fe | ||
|
|
33767b5635 | ||
|
|
175fc2b30e | ||
|
|
aec9347d69 | ||
|
|
91500d07c0 | ||
|
|
62ca14b504 | ||
|
|
9158a95d57 | ||
|
|
d0f3e387e7 | ||
|
|
158856f8ec | ||
|
|
1e59957753 | ||
|
|
f97d46040c | ||
|
|
d7485cb5d2 | ||
|
|
071fdb7f4a | ||
|
|
6dd96ffb20 | ||
|
|
f56fb0f7de | ||
|
|
7d28f6add9 | ||
|
|
eebef3e461 | ||
|
|
8336f630ea | ||
|
|
7d6bdbb6ed | ||
|
|
403b5c237f | ||
|
|
2367ed7911 | ||
|
|
b650f76979 | ||
|
|
96e59c198d | ||
|
|
bf1704d268 | ||
|
|
279dfd00c8 | ||
|
|
4d2577865a | ||
|
|
9d88fba40e | ||
|
|
9d2353b4f7 | ||
|
|
44b338d50a | ||
|
|
62114cab3b | ||
|
|
d426fb0355 | ||
|
|
2a8d6683ff | ||
|
|
7b18165390 | ||
|
|
814a44ac27 | ||
|
|
f5483c8996 | ||
|
|
acdbed4762 | ||
|
|
f09e4b54cf | ||
|
|
859fdf5e46 | ||
|
|
6aa72d5b7d | ||
|
|
886900df29 | ||
|
|
a450fed1f8 | ||
|
|
4c78100adb | ||
|
|
0317e6fe71 | ||
|
|
6825877344 | ||
|
|
a47ea88d28 | ||
|
|
a9e7f304ec | ||
|
|
d92a2ae3f4 | ||
|
|
b7dea35da4 | ||
|
|
5a5f11752d | ||
|
|
b6c300b5c2 | ||
|
|
0117dbfa5f | ||
|
|
c106f8a1a4 | ||
|
|
8b9741a70c | ||
|
|
271b85fd74 | ||
|
|
21a481790b | ||
|
|
ab5d9237f1 | ||
|
|
756853f2f3 | ||
|
|
5eb80d15f6 | ||
|
|
a98043b4ad | ||
|
|
d0818932e7 | ||
|
|
23b60ed385 | ||
|
|
5d2811bf21 | ||
|
|
7b202f2aa7 | ||
|
|
fa6e9c1735 | ||
|
|
c3300036d8 | ||
|
|
5efcdb2940 | ||
|
|
8113450ea4 | ||
|
|
d10b0c3c65 | ||
|
|
e8de0e8712 | ||
|
|
e1da1099da | ||
|
|
264aa8fefa | ||
|
|
941d0294b7 | ||
|
|
366f4e8914 | ||
|
|
1460c3c5a2 | ||
|
|
6271472827 | ||
|
|
3bc4a054ae | ||
|
|
dd85d559d7 | ||
|
|
ab10b082e5 | ||
|
|
60b60066f9 | ||
|
|
9e9e517698 | ||
|
|
020c0f2b98 | ||
|
|
dd74ebdc51 | ||
|
|
ddf7381efb | ||
|
|
3b67b912e4 | ||
|
|
37d7fa4510 | ||
|
|
096601473a | ||
|
|
2e25ed81ca | ||
|
|
4e11ea1d77 | ||
|
|
d9d470426b | ||
|
|
119b4ad8b2 | ||
|
|
ae1e9f000d | ||
|
|
eb0505f8f8 | ||
|
|
c8f7800ab5 | ||
|
|
bc4e7becdd | ||
|
|
71ece19c40 | ||
|
|
e9053c0d15 | ||
|
|
cf76296142 | ||
|
|
5e5d3626f4 | ||
|
|
5808c5b412 | ||
|
|
cd64392d44 | ||
|
|
79f889e16c | ||
|
|
595bfecf1c | ||
|
|
ced2e192eb | ||
|
|
ee91612c07 | ||
|
|
4d815c1fd9 | ||
|
|
d2687a55b7 | ||
|
|
41142d33dc | ||
|
|
52ab6107c5 | ||
|
|
56ac348ed4 | ||
|
|
1ac0af53ab | ||
|
|
f9361b471c | ||
|
|
8f39e0dcde | ||
|
|
81de402d2d | ||
|
|
9d6e3f52d8 | ||
|
|
6a0a9bbaaa | ||
|
|
24bc29b3cd | ||
|
|
aac5ebff73 | ||
|
|
61cc5b0a4a | ||
|
|
1383783c33 | ||
|
|
971129be6b | ||
|
|
f4fb10483a | ||
|
|
256a0e1f22 | ||
|
|
b82f0b04fe | ||
|
|
9714420f90 | ||
|
|
0054f0bc26 | ||
|
|
38f565d849 | ||
|
|
1aff7ae2a3 | ||
|
|
31020047ca | ||
|
|
26778ec52b | ||
|
|
b27590f4f5 | ||
|
|
ea2af5af04 | ||
|
|
3f1a7a0da0 | ||
|
|
8184a13bf3 | ||
|
|
2f7be56bef | ||
|
|
5c166bb3b2 | ||
|
|
4e76526843 | ||
|
|
0e6fae0632 | ||
|
|
62a8fd1b02 | ||
|
|
5a8e252790 | ||
|
|
78613cd7c0 | ||
|
|
8f6f7f7e1c | ||
|
|
f46ad6b898 | ||
|
|
f153b08a1e | ||
|
|
6b57f00d7d | ||
|
|
de65ab702b | ||
|
|
a87b735832 | ||
|
|
afc6022529 | ||
|
|
2d3bb21f60 | ||
|
|
c26f478b45 | ||
|
|
f958a0db3e | ||
|
|
6df1130140 | ||
|
|
260a771070 | ||
|
|
6659ed97ca | ||
|
|
4eae49fe58 | ||
|
|
41fef5c0e4 | ||
|
|
8c4b45a439 | ||
|
|
e35b5e78d2 | ||
|
|
b04c1a2c20 | ||
|
|
25d75b6f94 | ||
|
|
a2adacf5a3 |
No files matched your search
@@ -10,6 +10,7 @@ module.exports = {
|
||||
jasmine: false,
|
||||
},
|
||||
rules: {
|
||||
'no-console':'warn',
|
||||
'no-constant-condition': ['warn', { checkLoops: false }],
|
||||
'sort-imports': [
|
||||
'warn',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
@@ -1,3 +1,10 @@
|
||||
<!-- Thanks for contributing to _EAS CLI_! Before you submit your pull request, please make sure to check the following boxes by putting an x in the [ ] (don't: [x ], [ x], do: [x]) -->
|
||||
|
||||
# Checklist
|
||||
|
||||
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary.
|
||||
- [ ] I've tagged the changelog entry with `[EAS BUILD API]` if it's a part of a breaking change to EAS Build API (only possible when updating `@expo/eas-build-job` package).
|
||||
|
||||
# Why
|
||||
|
||||
Please describe the motivation for this PR, and link to relevant GitHub issues, forums posts, or feature requests.
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn lerna run build
|
||||
- run: yarn typecheck
|
||||
- run: yarn test
|
||||
if: ${{ !matrix.coverage }}
|
||||
- run: yarn test --coverage
|
||||
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
# Changelog
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## main
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Added support for iOS 15 capabilities: Communication Notifications, Time Sensitive Notifications, Group Activities, and Family Controls. ([#499](https://github.com/expo/eas-cli/pull/499) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Show more build metadata in `build:view` and `build:list`. ([#504](https://github.com/expo/eas-cli/pull/504) and [#508](https://github.com/expo/eas-cli/pull/508) by [@dsokal](https://github.com/dsokal))
|
||||
- Add runtime version to build metadata. ([#493](https://github.com/expo/eas-cli/pull/493) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [0.20.0](https://github.com/expo/eas-cli/releases/tag/v0.20.0) - 2021-07-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Unifify generic and managed workflow, deprecate `workflow` field. ([#497](https://github.com/expo/eas-cli/pull/497) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix runtime version checks. ([#495](https://github.com/expo/eas-cli/pull/495) by [@dsokal](https://github.com/dsokal))
|
||||
- Resolve `--android-package` correctly in `eas submit` command. ([#494](https://github.com/expo/eas-cli/pull/494) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.19.1](https://github.com/expo/eas-cli/releases/tag/v0.19.1) - 2021-07-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Improve project workflow detection (fixes the case where the `android` and/or `ios` directories are git-ignored). ([#490](https://github.com/expo/eas-cli/pull/490) by [@dsokal](https://github.com/dsokal))
|
||||
- Improve credentials workflow with project creation and current working directories ([#491](https://github.com/expo/eas-cli/pull/491) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Better debugging experience ([#492](https://github.com/expo/eas-cli/pull/492) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [0.19.0](https://github.com/expo/eas-cli/releases/tag/v0.19.0) - 2021-07-01
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Auto-suggest application id and bundle identifier when running `eas build:configure` for a managed project. ([#487](https://github.com/expo/eas-cli/pull/487) by [@dsokal](https://github.com/dsokal))
|
||||
- Support configuring one platform at a time when running `eas build`. ([#483](https://github.com/expo/eas-cli/pull/483) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [0.18.3](https://github.com/expo/eas-cli/releases/tag/v0.18.3) - 2021-06-28
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support bundle identifiers that begin with `.`. ([#459](https://github.com/expo/eas-cli/pull/459) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Push Key setup integrated with ios build command ([#473](https://github.com/expo/eas-cli/pull/473) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix android builds for PKCS12 keystores. ([#484](https://github.com/expo/eas-cli/pull/484) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [0.18.2](https://github.com/expo/eas-cli/releases/tag/v0.18.2) - 2021-06-25
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Change default iOS image for projects with Expo SDK <= 41 (SDK 41 won't build with Xcode 12.5). ([#479](https://github.com/expo/eas-cli/pull/479) by [@dsokal](https://github.com/dsokal))
|
||||
- Make Apple team optional in appropriate cases. ([#468](https://github.com/expo/eas-cli/pull/468) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Show projects that depend on a push key. ([#472](https://github.com/expo/eas-cli/pull/472) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix inverted conditional so we actually only prompt about commit when index is dirty. ([#481](https://github.com/expo/eas-cli/pull/481) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [0.18.1](https://github.com/expo/eas-cli/releases/tag/v0.18.1) - 2021-06-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add basic printing support for Apple App Specific Password. ([#474](https://github.com/expo/eas-cli/pull/474) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix CLI UI getting blocked on credentials migration, leading to partially migrated state. ([#477](https://github.com/expo/eas-cli/pull/477) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [0.18.0](https://github.com/expo/eas-cli/releases/tag/v0.18.0) - 2021-06-24
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Drop support for `experimental.npmToken` in credentials.json, EAS Secrets can be used instead. ([#444](https://github.com/expo/eas-cli/pull/444) by [@dsokal](https://github.com/dsokal))
|
||||
- Remove `--allow-experimental` flag from `eas build:configure` as it has no effect now. ([#444](https://github.com/expo/eas-cli/pull/444) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Make credentials manager work with multi-target iOS projects. ([#441](https://github.com/expo/eas-cli/pull/441) by [@dsokal](https://github.com/dsokal))
|
||||
- Copy over credentials from Expo Classic to EAS. ([#445](https://github.com/expo/eas-cli/pull/445) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add Big Sur image for iOS builds. ([#457](https://github.com/expo/eas-cli/pull/457) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- New version of Android credentials manager. ([#460](https://github.com/expo/eas-cli/pull/460) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add an internal distribution with dev client to `build:configure` defaults. ([#465](https://github.com/expo/eas-cli/pull/465) by [@fson](https://github.com/fson))
|
||||
- Add `updates.channel` to build metadata. ([#461](https://github.com/expo/eas-cli/pull/461) by [@jkhales](https://github.com/jkhales))
|
||||
- iOS push key setup and management now available in `eas-cli credentials`. ([#469](https://github.com/expo/eas-cli/pull/469) [#470](https://github.com/expo/eas-cli/pull/470) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Support new build status: `new`. ([#475](https://github.com/expo/eas-cli/pull/475) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Deprecate `--skip-credentials-check` flag because it doesn't do anything and is no longer needed. ([#442](https://github.com/expo/eas-cli/pull/442) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Move android credentials code to new Graphql API. ([#439](https://github.com/expo/eas-cli/pull/439) [#440](https://github.com/expo/eas-cli/pull/440) [#438](https://github.com/expo/eas-cli/pull/438) [#443](https://github.com/expo/eas-cli/pull/443) [#447](https://github.com/expo/eas-cli/pull/447) [#451](https://github.com/expo/eas-cli/pull/451) [#455](https://github.com/expo/eas-cli/pull/455) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Prepare Graphql infra to support iOS push keys. ([#456](https://github.com/expo/eas-cli/pull/456) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Improve credentials DX ([#448](https://github.com/expo/eas-cli/pull/448) [#449](https://github.com/expo/eas-cli/pull/449) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add analytics on dev client builds. ([#454](https://github.com/expo/eas-cli/pull/454) by [@fson](https://github.com/fson))
|
||||
- Support non-git projects. ([#462](https://github.com/expo/eas-cli/pull/462) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.17.0](https://github.com/expo/eas-cli/releases/tag/v0.17.0) - 2021-06-02
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix bundle identifier resolution when native target is not provided. ([#434](https://github.com/expo/eas-cli/pull/434) by [@dsokal](https://github.com/dsokal))
|
||||
- Fix git repo root path getter on Windows. ([#429](https://github.com/expo/eas-cli/pull/429) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Fix resolving Android application identifier. ([#431](https://github.com/expo/eas-cli/pull/431) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Android credentials setup now on Graphql API. ([#434](https://github.com/expo/eas-cli/pull/427) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [0.16.0](https://github.com/expo/eas-cli/releases/tag/v0.16.0) - 2021-05-26
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Opt out of capability syncing with `EXPO_NO_CAPABILITY_SYNC=1`. ([#426](https://github.com/expo/eas-cli/pull/426) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Add more verbose logging around capability syncing to help debug reported issues. ([#426](https://github.com/expo/eas-cli/pull/426) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Add managed credentials support for multi-target iOS projects. ([#414](https://github.com/expo/eas-cli/pull/414) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.15.1](https://github.com/expo/eas-cli/releases/tag/v0.15.1) - 2021-05-25
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support auto capabilities in managed workflow using Expo config plugin introspection. ([#419](https://github.com/expo/eas-cli/pull/419) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix sending `appBuildVersion` as part of the build metadata. ([#423](https://github.com/expo/eas-cli/pull/423) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.15.0](https://github.com/expo/eas-cli/releases/tag/v0.15.0) - 2021-05-20
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- `[EAS BUILD API]` Remove "Auto" option for `schemeBuildConfiguration` and make the old "Auto" behaviour the default. ([#394](https://github.com/expo/eas-cli/pull/394) by [@randomhajile](https://github.com/randomhajile))
|
||||
- Remove `experimental.disableIosBundleIdentifierValidation` flag from eas.json. ([#407](https://github.com/expo/eas-cli/pull/407) by [@dsokal](https://github.com/dsokal))
|
||||
- Deprecate `android.package` and `ios.bundleIdentifier` in app config for generic projects. EAS CLI depends on the values in native code now. ([#407](https://github.com/expo/eas-cli/pull/407) by [@dsokal](https://github.com/dsokal))
|
||||
- Remove application id synchronization (`android.package` and `ios.bundleIdentifier`) between app.json and native code when running builds. ([#407](https://github.com/expo/eas-cli/pull/407) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Auto sync associated domains capability before building. ([#384](https://github.com/expo/eas-cli/pull/384) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Add eas init command. [#402](https://github.com/expo/eas-cli/pull/402) by [@jkhales](https://github.com/jkhales))
|
||||
- Allow for arbitrary string values in `schemeBuildConfiguration`. ([#394](https://github.com/expo/eas-cli/pull/394) by [@randomhajile](https://github.com/randomhajile))
|
||||
- Allow for installing custom `expo-cli` version on EAS Build. ([#409](https://github.com/expo/eas-cli/pull/409) by [@randomhajile](https://github.com/randomhajile))
|
||||
- Support PKCS kesytores for Android. ([#398](https://github.com/expo/eas-cli/pull/398) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Support empty passwords in Android and iOS keystores. ([#398](https://github.com/expo/eas-cli/pull/398) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Add more build metadata - `appBuildVersion`. ([#413](https://github.com/expo/eas-cli/pull/413) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix failure when publishing without the platform flag. ([#415](https://github.com/expo/eas-cli/pull/415) by [@jkhales](https://github.com/jkhales))
|
||||
- Pin versions in package.json. ([#399](https://github.com/expo/eas-cli/pull/399) by [@dsokal](https://github.com/dsokal))
|
||||
- Revert [0ac2f](https://github.com/expo/eas-cli/commit/0ac2fb77a118df609381c4e350aa68609340c3cd) as the root cause of the issue has been fixed in [#399](https://github.com/expo/eas-cli/pull/399).
|
||||
- Include development-client in valid buildType for internal distribution. ([#410](https://github.com/expo/eas-cli/pull/410) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [0.14.1](https://github.com/expo/eas-cli/releases/tag/v0.14.1) - 2021-05-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Make the default urql requestPolicy 'network-only'. ([#397](https://github.com/expo/eas-cli/pull/397) by [@jkhales](https://github.com/jkhales))
|
||||
|
||||
## [0.14.0](https://github.com/expo/eas-cli/releases/tag/v0.14.0) - 2021-05-10
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Make eas branch:publish work with expo-cli >= 4.4.3 ([#366](https://github.com/expo/eas-cli/pull/366) by [@jkhales](https://github.com/jkhales))
|
||||
- Create asset keys without an extension. ([#366](https://github.com/expo/eas-cli/pull/366) by [@jkhales](https://github.com/jkhales))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Display platforms supported by an update group ([#391](https://github.com/expo/eas-cli/pull/391) by [@jkhales](https://github.com/jkhales))
|
||||
- Add a --platform flag to branch:publish command ([#389](https://github.com/expo/eas-cli/pull/389) by [@jkhales](https://github.com/jkhales))
|
||||
- Release new credentials manager. ([#363](https://github.com/expo/eas-cli/pull/363) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add QR code to install internal distribution build on device. ([#371](https://github.com/expo/eas-cli/pull/371) by [@axeldelafosse](https://github.com/axeldelafosse))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Cleanup unused code. ([#364](https://github.com/expo/eas-cli/pull/364), [#365](https://github.com/expo/eas-cli/pull/365) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Redesign UX in beta credentials manager. ([#360](https://github.com/expo/eas-cli/pull/360) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Port more options to the beta credentials manager. ([#352](https://github.com/expo/eas-cli/pull/352), [357](https://github.com/expo/eas-cli/pull/357), [361](https://github.com/expo/eas-cli/pull/361) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add getBuildProfileNamesAsync helper to EasJsonReader. ([#351](https://github.com/expo/eas-cli/pull/351) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Increase build timeout to 1 hour. ([#370](https://github.com/expo/eas-cli/pull/370) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Remove check for pending builds. ([#373](https://github.com/expo/eas-cli/pull/373) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.13.0](https://github.com/expo/eas-cli/releases/tag/v0.13.0) - 2021-04-22
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Implement offline distribution certificate validation when running a build in non-interactive mode. ([#344](https://github.com/expo/eas-cli/pull/344) by [@dsokal](https://github.com/dsokal))
|
||||
- Add support for building internal distribution apps for Apple Enterprise Teams. ([#344](https://github.com/expo/eas-cli/pull/344) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Display descriptive error message when API for EAS Build changes. ([#359](https://github.com/expo/eas-cli/pull/359) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.12.0](https://github.com/expo/eas-cli/releases/tag/v0.12.0) - 2021-04-22
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add the initiating user's username to the build metadata. ([#354](https://github.com/expo/eas-cli/pull/354) by [@dsokal](https://github.com/dsokal))
|
||||
- Add `--clear-cache` flag for `eas build`. ([#355](https://github.com/expo/eas-cli/pull/355) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix the bug where the account name was used as the username. ([#354](https://github.com/expo/eas-cli/pull/354) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.11.1](https://github.com/expo/eas-cli/releases/tag/v0.11.1) - 2021-04-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Resolve ios distribution types. ([#348](https://github.com/expo/eas-cli/pull/348) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Bump version of `@expo/apple-utils` to fix capabilities sync immediately after initial identifier registration. ([593c4](https://github.com/expo/eas-cli/commit/593c42f601396deba1751489343ec07b0a8876da) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [0.11.0](https://github.com/expo/eas-cli/releases/tag/v0.11.0) - 2021-04-20
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- `"credentialsSource": "auto"` is now deprecated. The option will use `"remote"` as the default value. ([#345](https://github.com/expo/eas-cli/pull/345) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add validation when setting up ad-hoc provisioning profile in non-interactive mode. ([#338](https://github.com/expo/eas-cli/pull/338) by [@dsokal](https://github.com/dsokal))
|
||||
- Allow for registering devices when running an ad-hoc build for the first time. ([#340](https://github.com/expo/eas-cli/pull/340) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix the bug where all Android managed builds produced AAB archives. The `buildType` property from `eas.json` is not ignored now. ([#349](https://github.com/expo/eas-cli/pull/349) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.10.0](https://github.com/expo/eas-cli/releases/tag/v0.10.0) - 2021-04-16
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- `secrets:<COMMAND>` is renamed to `secret:<COMMAND>` ([#315](https://github.com/expo/eas-cli/pull/315) by [@fiberjw](https://github.com/fiberjw))
|
||||
- `secret:delete` now accepts `id` through a flag rather than an argument ([#315](https://github.com/expo/eas-cli/pull/315) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Use special Expo SDK runtime version for managed projects ([#336](https://github.com/expo/eas-cli/pull/336)) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix the behavior where the provisioning profile was invalidated after syncing bundle id capabilities. ([#334](https://github.com/expo/eas-cli/pull/334) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Change build credentials summary format. ([#321](https://github.com/expo/eas-cli/pull/321) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.9.1](https://github.com/expo/eas-cli/releases/tag/v0.9.1) - 2021-04-09
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix provisioning profile validation to actually check status. ([#318](https://github.com/expo/eas-cli/pull/318) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Replace Credentials REST API calls with GraphQL counterparts, also improved DX. ([#293](https://github.com/expo/eas-cli/pull/293), [#299](https://github.com/expo/eas-cli/pull/299), [#301](https://github.com/expo/eas-cli/pull/301), [#317](https://github.com/expo/eas-cli/pull/317) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [0.9.0](https://github.com/expo/eas-cli/releases/tag/v0.9.0) - 2021-04-09
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Retry graphQL call on network or transient server errors. ([#320](https://github.com/expo/eas-cli/pull/320) by [@jkhales](https://github.com/jkhales))
|
||||
- Display more friendly error messages when `eas submit` fails. ([#311](https://github.com/expo/eas-cli/pull/297) by [@barthap](https://github.com/barthap))
|
||||
- Add support for managing webhooks (new commands: `webhook:create`, `webhook:view`, `webhook:list`, `webhook:update`, and `webhook:delete`). ([#314](https://github.com/expo/eas-cli/pull/314) by [@dsokal](https://github.com/dsokal))
|
||||
- Support for local builds [experimental]. ([#305](https://github.com/expo/eas-cli/pull/305) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix the issue where the first iOS build fails for a project without `ios.bundleIdentifier` set in `app.json`. ([#319](https://github.com/expo/eas-cli/pull/319) by [@dsokal](https://github.com/dsokal))
|
||||
- Bump @expo/config-plugins to fix `eas build:configure` on Windows. ([d0f3e](https://github.com/expo/eas-cli/commit/d0f3e387e7f31dbce4c3b93b8d083c7d20d9e30d) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Replace REST API calls with GraphQL counterparts. ([#333](https://github.com/expo/eas-cli/pull/333) by [@barthap](https://github.com/barthap))
|
||||
|
||||
## [0.8.1](https://github.com/expo/eas-cli/releases/tag/v0.8.1) - 2021-04-06
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix the issue where the build status never got updated when running `eas build`. ([#310](https://github.com/expo/eas-cli/pull/310) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.8.0](https://github.com/expo/eas-cli/releases/tag/v0.8.0) - 2021-04-06
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Change the way of disabling cache - add `cache.disabled` field. ([#295](https://github.com/expo/eas-cli/pull/295) by [@dsokal](https://github.com/dsokal))
|
||||
- `secrets:create` now uses flags rather than positional arguments ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
|
||||
- `secrets:create`'s `target` arg is now called `scope` ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
|
||||
- `secrets:list`'s `target` property is now called `scope` ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
|
||||
- `secrets:delete`'s `ID` arg is now optional ([#309](https://github.com/expo/eas-cli/pull/309) by [@fiberjw](https://github.com/fiberjw))
|
||||
- `secrets:delete`'s now allows users to choose secrets from a list ([#309](https://github.com/expo/eas-cli/pull/309) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- `build:view` and `build:list` now showing the distribution type (store / internal) and release channel. ([#284](https://github.com/expo/eas-cli/pull/284) by [@vthibault](https://github.com/vthibault))
|
||||
- Add analytics to EAS Build. ([#162](https://github.com/expo/eas-cli/pull/162) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Improve tar archive support in EAS Submit. ([#297](https://github.com/expo/eas-cli/pull/297) by [@barthap](https://github.com/barthap))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix environment secret creation prompt. ([#298](https://github.com/expo/eas-cli/pull/298) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Replace REST API calls with GraphQL counterparts. ([#286](https://github.com/expo/eas-cli/pull/286), [#288](https://github.com/expo/eas-cli/pull/288), [#303](https://github.com/expo/eas-cli/pull/303), [#306](https://github.com/expo/eas-cli/pull/306), [#307](https://github.com/expo/eas-cli/pull/307) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.7.0](https://github.com/expo/eas-cli/releases/tag/v0.7.0) - 2021-03-22
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Print common error messages when builds fail. ([#272](https://github.com/expo/eas-cli/pull/272) by [@dsokal](https://github.com/dsokal))
|
||||
- Commit automatically if `EAS_BUILD_AUTOCOMMIT` is set. ([#271](https://github.com/expo/eas-cli/pull/271) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Allow for installing custom `bundler` version on EAS Build. ([#277](https://github.com/expo/eas-cli/pull/277) by [@dsokal](https://github.com/dsokal))
|
||||
- Add support for managing environment secrets. ([#275](https://github.com/expo/eas-cli/pull/275) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `eas submit` local archive prompt for `.aab` files when submitting for iOS. ([#273](https://github.com/expo/eas-cli/pull/273) by [@barthap](https://github.com/barthap))
|
||||
- Verify whether "name" field in app.json contains any alphanumeric characters. ([#280](https://github.com/expo/eas-cli/pull/280) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Detect dependency cycles in eas.json build profiles. ([#283](https://github.com/expo/eas-cli/pull/283) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.6.0](https://github.com/expo/eas-cli/releases/tag/v0.6.0) - 2021-03-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Generic iOS projects: build release builds by default. ([#266](https://github.com/expo/eas-cli/pull/266) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Log the size of the archived project when uploading. ([#264](https://github.com/expo/eas-cli/pull/264) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Add more build metadata (release channel, build profile name, git commit hash). ([#265](https://github.com/expo/eas-cli/pull/265) by [@dsokal](https://github.com/dsokal))
|
||||
- Display App Store link after successful submission. ([#144](https://github.com/expo/eas-cli/pull/144) by [@barthap](https://github.com/barthap))
|
||||
- Add `experimental.disableIosBundleIdentifierValidation` flag to eas.json. ([#263](https://github.com/expo/eas-cli/pull/263) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Support internal distribution in non-interactive builds. ([#269](https://github.com/expo/eas-cli/pull/269) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Print Apple Team ID in the output of `device:list` when the team name is unknown. ([#268](https://github.com/expo/eas-cli/pull/268) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.5.0](https://github.com/expo/eas-cli/releases/tag/v0.5.0) - 2021-03-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add support for build cache. ([#247](https://github.com/expo/eas-cli/pull/247) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Enable internal distribution support for self-managed credentials. ([#256](https://github.com/expo/eas-cli/pull/256) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Make sure all files are committed before build. ([#251](https://github.com/expo/eas-cli/pull/251) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Fix `eas submit` support for tar.gz files. ([#257](https://github.com/expo/eas-cli/pull/257)) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Show untracked files when checking `git status`. ([#259](https://github.com/expo/eas-cli/pull/259) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Upgrade `@expo/eas-build-job` from `0.2.12` to `0.2.13`. ([#245](https://github.com/expo/eas-cli/pull/245) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.4.3](https://github.com/expo/eas-cli/releases/tag/v0.4.3) - 2021-02-23
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add support for iOS simulator builds. ([#240](https://github.com/expo/eas-cli/pull/240) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Use fixed version of `@expo/eas-json`. ([#243](https://github.com/expo/eas-cli/pull/243) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.4.2](https://github.com/expo/eas-cli/releases/tag/v0.4.2) - 2021-02-18
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix detecting application target (iOS builds). ([#238](https://github.com/expo/eas-cli/pull/238) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.4.1](https://github.com/expo/eas-cli/releases/tag/v0.4.1) - 2021-02-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `"buildType" is not allowed` error. ([#595bf](https://github.com/expo/eas-cli/commit/595bfecf1cbff0f76e7fd2049fe16f6f38bbe150) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.4.0](https://github.com/expo/eas-cli/releases/tag/v0.4.0) - 2021-02-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add build:cancel command. ([#219](https://github.com/expo/eas-cli/pull/219) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Implement version auto increment for iOS builds. ([#231](https://github.com/expo/eas-cli/pull/231) by [@dsokal](https://github.com/dsokal))
|
||||
- Add support for builder environment customizations. ([#230](https://github.com/expo/eas-cli/pull/230) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Add `schemeBuildConfiguration` option for generic iOS builds. ([#234](https://github.com/expo/eas-cli/pull/234) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `--no-wait` flag for `eas build`. ([#226](https://github.com/expo/eas-cli/pull/226) by [@paul-ridgway](https://github.com/paul-ridgway))
|
||||
- Fix running builds from project subdirectories. ([#229](https://github.com/expo/eas-cli/pull/229) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
+37
-6
@@ -1,8 +1,39 @@
|
||||
## Releasing a new version
|
||||
|
||||
1. Add release notes to `CHANGELOG.md`.
|
||||
1. Run `yarn release` in the repository root folder. This will create a new tag
|
||||
like `v1.0.0`.
|
||||
1. Wait until the `release` GitHub workflow has finished.
|
||||
1. Once the release ready to go, you can go to
|
||||
https://github.com/expo/eas-cli/releases and mark it as not draft.
|
||||
1. Run `yarn release` in the repository root folder. This will create a new tag like `v1.0.0`.
|
||||
1. **Wait until the `release` GitHub workflow has finished.**
|
||||

|
||||
1. Run `yarn lerna publish from-git` in the repository root folder. This will publish the packages to npm.
|
||||
|
||||
- This command works only if `HEAD` is on the release commit, e.g. when `git log -1` prints:
|
||||
|
||||
```
|
||||
commit cbb2de9e118bfbcb5a597b261d2dbef0bb5d4b0b (tag: v0.11.1)
|
||||
Author: Brent Vatne <[email protected]>
|
||||
Date: Tue Apr 20 15:00:15 2021 -0700
|
||||
|
||||
v0.11.1
|
||||
```
|
||||
|
||||
- If you run `yarn lerna publish from-git` on any other commit, it'll print something like:
|
||||
|
||||
```
|
||||
lerna notice from-git No tagged release found
|
||||
lerna success No changed packages to publish
|
||||
```
|
||||
|
||||
- If you ever run into the above issue, use `git checkout RELEASE_COMMIT_HASH` to checkout to the release commit. Later, when you've published the packages to npm, run `git checkout main` to go back to the latest commit.
|
||||
|
||||
1. Update `CHANGELOG.md`.
|
||||
- Add the new section with the release version in the title.
|
||||
- Leave the `main` section empty (without any changelog entries).
|
||||
1. Once the release is ready to go, you can go to https://github.com/expo/eas-cli/releases and mark it as not draft.
|
||||
- Remember to copy the changelog entries from `CHANGELOG.md`.
|
||||
- Before hitting the submit button, make sure there are 5 assets available under the release entry.
|
||||

|
||||
1. If any of the changelog entries have been tagged with `[EAS BUILD API]`
|
||||
- Open `package.json` and note the version of `@expo/eas-build-job` used in the eas-cli.
|
||||
- Find the `Publish` commit in [expo/eas-build](https://github.com/expo/eas-build/commit/) for that version. In most cases, it will be the latest `Publish` commit.
|
||||
- Find the `eas-cli-local-build-plugin` version published in that commit.
|
||||

|
||||
- Run `npm dist-tags add eas-cli-local-build-plugin@VERSION latest` with the version from the previous step.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2020-present 650 Industries, Inc. (aka Expo)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,11 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
// Only use this when running tests
|
||||
env: {
|
||||
test: {
|
||||
presets: ['@expo/babel-preset-cli'],
|
||||
},
|
||||
},
|
||||
presets: ['@expo/babel-preset-cli'],
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.1.2"
|
||||
"version": "0.21.0"
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
"scripts": {
|
||||
"build": "lerna run build",
|
||||
"build:tarballs:linux": "yarn build && yarn workspace eas-cli oclif-dev pack --targets linux-x64",
|
||||
"typecheck": "lerna run typecheck",
|
||||
"start": "lerna run watch --parallel",
|
||||
"watch": "yarn start",
|
||||
"eas": "packages/eas-cli/bin/run",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"release": "lerna version",
|
||||
@@ -16,6 +19,7 @@
|
||||
"@types/jest": "^26.0.13",
|
||||
"eslint": "^6.6.0",
|
||||
"eslint-config-universe": "^4.0.0",
|
||||
"eslint-plugin-graphql": "^4.0.0",
|
||||
"jest": "^26.4.2",
|
||||
"jest-watch-typeahead": "^0.6.1",
|
||||
"lerna": "^3.22.1",
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
'import/no-cycle': 'error',
|
||||
'import/no-extraneous-dependencies': ['error', { devDependencies: [
|
||||
'**/__tests__/**/*',
|
||||
'**/__mocks__/**/*'
|
||||
] }],
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{ devDependencies: ['**/__tests__/**/*', '**/__mocks__/**/*'] },
|
||||
],
|
||||
'graphql/template-strings': [
|
||||
'error',
|
||||
{
|
||||
env: 'apollo',
|
||||
schemaJson: require('./graphql.schema.json'),
|
||||
},
|
||||
],
|
||||
'graphql/named-operations': [
|
||||
'error',
|
||||
{
|
||||
schemaJson: require('./graphql.schema.json'),
|
||||
},
|
||||
],
|
||||
'graphql/required-fields': [
|
||||
'error',
|
||||
{
|
||||
env: 'apollo',
|
||||
schemaJson: require('./graphql.schema.json'),
|
||||
requiredFields: ['id'],
|
||||
},
|
||||
],
|
||||
'graphql/capitalized-type-name': [
|
||||
'error',
|
||||
{
|
||||
schemaJson: require('./graphql.schema.json'),
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: ['graphql'],
|
||||
};
|
||||
+192
-34
@@ -7,27 +7,28 @@ EAS command line tool
|
||||
[](https://npmjs.org/package/eas-cli)
|
||||
[](https://github.com/expo/eas-cli/blob/main/package.json)
|
||||
|
||||
<!-- toc -->
|
||||
* [eas-cli](#eas-cli)
|
||||
* [Installation](#installation)
|
||||
* [Usage](#usage)
|
||||
* [Commands](#commands)
|
||||
<!-- tocstop -->
|
||||
|
||||
# Installation
|
||||
|
||||
```sh
|
||||
npm install -g eas-cli
|
||||
# or
|
||||
yarn global add eas-cli
|
||||
```
|
||||
|
||||
# Usage
|
||||
|
||||
<!-- usage -->
|
||||
```sh-session
|
||||
$ npm install -g eas-cli
|
||||
$ eas COMMAND
|
||||
running command...
|
||||
$ eas (-v|--version|version)
|
||||
eas-cli/0.1.2 darwin-x64 node-v14.15.0
|
||||
$ eas --help [COMMAND]
|
||||
USAGE
|
||||
$ eas COMMAND
|
||||
...
|
||||
```sh
|
||||
eas COMMAND
|
||||
# runs the command
|
||||
eas (-v|--version|version)
|
||||
# prints the version
|
||||
eas --help COMMAND
|
||||
# outputs help for specific command
|
||||
```
|
||||
<!-- usagestop -->
|
||||
|
||||
# Commands
|
||||
|
||||
@@ -35,16 +36,26 @@ USAGE
|
||||
* [`eas account:login`](#eas-accountlogin)
|
||||
* [`eas account:logout`](#eas-accountlogout)
|
||||
* [`eas account:view`](#eas-accountview)
|
||||
* [`eas analytics [STATUS]`](#eas-analytics-status)
|
||||
* [`eas build`](#eas-build)
|
||||
* [`eas build:cancel [BUILD_ID]`](#eas-buildcancel-build_id)
|
||||
* [`eas build:configure`](#eas-buildconfigure)
|
||||
* [`eas build:list`](#eas-buildlist)
|
||||
* [`eas build:view [BUILDID]`](#eas-buildview-buildid)
|
||||
* [`eas build:view [BUILD_ID]`](#eas-buildview-build_id)
|
||||
* [`eas credentials`](#eas-credentials)
|
||||
* [`eas device:create`](#eas-devicecreate)
|
||||
* [`eas device:list`](#eas-devicelist)
|
||||
* [`eas device:view [UDID]`](#eas-deviceview-udid)
|
||||
* [`eas help [COMMAND]`](#eas-help-command)
|
||||
* [`eas secret:create`](#eas-secretcreate)
|
||||
* [`eas secret:delete`](#eas-secretdelete)
|
||||
* [`eas secret:list`](#eas-secretlist)
|
||||
* [`eas submit --platform=(android|ios)`](#eas-submit---platformandroidios)
|
||||
* [`eas webhook:create`](#eas-webhookcreate)
|
||||
* [`eas webhook:delete [ID]`](#eas-webhookdelete-id)
|
||||
* [`eas webhook:list`](#eas-webhooklist)
|
||||
* [`eas webhook:update`](#eas-webhookupdate)
|
||||
* [`eas webhook:view ID`](#eas-webhookview-id)
|
||||
|
||||
## `eas account:login`
|
||||
|
||||
@@ -58,7 +69,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/login.ts)_
|
||||
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -72,7 +83,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/logout.ts)_
|
||||
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -86,7 +97,18 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/view.ts)_
|
||||
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
View or change analytics settings
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas analytics [STATUS]
|
||||
```
|
||||
|
||||
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -98,14 +120,26 @@ USAGE
|
||||
|
||||
OPTIONS
|
||||
-p, --platform=(android|ios|all)
|
||||
--clear-cache Clear cache before the build
|
||||
--local Run build locally [experimental]
|
||||
--non-interactive Run command in --non-interactive mode
|
||||
--profile=profile [default: release] Name of the build profile from eas.json
|
||||
--skip-credentials-check Skip validation of build credentials
|
||||
--skip-project-configuration Skip project configuration
|
||||
--wait Wait for build(s) to complete
|
||||
--[no-]wait Wait for build(s) to complete
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/index.ts)_
|
||||
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
Cancel a build.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:cancel [BUILD_ID]
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -117,10 +151,9 @@ USAGE
|
||||
|
||||
OPTIONS
|
||||
-p, --platform=(android|ios|all) Platform to configure
|
||||
--allow-experimental Enable experimental configuration steps.
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/configure.ts)_
|
||||
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -133,21 +166,21 @@ USAGE
|
||||
OPTIONS
|
||||
--limit=limit
|
||||
--platform=(all|android|ios)
|
||||
--status=(in-queue|in-progress|errored|finished)
|
||||
--status=(new|in-queue|in-progress|errored|finished|canceled)
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/list.ts)_
|
||||
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:view [BUILDID]`
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
view a build for your project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:view [BUILDID]
|
||||
$ eas build:view [BUILD_ID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/view.ts)_
|
||||
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -158,7 +191,7 @@ USAGE
|
||||
$ eas credentials
|
||||
```
|
||||
|
||||
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/credentials.ts)_
|
||||
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/credentials.ts)_
|
||||
|
||||
## `eas device:create`
|
||||
|
||||
@@ -169,7 +202,7 @@ USAGE
|
||||
$ eas device:create
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/create.ts)_
|
||||
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -183,7 +216,7 @@ OPTIONS
|
||||
--apple-team-id=apple-team-id
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/list.ts)_
|
||||
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -194,7 +227,7 @@ USAGE
|
||||
$ eas device:view [UDID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/view.ts)_
|
||||
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -213,6 +246,50 @@ OPTIONS
|
||||
|
||||
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
|
||||
|
||||
## `eas secret:create`
|
||||
|
||||
Create an environment secret on the current project or owner account.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas secret:create
|
||||
|
||||
OPTIONS
|
||||
--name=name Name of the secret
|
||||
--scope=(account|project) [default: project] Scope for the secret
|
||||
--value=value Value of the secret
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/secret/create.ts)_
|
||||
|
||||
## `eas secret:delete`
|
||||
|
||||
Delete an environment secret by ID.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas secret:delete
|
||||
|
||||
OPTIONS
|
||||
--id=id ID of the secret to delete
|
||||
|
||||
DESCRIPTION
|
||||
Unsure where to find the secret's ID? Run eas secrets:list
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/secret/delete.ts)_
|
||||
|
||||
## `eas secret:list`
|
||||
|
||||
Lists environment secrets available for your current app
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas secret:list
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/secret/list.ts)_
|
||||
|
||||
## `eas submit --platform=(android|ios)`
|
||||
|
||||
Submits build artifact to app store
|
||||
@@ -291,5 +368,86 @@ EXAMPLES
|
||||
and provide its App ID
|
||||
```
|
||||
|
||||
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/submit.ts)_
|
||||
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
Create a webhook on the current project.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas webhook:create
|
||||
|
||||
OPTIONS
|
||||
--event=(BUILD) [default: BUILD] Event type that triggers the webhook
|
||||
|
||||
--secret=secret Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature'
|
||||
header.
|
||||
|
||||
--url=url Webhook URL
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
Delete a webhook on the current project.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas webhook:delete [ID]
|
||||
|
||||
ARGUMENTS
|
||||
ID ID of the webhook to delete
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
List webhooks on the current project.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas webhook:list
|
||||
|
||||
OPTIONS
|
||||
--event=(BUILD) Event type that triggers the webhook
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
Create a webhook on the current project.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas webhook:update
|
||||
|
||||
OPTIONS
|
||||
--event=(BUILD) [default: BUILD] Event type that triggers the webhook
|
||||
--id=id (required) Webhook ID
|
||||
|
||||
--secret=secret Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature'
|
||||
header.
|
||||
|
||||
--url=url Webhook URL
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
View a webhook on the current project.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas webhook:view ID
|
||||
|
||||
ARGUMENTS
|
||||
ID ID of the webhook to view
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v0.21.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
@@ -1,3 +0,0 @@
|
||||
const getConfig = jest.fn(() => ({ exp: {} }));
|
||||
|
||||
export { getConfig };
|
||||
@@ -1,2 +1,9 @@
|
||||
import { fs } from 'memfs';
|
||||
|
||||
// needed because of a weird bug with tempy (dependency of @expo/config-plugins)
|
||||
fs.mkdirSync('/tmp');
|
||||
if (process.env.TMPDIR) {
|
||||
fs.mkdirSync(process.env.TMPDIR, { recursive: true });
|
||||
}
|
||||
|
||||
module.exports = fs;
|
||||
@@ -2,11 +2,19 @@ overwrite: true
|
||||
schema: 'https://staging.exp.host/--/graphql'
|
||||
documents:
|
||||
- 'src/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/commands/**/*.ts'
|
||||
generates:
|
||||
src/graphql/generated.ts:
|
||||
plugins:
|
||||
- 'typescript'
|
||||
- 'typescript-operations'
|
||||
config:
|
||||
dedupeOperationSuffix: true
|
||||
hooks:
|
||||
afterOneFileWrite:
|
||||
- ./annotate-graphql-codegen.sh
|
||||
./graphql.schema.json:
|
||||
plugins:
|
||||
- 'introspection'
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,4 +2,6 @@ module.exports = {
|
||||
...require('../../jest/jest.config.js'),
|
||||
rootDir: __dirname,
|
||||
roots: ['src', '__mocks__'],
|
||||
globalSetup: './jest/global-setup.ts',
|
||||
setupFilesAfterEnv: ['./jest/setup-after-env.ts'],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = async () => {
|
||||
process.env.TZ = 'UTC';
|
||||
process.env.EAS_NO_VCS = '1';
|
||||
process.env.EAS_PROJECT_ROOT = '/app';
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/* eslint-disable no-console */
|
||||
beforeAll(() => {
|
||||
console.log = jest.fn();
|
||||
console.warn = jest.fn();
|
||||
console.error = jest.fn();
|
||||
});
|
||||
@@ -1,61 +1,74 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "0.1.2",
|
||||
"version": "0.21.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "0.0.0-alpha.16",
|
||||
"@expo/config": "~3.3.19",
|
||||
"@expo/config-plugins": "~1.0.9",
|
||||
"@expo/eas-build-job": "0.1.4",
|
||||
"@expo/eas-json": "^0.1.0-alpha.20",
|
||||
"@expo/json-file": "^8.2.24",
|
||||
"@expo/plist": "^0.0.11",
|
||||
"@expo/plugin-warn-if-update-available": "^1.7.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "^1.5.0",
|
||||
"@hapi/joi": "^17.1.1",
|
||||
"@oclif/command": "^1",
|
||||
"@oclif/config": "^1",
|
||||
"@oclif/plugin-help": "^3",
|
||||
"@urql/core": "^1.13.0",
|
||||
"chalk": "^4.1.0",
|
||||
"cli-table3": "^0.6.0",
|
||||
"dateformat": "^3.0.3",
|
||||
"env-paths": "^2.2.0",
|
||||
"fast-glob": "^3.2.4",
|
||||
"figures": "^3.2.0",
|
||||
"form-data": "^3.0.0",
|
||||
"fs-extra": "^9.0.1",
|
||||
"getenv": "^1.0.0",
|
||||
"got": "^11.5.2",
|
||||
"graphql": "^15.3.0",
|
||||
"graphql-tag": "^2.11.0",
|
||||
"indent-string": "^4.0.0",
|
||||
"keychain": "^1.3.0",
|
||||
"lodash": "^4.17.20",
|
||||
"md5-file": "^5.0.0",
|
||||
"mime": "^2.4.7",
|
||||
"minimatch": "^3.0.4",
|
||||
"node-fetch": "^2.6.1",
|
||||
"node-forge": "^0.10.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
"ora": "^5.1.0",
|
||||
"pkg-dir": "^4.2.0",
|
||||
"progress": "^2.0.3",
|
||||
"prompts": "^2.3.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"tar": "^6.0.5",
|
||||
"tempy": "^0.7.0",
|
||||
"tslib": "^1",
|
||||
"untildify": "^4.0.0",
|
||||
"uuid": "^8.3.0",
|
||||
"wordwrap": "^1.0.0"
|
||||
"@amplitude/identify": "1.5.0",
|
||||
"@amplitude/node": "1.5.0",
|
||||
"@expo/apple-utils": "0.0.0-alpha.23",
|
||||
"@expo/config": "3.3.42",
|
||||
"@expo/config-plugins": "3.0.3",
|
||||
"@expo/eas-build-job": "0.2.43",
|
||||
"@expo/eas-json": "^0.21.0",
|
||||
"@expo/json-file": "8.2.25",
|
||||
"@expo/pkcs12": "0.0.4",
|
||||
"@expo/plist": "0.0.11",
|
||||
"@expo/plugin-warn-if-update-available": "1.7.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/sdk-runtime-versions": "1.0.0",
|
||||
"@expo/spawn-async": "1.5.0",
|
||||
"@hapi/joi": "17.1.1",
|
||||
"@oclif/command": "1.8.0",
|
||||
"@oclif/config": "1.17.0",
|
||||
"@oclif/errors": "1.3.3",
|
||||
"@oclif/plugin-help": "3.2.0",
|
||||
"@urql/core": "2.0.0",
|
||||
"@urql/exchange-retry": "0.2.0",
|
||||
"chalk": "4.1.0",
|
||||
"cli-table3": "0.6.0",
|
||||
"dateformat": "3.0.3",
|
||||
"env-paths": "2.2.0",
|
||||
"fast-glob": "3.2.4",
|
||||
"figures": "3.2.0",
|
||||
"form-data": "3.0.0",
|
||||
"fs-extra": "9.0.1",
|
||||
"getenv": "1.0.0",
|
||||
"got": "11.5.2",
|
||||
"graphql": "15.3.0",
|
||||
"graphql-tag": "2.11.0",
|
||||
"ignore": "5.1.8",
|
||||
"indent-string": "4.0.0",
|
||||
"jks-js": "1.0.0",
|
||||
"keychain": "1.3.0",
|
||||
"lodash": "4.17.20",
|
||||
"mime": "2.4.7",
|
||||
"minimatch": "3.0.4",
|
||||
"nanoid": "3.1.23",
|
||||
"node-fetch": "2.6.1",
|
||||
"node-forge": "0.10.0",
|
||||
"nullthrows": "1.1.1",
|
||||
"ora": "5.1.0",
|
||||
"pkg-dir": "4.2.0",
|
||||
"progress": "2.0.3",
|
||||
"prompts": "2.3.2",
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"resolve-from": "5.0.0",
|
||||
"semver": "7.3.4",
|
||||
"strip-ansi": "6.0.0",
|
||||
"tar": "6.0.5",
|
||||
"tempy": "0.7.0",
|
||||
"terminal-link": "2.1.1",
|
||||
"timeago.js": "4.0.2",
|
||||
"tslib": "1.14.1",
|
||||
"turndown": "7.0.0",
|
||||
"untildify": "4.0.0",
|
||||
"uuid": "8.3.0",
|
||||
"wrap-ansi": "7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.6",
|
||||
@@ -63,6 +76,7 @@
|
||||
"@babel/preset-typescript": "^7.10.4",
|
||||
"@expo/babel-preset-cli": "^0.2.17",
|
||||
"@graphql-codegen/cli": "^1.19.2",
|
||||
"@graphql-codegen/introspection": "^1.18.0",
|
||||
"@graphql-codegen/typescript": "^1.17.11",
|
||||
"@graphql-codegen/typescript-operations": "^1.17.10",
|
||||
"@types/color-string": "^1.5.0",
|
||||
@@ -77,15 +91,17 @@
|
||||
"@types/node-forge": "^0.9.5",
|
||||
"@types/progress": "^2.0.3",
|
||||
"@types/prompts": "^2.0.9",
|
||||
"@types/semver": "^7.3.4",
|
||||
"@types/tar": "^4.0.3",
|
||||
"@types/tough-cookie": "^4.0.0",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@types/wordwrap": "^1.0.0",
|
||||
"@types/wrap-ansi": "^3.0.0",
|
||||
"@vercel/ncc": "^0.23.0",
|
||||
"axios": "^0.21.0",
|
||||
"babel-jest": "^26.3.0",
|
||||
"memfs": "^3.2.0",
|
||||
"mockdate": "^3.0.2",
|
||||
"nock": "^13.0.5",
|
||||
"ts-node": "^8",
|
||||
"typescript": "^3.9.7",
|
||||
"wonka": "^4.0.14"
|
||||
@@ -152,11 +168,14 @@
|
||||
"repository": "expo/eas-cli",
|
||||
"scripts": {
|
||||
"postpack": "rm -f oclif.manifest.json",
|
||||
"prepack": "rm -rf build && yarn build && yarn oclif-dev manifest && yarn oclif-dev readme",
|
||||
"build": "tsc",
|
||||
"prepack": "rm -rf build && yarn build && yarn oclif-dev manifest && yarn oclif-dev readme && node patch-readme",
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"watch": "yarn build --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"test": "jest",
|
||||
"version": "yarn oclif-dev readme && git add README.md",
|
||||
"version": "yarn oclif-dev readme && node patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml"
|
||||
},
|
||||
"types": "build/index.d.ts"
|
||||
"types": "build/index.d.ts",
|
||||
"gitHead": "161808bc37c255fdea6801d69d23dbc725c056c3"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
const fs = require('fs');
|
||||
|
||||
// Patch `oclif-dev readme` path and link generation
|
||||
let readmeContent = fs.readFileSync('README.md', 'utf8');
|
||||
readmeContent = readmeContent.replace(/\[build\//g, '[src/');
|
||||
readmeContent = readmeContent.replace(/build\/commands/g, 'packages/eas-cli/src/commands');
|
||||
fs.writeFileSync('README.md', readmeContent);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Patched README path generation');
|
||||
@@ -0,0 +1,27 @@
|
||||
import { JSONValue } from '@expo/json-file';
|
||||
import { HTTPError, RequestError } from 'got/dist/source';
|
||||
|
||||
export default class ApiV2Error extends RequestError {
|
||||
readonly name = 'ApiV2Error';
|
||||
readonly expoApiV2ErrorCode: string;
|
||||
readonly expoApiV2ErrorDetails?: JSONValue;
|
||||
readonly expoApiV2ErrorServerStack?: string;
|
||||
readonly expoApiV2ErrorMetadata?: object;
|
||||
|
||||
constructor(
|
||||
originalError: HTTPError,
|
||||
response: {
|
||||
message: string;
|
||||
code: string;
|
||||
stack?: string;
|
||||
details?: JSONValue;
|
||||
metadata?: object;
|
||||
}
|
||||
) {
|
||||
super(response.message, originalError, originalError.request);
|
||||
this.expoApiV2ErrorCode = response.code;
|
||||
this.expoApiV2ErrorDetails = response.details;
|
||||
this.expoApiV2ErrorServerStack = response.stack;
|
||||
this.expoApiV2ErrorMetadata = response.metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'assert';
|
||||
import { RequestError } from 'got/dist/source';
|
||||
import nock from 'nock';
|
||||
|
||||
import ApiV2Error from '../ApiV2Error';
|
||||
import { apiClient, getExpoApiBaseUrl } from '../api';
|
||||
|
||||
describe('apiClient', () => {
|
||||
it('converts Expo APIv2 error to ApiV2Error', async () => {
|
||||
nock(getExpoApiBaseUrl())
|
||||
.post('/--/api/v2/test')
|
||||
.reply(400, {
|
||||
errors: [
|
||||
{
|
||||
message: 'hellomessage',
|
||||
code: 'TEST_CODE',
|
||||
stack: 'line 1: hello',
|
||||
details: { who: 'world' },
|
||||
metadata: { an: 'object' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await apiClient.post('test');
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(ApiV2Error);
|
||||
assert(error instanceof ApiV2Error);
|
||||
|
||||
expect(error.message).toEqual('hellomessage');
|
||||
expect(error.expoApiV2ErrorCode).toEqual('TEST_CODE');
|
||||
expect(error.expoApiV2ErrorDetails).toEqual({ who: 'world' });
|
||||
expect(error.expoApiV2ErrorMetadata).toEqual({ an: 'object' });
|
||||
expect(error.expoApiV2ErrorServerStack).toEqual('line 1: hello');
|
||||
});
|
||||
|
||||
it('does not convert non-APIv2 error to ApiV2Error', async () => {
|
||||
nock(getExpoApiBaseUrl()).post('/--/api/v2/test').reply(500, 'Something went wrong');
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await apiClient.post('test');
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error).toBeInstanceOf(RequestError);
|
||||
expect(error).not.toBeInstanceOf(ApiV2Error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Identify } from '@amplitude/identify';
|
||||
import * as Amplitude from '@amplitude/node';
|
||||
import os from 'os';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import UserSettings from './user/UserSettings';
|
||||
|
||||
/**
|
||||
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
|
||||
* the src directory and would change the directory structure of the emitted files
|
||||
* under the build directory
|
||||
*/
|
||||
const packageJSON = require('../package.json');
|
||||
|
||||
const PLATFORM_TO_ANALYTICS_PLATFORM: { [platform: string]: string } = {
|
||||
darwin: 'Mac',
|
||||
win32: 'Windows',
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
let client: Amplitude.NodeClient | null = null;
|
||||
let userIdentifyCalled = false;
|
||||
let identifyData: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
identify: Identify;
|
||||
} | null = null;
|
||||
|
||||
export async function initAsync(): Promise<void> {
|
||||
if (process.env.DISABLE_EAS_ANALYTICS) {
|
||||
await UserSettings.setAsync('amplitudeEnabled', false);
|
||||
}
|
||||
const amplitudeEnabled = await UserSettings.getAsync('amplitudeEnabled', true);
|
||||
if (amplitudeEnabled) {
|
||||
const apiKey =
|
||||
process.env.EXPO_STAGING || process.env.EXPO_LOCAL
|
||||
? 'cdebbc678931403439486c4750781544'
|
||||
: '4ac443afd5073c0df6169291db1d3495';
|
||||
client = Amplitude.init(apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserDataAsync(
|
||||
userId: string,
|
||||
properties: Record<string, any>
|
||||
): Promise<void> {
|
||||
const identify = new Identify();
|
||||
Object.entries(properties).forEach(([property, value]) => {
|
||||
identify.set(property, value);
|
||||
});
|
||||
const savedDeviceId = await UserSettings.getAsync('amplitudeDeviceId', null);
|
||||
const deviceId = savedDeviceId ?? uuidv4();
|
||||
if (!savedDeviceId) {
|
||||
await UserSettings.setAsync('amplitudeDeviceId', deviceId);
|
||||
}
|
||||
identifyData = {
|
||||
userId,
|
||||
deviceId,
|
||||
identify,
|
||||
};
|
||||
|
||||
ensureUserIdentified();
|
||||
}
|
||||
|
||||
export async function flushAsync(): Promise<void> {
|
||||
if (client) {
|
||||
await client.flush();
|
||||
}
|
||||
}
|
||||
|
||||
export function logEvent(name: string, properties: Record<string, any> = {}) {
|
||||
if (client) {
|
||||
ensureUserIdentified();
|
||||
const { userId, deviceId } = identifyData ?? {};
|
||||
client.logEvent({
|
||||
event_type: name,
|
||||
event_properties: properties,
|
||||
...(userId && { user_id: userId }),
|
||||
...(deviceId && { device_id: deviceId }),
|
||||
...getContext(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUserIdentified() {
|
||||
if (client && !userIdentifyCalled && identifyData) {
|
||||
client.identify(identifyData.userId, identifyData.deviceId, identifyData.identify);
|
||||
userIdentifyCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function getContext() {
|
||||
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
|
||||
return {
|
||||
os_name: platform,
|
||||
os_version: os.release(),
|
||||
device_brand: platform,
|
||||
device_model: platform,
|
||||
source_version: packageJSON?.version,
|
||||
source: 'eas cli',
|
||||
};
|
||||
}
|
||||
|
||||
export enum AnalyticsEvent {
|
||||
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import got, { NormalizedOptions } from 'got';
|
||||
import got, { HTTPError, NormalizedOptions, RequestError } from 'got';
|
||||
|
||||
import ApiV2Error from './ApiV2Error';
|
||||
import { getAccessToken, getSessionSecret } from './user/sessionStorage';
|
||||
|
||||
export const apiClient = got.extend({
|
||||
@@ -18,6 +19,22 @@ export const apiClient = got.extend({
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeError: [
|
||||
(error: RequestError): RequestError => {
|
||||
if (error instanceof HTTPError) {
|
||||
let result: { [key: string]: any };
|
||||
try {
|
||||
result = JSON.parse(error.response.body as string);
|
||||
} catch (e2) {
|
||||
return error;
|
||||
}
|
||||
if (result.errors?.length) {
|
||||
return new ApiV2Error(error, result.errors[0]);
|
||||
}
|
||||
}
|
||||
return error;
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
import prompts from 'prompts';
|
||||
|
||||
import { asMock } from '../../__tests__/utils';
|
||||
import { CredentialsProvider } from '../../credentials/CredentialsProvider';
|
||||
import { ensureCredentialsAsync } from '../credentials';
|
||||
|
||||
jest.mock('prompts');
|
||||
|
||||
function createMockCredentialsProvider({
|
||||
hasRemote,
|
||||
hasLocal,
|
||||
isLocalSynced,
|
||||
}: any): CredentialsProvider {
|
||||
return {
|
||||
platform: 'android',
|
||||
hasRemoteAsync: jest.fn().mockImplementation(() => hasRemote || false),
|
||||
hasLocalAsync: jest.fn().mockImplementation(() => hasLocal || false),
|
||||
isLocalSyncedAsync: jest.fn().mockImplementation(() => isLocalSynced || false),
|
||||
} as CredentialsProvider;
|
||||
}
|
||||
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleWarn = console.warn;
|
||||
beforeAll(() => {
|
||||
console.log = jest.fn();
|
||||
console.warn = jest.fn();
|
||||
});
|
||||
afterAll(() => {
|
||||
console.log = originalConsoleLog;
|
||||
console.warn = originalConsoleWarn;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
asMock(prompts).mockReset();
|
||||
asMock(prompts).mockImplementation(() => {
|
||||
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureCredentialsAsync, () => {
|
||||
describe('for generic builds', () => {
|
||||
it("should use credentials.json if it's the only available credentials", async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: false,
|
||||
hasLocal: true,
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
expect(src).toBe('local');
|
||||
});
|
||||
it("should use credentials from server if it's the only available credentials", async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: true,
|
||||
hasLocal: false,
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
expect(src).toBe('remote');
|
||||
});
|
||||
it('should use credentials.json if credentials are the same in credentials.json and on server', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: true,
|
||||
hasLocal: true,
|
||||
isLocalSynced: true,
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
expect(prompts).toHaveBeenCalledTimes(0);
|
||||
expect(src).toBe('local');
|
||||
});
|
||||
it('should ask which credentials to use when local and remote crednitals have different values', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: true,
|
||||
hasLocal: true,
|
||||
isLocalSynced: false,
|
||||
});
|
||||
asMock(prompts).mockImplementationOnce(() => {
|
||||
return { select: 'local' };
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
expect(prompts).toHaveBeenCalledTimes(1);
|
||||
expect(src).toBe('local');
|
||||
});
|
||||
it('should ask when local and remote are not the same (select remote)', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: true,
|
||||
hasLocal: true,
|
||||
isLocalSynced: false,
|
||||
});
|
||||
asMock(prompts).mockImplementationOnce(() => {
|
||||
return { select: 'remote' };
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
expect(prompts).toHaveBeenCalledTimes(1);
|
||||
expect(src).toBe('remote');
|
||||
});
|
||||
it('should should throw an error when local and remote are not the same (in non interactive mode)', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: true,
|
||||
hasLocal: true,
|
||||
isLocalSynced: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, true);
|
||||
throw new Error('ensureCredentialsAsync should throw an Error');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(
|
||||
'Contents of your local credentials.json for Android are not the same as credentials on Expo servers'
|
||||
);
|
||||
}
|
||||
|
||||
expect(prompts).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it('should ask when no credentials are present and return remote if confirm=true', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: false,
|
||||
hasLocal: false,
|
||||
isLocalSynced: false,
|
||||
});
|
||||
asMock(prompts).mockImplementationOnce(() => {
|
||||
return { value: true }; //confirm
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
expect(prompts).toHaveBeenCalledTimes(1);
|
||||
expect(src).toBe('remote');
|
||||
});
|
||||
it('should ask when no credentials are present and abort if confimr=false', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: false,
|
||||
hasLocal: false,
|
||||
isLocalSynced: false,
|
||||
});
|
||||
asMock(prompts).mockImplementationOnce(() => {
|
||||
return { value: false }; //confirm
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, false);
|
||||
throw new Error('ensureCredentialsAsync should throw an Error');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(
|
||||
'Aborting build process, credentials are not configured for Android'
|
||||
);
|
||||
}
|
||||
|
||||
expect(prompts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should throw an error when no credentials are present in non interactive mode', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: false,
|
||||
hasLocal: false,
|
||||
isLocalSynced: false,
|
||||
});
|
||||
asMock(prompts).mockImplementation(() => {
|
||||
return { value: true }; // confirm
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, true);
|
||||
throw new Error('ensureCredentialsAsync should throw an Error');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch('Credentials for this app are not configured');
|
||||
}
|
||||
|
||||
expect(prompts).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('for managed builds', () => {
|
||||
it("should use credentials.json if it's the only available credentials", async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: false,
|
||||
hasLocal: true,
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Managed,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
expect(src).toBe('local');
|
||||
});
|
||||
it("should use credentials from server if it's the only available credentials", async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: true,
|
||||
hasLocal: false,
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Managed,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
expect(src).toBe('remote');
|
||||
});
|
||||
it('should use credentials.json if credentials are the same in credentials.json and on server', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: true,
|
||||
hasLocal: true,
|
||||
isLocalSynced: true,
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Managed,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
expect(prompts).toHaveBeenCalledTimes(0);
|
||||
expect(src).toBe('local');
|
||||
});
|
||||
|
||||
it('should use credentials.json even if credentials on server are different', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: true,
|
||||
hasLocal: true,
|
||||
isLocalSynced: false,
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Managed,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
expect(prompts).toHaveBeenCalledTimes(0);
|
||||
expect(src).toBe('local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic builds with credentialsSource set to local', () => {
|
||||
it('should use credentials.json without asking even if credentials on server are different', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: true,
|
||||
hasLocal: true,
|
||||
isLocalSynced: false,
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
CredentialsSource.LOCAL,
|
||||
false
|
||||
);
|
||||
expect(prompts).toHaveBeenCalledTimes(0);
|
||||
expect(src).toBe('local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic builds with credentialsSource set to remote', () => {
|
||||
it('should use credentials from server even if credentials.json exists and have different values', async () => {
|
||||
const provider = createMockCredentialsProvider({
|
||||
hasRemote: true,
|
||||
hasLocal: true,
|
||||
isLocalSynced: false,
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
CredentialsSource.REMOTE,
|
||||
false
|
||||
);
|
||||
expect(prompts).toHaveBeenCalledTimes(0);
|
||||
expect(src).toBe('remote');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import { AndroidConfig, AndroidManifest } from '@expo/config-plugins';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import log from '../../log';
|
||||
import Log from '../../log';
|
||||
import { getProjectAccountName } from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { ensureValidVersions } from '../utils/updates';
|
||||
@@ -10,28 +10,22 @@ import { ensureValidVersions } from '../utils/updates';
|
||||
export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
|
||||
ensureValidVersions(exp);
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
|
||||
const buildGradleContent = await fs.readFile(buildGradlePath, 'utf8');
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
|
||||
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
|
||||
|
||||
if (!AndroidConfig.Updates.isBuildGradleConfigured(buildGradleContent, projectDir, exp)) {
|
||||
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir, exp);
|
||||
|
||||
await fs.writeFile(
|
||||
buildGradlePath,
|
||||
`${buildGradleContent}\n// Integration with Expo updates\n${gradleScriptApply}\n`
|
||||
if (!AndroidConfig.Updates.isBuildGradleConfigured(projectDir, buildGradleContents)) {
|
||||
const updatedBuildGradleContents = AndroidConfig.Updates.ensureBuildGradleContainsConfigurationScript(
|
||||
projectDir,
|
||||
buildGradleContents
|
||||
);
|
||||
await fs.writeFile(buildGradlePath, updatedBuildGradleContents);
|
||||
}
|
||||
|
||||
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
|
||||
if (!androidManifestPath) {
|
||||
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
|
||||
}
|
||||
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
|
||||
androidManifestPath
|
||||
);
|
||||
const androidManifest = await getAndroidManifestAsync(projectDir);
|
||||
|
||||
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
|
||||
const result = await AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
|
||||
const result = AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
|
||||
|
||||
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, result);
|
||||
}
|
||||
@@ -46,17 +40,14 @@ export async function syncUpdatesConfigurationAsync(
|
||||
try {
|
||||
await ensureUpdatesConfiguredAsync(projectDir, exp);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
Log.error(
|
||||
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
|
||||
if (!androidManifestPath) {
|
||||
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
|
||||
}
|
||||
let androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(androidManifestPath);
|
||||
let androidManifest = await getAndroidManifestAsync(projectDir);
|
||||
|
||||
if (!AndroidConfig.Updates.areVersionsSynced(exp, androidManifest)) {
|
||||
androidManifest = AndroidConfig.Updates.setVersionsConfig(exp, androidManifest);
|
||||
@@ -64,30 +55,60 @@ export async function syncUpdatesConfigurationAsync(
|
||||
}
|
||||
|
||||
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
|
||||
log.warn(
|
||||
Log.warn(
|
||||
'Native project configuration is not synced with values present in your app.json, run "eas build:configure" to make sure all values are applied in the native project'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
|
||||
const buildGradleContent = await fs.readFile(buildGradlePath, 'utf8');
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
|
||||
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
|
||||
|
||||
if (!AndroidConfig.Updates.isBuildGradleConfigured(buildGradleContent, projectDir, exp)) {
|
||||
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir, exp);
|
||||
if (!AndroidConfig.Updates.isBuildGradleConfigured(projectDir, buildGradleContents)) {
|
||||
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir);
|
||||
throw new Error(`Missing ${gradleScriptApply} in ${buildGradlePath}`);
|
||||
}
|
||||
|
||||
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
|
||||
if (!androidManifestPath) {
|
||||
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
|
||||
}
|
||||
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
|
||||
androidManifestPath
|
||||
);
|
||||
const androidManifest = await getAndroidManifestAsync(projectDir);
|
||||
|
||||
if (!AndroidConfig.Updates.isMainApplicationMetaDataSet(androidManifest)) {
|
||||
throw new Error('Missing values in AndroidManifest.xml');
|
||||
}
|
||||
}
|
||||
|
||||
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
|
||||
try {
|
||||
const androidManifest = await getAndroidManifestAsync(projectDir);
|
||||
return AndroidConfig.Manifest.getMainApplicationMetaDataValue(
|
||||
androidManifest,
|
||||
AndroidConfig.Updates.Config.RELEASE_CHANNEL
|
||||
);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readChannelSafelyAsync(projectDir: string): Promise<string | null> {
|
||||
try {
|
||||
const androidManifest = await getAndroidManifestAsync(projectDir);
|
||||
const stringifiedRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
|
||||
androidManifest,
|
||||
AndroidConfig.Updates.Config.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY
|
||||
);
|
||||
if (!stringifiedRequestHeaders) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(stringifiedRequestHeaders)['expo-channel-name'] ?? null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAndroidManifestAsync(projectDir: string): Promise<AndroidManifest> {
|
||||
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
|
||||
if (!androidManifestPath) {
|
||||
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
|
||||
}
|
||||
return AndroidConfig.Manifest.readAndroidManifestAsync(androidManifestPath);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
|
||||
import { readChannelSafelyAsync } from '../UpdatesModule';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
afterAll(() => {
|
||||
// do not remove the following line
|
||||
// this fixes a weird error with tempy in @expo/image-utils
|
||||
fs.removeSync(os.tmpdir());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
// do not remove the following line
|
||||
// this fixes a weird error with tempy in @expo/image-utils
|
||||
fs.mkdirpSync(os.tmpdir());
|
||||
});
|
||||
|
||||
const testChannel = 'test-channel';
|
||||
describe(readChannelSafelyAsync, () => {
|
||||
it('retrieves the channel when it is defined natively', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./android/app/src/main/AndroidManifest.xml': AndroidManifestWithChannel,
|
||||
},
|
||||
'/expo-project'
|
||||
);
|
||||
const channel = await readChannelSafelyAsync('/expo-project');
|
||||
expect(channel).toBe(testChannel);
|
||||
});
|
||||
it('returns null if the channel is not defined natively', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./android/app/src/main/AndroidManifest.xml': AndroidManifestNoChannel,
|
||||
},
|
||||
'/expo-project'
|
||||
);
|
||||
const channel = await readChannelSafelyAsync('/expo-project');
|
||||
expect(channel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const AndroidManifestWithChannel = `
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.expo.mycoolapp">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:allowBackup="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value='{"expo-channel-name":"${testChannel}"}'/>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
|
||||
</application>
|
||||
|
||||
|
||||
</manifest>
|
||||
`;
|
||||
const AndroidManifestNoChannel = `
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.expo.mycoolapp">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:allowBackup="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
|
||||
</application>
|
||||
|
||||
|
||||
</manifest>
|
||||
`;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
|
||||
import { readVersionCodeAsync, readVersionNameAsync } from '../version';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
afterAll(() => {
|
||||
// do not remove the following line
|
||||
// this fixes a weird error with tempy in @expo/image-utils
|
||||
fs.removeSync(os.tmpdir());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
// do not remove the following line
|
||||
// this fixes a weird error with tempy in @expo/image-utils
|
||||
fs.mkdirpSync(os.tmpdir());
|
||||
});
|
||||
|
||||
describe(readVersionCodeAsync, () => {
|
||||
describe('generic project', () => {
|
||||
it('reads the version code from native code', async () => {
|
||||
const exp = initGenericProject();
|
||||
const versionCode = await readVersionCodeAsync('/repo', exp);
|
||||
expect(versionCode).toBe(123);
|
||||
});
|
||||
});
|
||||
describe('managed project', () => {
|
||||
it('reads the version code from expo config', async () => {
|
||||
const exp = initManagedProject();
|
||||
const versionCode = await readVersionCodeAsync('/repo', exp);
|
||||
expect(versionCode).toBe(123);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(readVersionNameAsync, () => {
|
||||
describe('generic project', () => {
|
||||
it('reads the version name from native code', async () => {
|
||||
const exp = initGenericProject();
|
||||
const versionName = await readVersionNameAsync('/repo', exp);
|
||||
expect(versionName).toBe('1.0');
|
||||
});
|
||||
});
|
||||
describe('managed project', () => {
|
||||
it('reads the version from expo config', async () => {
|
||||
const exp = initManagedProject();
|
||||
const versionName = await readVersionNameAsync('/repo', exp);
|
||||
expect(versionName).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function initGenericProject(): ExpoConfig {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: {
|
||||
version: '1.0.0',
|
||||
android: {
|
||||
versionCode: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./android/app/build.gradle': `android {
|
||||
defaultConfig {
|
||||
applicationId "com.expo.testapp"
|
||||
versionCode 123
|
||||
versionName "1.0"
|
||||
}
|
||||
}`,
|
||||
'./android/app/src/main/AndroidManifest.xml': 'fake',
|
||||
},
|
||||
'/repo'
|
||||
);
|
||||
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
version: '1.0.0',
|
||||
android: {
|
||||
versionCode: 123,
|
||||
},
|
||||
};
|
||||
return fakeExp;
|
||||
}
|
||||
|
||||
function initManagedProject(): ExpoConfig {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: {
|
||||
version: '1.0.0',
|
||||
android: {
|
||||
versionCode: 123,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
'/repo'
|
||||
);
|
||||
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
version: '1.0.0',
|
||||
android: {
|
||||
versionCode: 123,
|
||||
},
|
||||
};
|
||||
return fakeExp;
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import log from '../../log';
|
||||
import {
|
||||
ensureAppIdentifierIsDefinedAsync,
|
||||
getAndroidApplicationIdAsync,
|
||||
getProjectConfigDescription,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { gitAddAsync } from '../../utils/git';
|
||||
import { Platform } from '../types';
|
||||
|
||||
enum ApplicationIdSource {
|
||||
AndroidProject,
|
||||
AppJson,
|
||||
}
|
||||
|
||||
function isApplicationIdValid(applicationId: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(applicationId);
|
||||
}
|
||||
|
||||
export async function ensureApplicationIdIsValidAsync(projectDir: string) {
|
||||
const applicationId = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.Android);
|
||||
if (!isApplicationIdValid(applicationId)) {
|
||||
const configDescription = getProjectConfigDescription(projectDir);
|
||||
log.error(
|
||||
`Invalid format of Android applicationId. Only alphanumeric characters, '.' and '_' are allowed, and each '.' must be followed by a letter.`
|
||||
);
|
||||
log.error(`Update "android.package" in ${configDescription} and run this command again.`);
|
||||
throw new Error('Invalid applicationId');
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureApplicationIdAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
allowExperimental: boolean
|
||||
): Promise<void> {
|
||||
const configDescription = getProjectConfigDescription(projectDir);
|
||||
const applicationIdFromConfig = AndroidConfig.Package.getPackage(exp);
|
||||
const applicationIdFromAndroidProject = await getAndroidApplicationIdAsync(projectDir);
|
||||
|
||||
if (applicationIdFromAndroidProject && applicationIdFromConfig) {
|
||||
if (applicationIdFromConfig !== applicationIdFromAndroidProject) {
|
||||
log.newLine();
|
||||
log.warn(
|
||||
`We detected that your Android project is configured with a different application id than the one defined in ${configDescription}.`
|
||||
);
|
||||
const hasApplicationIdInStaticConfig = await hasApplicationIdInStaticConfigAsync(
|
||||
projectDir,
|
||||
exp
|
||||
);
|
||||
if (!hasApplicationIdInStaticConfig) {
|
||||
log(`If you choose the one defined in ${configDescription} we'll automatically configure your Android project with it.
|
||||
However, if you choose the one defined in the Android project you'll have to update ${configDescription} on your own.`);
|
||||
}
|
||||
log.newLine();
|
||||
const { applicationIdSource } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'applicationIdSource',
|
||||
message: 'Which application id should we use?',
|
||||
choices: [
|
||||
{
|
||||
title: `${chalk.bold(
|
||||
applicationIdFromAndroidProject
|
||||
)} - In build.gradle in Android project`,
|
||||
value: ApplicationIdSource.AndroidProject,
|
||||
},
|
||||
{
|
||||
title: `${chalk.bold(applicationIdFromConfig)} - In your ${configDescription}`,
|
||||
value: ApplicationIdSource.AppJson,
|
||||
},
|
||||
],
|
||||
});
|
||||
switch (applicationIdSource) {
|
||||
case ApplicationIdSource.AppJson: {
|
||||
await updateApplicationIdInBuildGradleAsync(projectDir, exp);
|
||||
break;
|
||||
}
|
||||
case ApplicationIdSource.AndroidProject: {
|
||||
if (hasApplicationIdInStaticConfig) {
|
||||
await updateAppJsonConfigAsync(projectDir, exp, applicationIdFromAndroidProject);
|
||||
} else {
|
||||
throw new Error(missingPackageMessage(configDescription));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!applicationIdFromAndroidProject && !applicationIdFromConfig) {
|
||||
throw new Error(missingPackageMessage(configDescription));
|
||||
} else if (applicationIdFromAndroidProject && !applicationIdFromConfig) {
|
||||
if (getConfigFilePaths(projectDir).staticConfigPath) {
|
||||
await updateAppJsonConfigAsync(projectDir, exp, applicationIdFromAndroidProject);
|
||||
} else {
|
||||
throw new Error(missingPackageMessage(configDescription));
|
||||
}
|
||||
} else if (!applicationIdFromAndroidProject && applicationIdFromConfig) {
|
||||
// This should never happen, adding warning just in case
|
||||
log.warn(
|
||||
'applicationId is not specified in your ./android/app/build.gradle file. Make sure your project is configured correctly before building.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (allowExperimental) {
|
||||
// this step is optional, the Play store is using applicationId value from build.gradle
|
||||
// to identify the app, so package name does not need to be updated
|
||||
await updatePackageNameAsync(projectDir, exp);
|
||||
await gitAddAsync(path.join(projectDir, 'android'), { intentToAdd: true });
|
||||
}
|
||||
}
|
||||
|
||||
function missingPackageMessage(configDescription: string): string {
|
||||
return `Please define "android.package" in ${configDescription} and run "eas build:configure" again.`;
|
||||
}
|
||||
|
||||
async function updateAppJsonConfigAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
newApplicationId: string
|
||||
): Promise<void> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
assert(paths.staticConfigPath, "can't update dynamic configs");
|
||||
|
||||
const rawStaticConfig = await fs.readJSON(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = {
|
||||
...rawStaticConfig.expo,
|
||||
android: { ...rawStaticConfig.expo?.android, package: newApplicationId },
|
||||
};
|
||||
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
|
||||
|
||||
exp.android = { ...exp.android, package: newApplicationId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if static config exists and if expo.android.package is defined there.
|
||||
* It will return false if the value in static config is different than "android.package" in ExpoConfig
|
||||
*/
|
||||
async function hasApplicationIdInStaticConfigAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<boolean> {
|
||||
if (!exp.android?.package) {
|
||||
return false;
|
||||
}
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (!paths.staticConfigPath) {
|
||||
return false;
|
||||
}
|
||||
const rawStaticConfig = await fs.readJson(paths.staticConfigPath);
|
||||
return rawStaticConfig?.expo?.android?.package === exp.android.package;
|
||||
}
|
||||
|
||||
async function updateApplicationIdInBuildGradleAsync(projectDir: string, exp: ExpoConfig) {
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
|
||||
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
|
||||
const updatedBuildGradleContents = AndroidConfig.Package.setPackageInBuildGradle(
|
||||
exp,
|
||||
buildGradleContents
|
||||
);
|
||||
await fs.writeFile(buildGradlePath, updatedBuildGradleContents);
|
||||
}
|
||||
|
||||
async function updatePackageNameAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
|
||||
await AndroidConfig.Package.renamePackageOnDisk(exp, projectDir);
|
||||
|
||||
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
|
||||
if (!androidManifestPath) {
|
||||
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
|
||||
}
|
||||
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
|
||||
androidManifestPath
|
||||
);
|
||||
const updatedAndroidManifest = AndroidConfig.Package.setPackageInAndroidManifest(
|
||||
exp,
|
||||
androidManifest
|
||||
);
|
||||
await AndroidConfig.Manifest.writeAndroidManifestAsync(
|
||||
androidManifestPath,
|
||||
updatedAndroidManifest
|
||||
);
|
||||
}
|
||||
@@ -1,111 +1,116 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { Android, Metadata, Workflow } from '@expo/eas-build-job';
|
||||
import { EasConfig } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import AndroidCredentialsProvider, {
|
||||
AndroidCredentials,
|
||||
} from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { createCredentialsContextAsync } from '../../credentials/context';
|
||||
import log from '../../log';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
ensureApplicationIdIsDefinedForManagedProjectAsync,
|
||||
getApplicationIdAsync,
|
||||
} from '../../project/android/applicationId';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
import { findAccountByName } from '../../user/Account';
|
||||
import { CredentialsResult, prepareBuildRequestForPlatformAsync } from '../build';
|
||||
import { BuildContext, CommandContext, createBuildContext } from '../context';
|
||||
import { ensureCredentialsAsync } from '../credentials';
|
||||
import { BuildContext, CommandContext, createBuildContextAsync } from '../context';
|
||||
import { transformMetadata } from '../graphql';
|
||||
import { Platform } from '../types';
|
||||
import { ensureApplicationIdIsValidAsync } from './applicationId';
|
||||
import { logCredentialsSource } from '../utils/credentials';
|
||||
import { validateAndSyncProjectConfigurationAsync } from './configure';
|
||||
import { transformJob } from './graphql';
|
||||
import { prepareJobAsync } from './prepareJob';
|
||||
|
||||
export async function prepareAndroidBuildAsync(
|
||||
commandCtx: CommandContext,
|
||||
easConfig: EasConfig
|
||||
): Promise<() => Promise<string>> {
|
||||
const buildCtx = createBuildContext<Platform.Android>({
|
||||
): Promise<() => Promise<string | undefined>> {
|
||||
const buildCtx = await createBuildContextAsync<Platform.ANDROID>({
|
||||
commandCtx,
|
||||
platform: Platform.Android,
|
||||
platform: Platform.ANDROID,
|
||||
easConfig,
|
||||
});
|
||||
const { buildProfile } = buildCtx;
|
||||
|
||||
if (
|
||||
buildProfile.workflow === Workflow.Generic &&
|
||||
!(await fs.pathExists(path.join(commandCtx.projectDir, 'android')))
|
||||
) {
|
||||
throw new Error(
|
||||
`"android" directory not found. If you're trying to build a managed project, set ${chalk.bold(
|
||||
`builds.android.${commandCtx.profile}.workflow`
|
||||
)} in "eas.json" to "managed".`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
buildProfile.workflow === Workflow.Generic &&
|
||||
buildProfile.distribution === 'internal' &&
|
||||
buildProfile.gradleCommand?.match(/bundle/)
|
||||
) {
|
||||
log.addNewLineIfNone();
|
||||
log.warn(
|
||||
if (buildProfile.distribution === 'internal' && buildProfile.gradleCommand?.match(/bundle/)) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`You're building your Android app for internal distribution. However, we've detected that the Gradle command you defined (${chalk.underline(
|
||||
buildProfile.gradleCommand
|
||||
)}) includes string 'bundle'.
|
||||
This means that it will most likely produce an AAB and you will not be able to install it on your Android devices straight from the Expo website.`
|
||||
);
|
||||
log.newLine();
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({ message: 'Would you like to proceed?' });
|
||||
if (!confirmed) {
|
||||
log.error('Please update eas.json and come back again.');
|
||||
Log.error('Please update eas.json and come back again.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await ensureApplicationIdIsValidAsync(commandCtx.projectDir);
|
||||
if (buildCtx.workflow === Workflow.MANAGED) {
|
||||
await ensureApplicationIdIsDefinedForManagedProjectAsync(commandCtx.projectDir, commandCtx.exp);
|
||||
}
|
||||
|
||||
return await prepareBuildRequestForPlatformAsync({
|
||||
ctx: buildCtx,
|
||||
projectConfiguration: {},
|
||||
ensureCredentialsAsync: ensureAndroidCredentialsAsync,
|
||||
ensureProjectConfiguredAsync: async () => {
|
||||
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
|
||||
if (buildCtx.workflow === Workflow.GENERIC) {
|
||||
await validateAndSyncProjectConfigurationAsync(commandCtx.projectDir, commandCtx.exp);
|
||||
}
|
||||
},
|
||||
prepareJobAsync,
|
||||
sendBuildRequestAsync: async (
|
||||
appId: string,
|
||||
job: Android.Job,
|
||||
metadata: Metadata
|
||||
): Promise<BuildResult> => {
|
||||
const graphqlMetadata = transformMetadata(metadata);
|
||||
const graphqlJob = transformJob(job);
|
||||
return await BuildMutation.createAndroidBuildAsync({
|
||||
appId,
|
||||
job: graphqlJob,
|
||||
metadata: graphqlMetadata,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(ctx: BuildContext<Platform.Android>): boolean {
|
||||
return (
|
||||
ctx.buildProfile.workflow === Workflow.Managed ||
|
||||
(ctx.buildProfile.workflow === Workflow.Generic && !ctx.buildProfile.withoutCredentials)
|
||||
);
|
||||
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
|
||||
return !ctx.buildProfile.withoutCredentials;
|
||||
}
|
||||
|
||||
async function ensureAndroidCredentialsAsync(
|
||||
ctx: BuildContext<Platform.Android>
|
||||
ctx: BuildContext<Platform.ANDROID>
|
||||
): Promise<CredentialsResult<AndroidCredentials> | undefined> {
|
||||
if (!shouldProvideCredentials(ctx)) {
|
||||
return;
|
||||
}
|
||||
const androidApplicationIdentifier = await getApplicationIdAsync(
|
||||
ctx.commandCtx.projectDir,
|
||||
ctx.commandCtx.exp
|
||||
);
|
||||
const provider = new AndroidCredentialsProvider(
|
||||
await createCredentialsContextAsync(ctx.commandCtx.projectDir, {
|
||||
nonInteractive: ctx.commandCtx.nonInteractive,
|
||||
}),
|
||||
{
|
||||
app: {
|
||||
account: nullthrows(
|
||||
findAccountByName(ctx.commandCtx.user.accounts, ctx.commandCtx.accountName),
|
||||
`You do not have access to account: ${ctx.commandCtx.accountName}`
|
||||
),
|
||||
projectName: ctx.commandCtx.projectName,
|
||||
accountName: ctx.commandCtx.accountName,
|
||||
androidApplicationIdentifier,
|
||||
},
|
||||
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
|
||||
}
|
||||
);
|
||||
const credentialsSource = await ensureCredentialsAsync(
|
||||
provider,
|
||||
ctx.buildProfile.workflow,
|
||||
ctx.buildProfile.credentialsSource,
|
||||
ctx.commandCtx.nonInteractive
|
||||
);
|
||||
const { credentialsSource } = ctx.buildProfile;
|
||||
logCredentialsSource(credentialsSource, Platform.ANDROID);
|
||||
return {
|
||||
credentials: await provider.getCredentialsAsync(credentialsSource),
|
||||
source: credentialsSource,
|
||||
|
||||
@@ -1,49 +1,44 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
|
||||
import log from '../../log';
|
||||
import { getAndroidApplicationIdAsync } from '../../project/projectUtils';
|
||||
import { gitAddAsync } from '../../utils/git';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
ensureApplicationIdIsDefinedForManagedProjectAsync,
|
||||
warnIfAndroidPackageDefinedInAppConfigForGenericProject,
|
||||
} from '../../project/android/applicationId';
|
||||
import vcs from '../../vcs';
|
||||
import { ConfigureContext } from '../context';
|
||||
import { isExpoUpdatesInstalled } from '../utils/updates';
|
||||
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
|
||||
import { configureApplicationIdAsync, ensureApplicationIdIsValidAsync } from './applicationId';
|
||||
|
||||
export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void> {
|
||||
if (!ctx.hasAndroidNativeProject) {
|
||||
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
|
||||
return;
|
||||
}
|
||||
|
||||
warnIfAndroidPackageDefinedInAppConfigForGenericProject(ctx.projectDir, ctx.exp);
|
||||
|
||||
await AndroidConfig.EasBuild.configureEasBuildAsync(ctx.projectDir);
|
||||
await configureApplicationIdAsync(ctx.projectDir, ctx.exp, ctx.allowExperimental);
|
||||
await ensureApplicationIdIsValidAsync(ctx.projectDir);
|
||||
|
||||
const easGradlePath = AndroidConfig.EasBuild.getEasBuildGradlePath(ctx.projectDir);
|
||||
await gitAddAsync(easGradlePath, { intentToAdd: true });
|
||||
await vcs.trackFileAsync(easGradlePath);
|
||||
|
||||
if (isExpoUpdatesInstalled(ctx.projectDir)) {
|
||||
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
|
||||
}
|
||||
log.withTick('Android project configured');
|
||||
Log.withTick('Android project configured');
|
||||
}
|
||||
|
||||
export async function validateAndSyncProjectConfigurationAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<void> {
|
||||
const applicationIdFromAndroidProject = await getAndroidApplicationIdAsync(projectDir);
|
||||
const packageNameFromConfig = AndroidConfig.Package.getPackage(exp);
|
||||
const isApplicationIdConfigured =
|
||||
applicationIdFromAndroidProject && applicationIdFromAndroidProject === packageNameFromConfig;
|
||||
|
||||
const isEasBuildGradleConfigured = await AndroidConfig.EasBuild.isEasBuildGradleConfiguredAsync(
|
||||
projectDir
|
||||
);
|
||||
if (!isEasBuildGradleConfigured || !isApplicationIdConfigured) {
|
||||
if (!(await AndroidConfig.EasBuild.isEasBuildGradleConfiguredAsync(projectDir))) {
|
||||
throw new Error(
|
||||
'Project is not configured. Please run "eas build:configure" to configure the project.'
|
||||
);
|
||||
}
|
||||
|
||||
if (isExpoUpdatesInstalled(projectDir)) {
|
||||
await syncUpdatesConfigurationAsync(projectDir, exp);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
|
||||
import { AndroidBuildType, AndroidJobInput } from '../../graphql/generated';
|
||||
import { transformProjectArchive, transformWorkflow } from '../graphql';
|
||||
|
||||
export function transformJob(job: Android.Job): AndroidJobInput {
|
||||
return {
|
||||
type: transformWorkflow(job.type),
|
||||
projectArchive: transformProjectArchive(job.projectArchive),
|
||||
projectRootDirectory: job.projectRootDirectory,
|
||||
releaseChannel: job.releaseChannel,
|
||||
updates: job.updates,
|
||||
secrets: job.secrets,
|
||||
builderEnvironment: job.builderEnvironment,
|
||||
cache: job.cache,
|
||||
gradleCommand: job.gradleCommand,
|
||||
artifactPath: job.artifactPath,
|
||||
username: job.username,
|
||||
buildType: job.buildType && transformBuildType(job.buildType),
|
||||
};
|
||||
}
|
||||
|
||||
function transformBuildType(buildType: Android.BuildType): AndroidBuildType {
|
||||
if (buildType === Android.BuildType.APK) {
|
||||
return AndroidBuildType.Apk;
|
||||
} else if (buildType === Android.BuildType.APP_BUNDLE) {
|
||||
return AndroidBuildType.AppBundle;
|
||||
} else {
|
||||
return AndroidBuildType.DevelopmentClient;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,26 @@
|
||||
import {
|
||||
Android,
|
||||
ArchiveSource,
|
||||
ArchiveSourceType,
|
||||
Job,
|
||||
Workflow,
|
||||
sanitizeJob,
|
||||
} from '@expo/eas-build-job';
|
||||
import { AndroidGenericBuildProfile, AndroidManagedBuildProfile } from '@expo/eas-json';
|
||||
import { ArchiveSource, Job, sanitizeJob } from '@expo/eas-build-job';
|
||||
import path from 'path';
|
||||
|
||||
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { readSecretEnvsAsync } from '../../credentials/credentialsJson/read';
|
||||
import { getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { gitRootDirectoryAsync } from '../../utils/git';
|
||||
import { getUsername } from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import vcs from '../../vcs';
|
||||
import { BuildContext } from '../context';
|
||||
import { Platform } from '../types';
|
||||
|
||||
interface JobData {
|
||||
archiveBucketKey: string;
|
||||
projectArchive: ArchiveSource;
|
||||
credentials?: AndroidCredentials;
|
||||
}
|
||||
|
||||
export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.Android>,
|
||||
ctx: BuildContext<Platform.ANDROID>,
|
||||
jobData: JobData
|
||||
): Promise<Job> {
|
||||
if (ctx.buildProfile.workflow === Workflow.Generic) {
|
||||
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
|
||||
return sanitizeJob(partialJob);
|
||||
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
|
||||
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
|
||||
return sanitizeJob(partialJob);
|
||||
} else {
|
||||
throw new Error("Unknown workflow. Shouldn't happen");
|
||||
}
|
||||
}
|
||||
|
||||
interface CommonJobProperties {
|
||||
platform: Platform.Android;
|
||||
projectArchive: ArchiveSource;
|
||||
secrets: {
|
||||
buildCredentials?: {
|
||||
keystore: Android.Keystore;
|
||||
};
|
||||
secretEnvs?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareJobCommonAsync(
|
||||
ctx: BuildContext<Platform.Android>,
|
||||
jobData: JobData
|
||||
): Promise<Partial<CommonJobProperties>> {
|
||||
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
|
||||
const credentials = jobData.credentials;
|
||||
const username = getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync());
|
||||
const projectRootDirectory =
|
||||
path.relative(await vcs.getRootPathAsync(), ctx.commandCtx.projectDir) || '.';
|
||||
const { credentials } = jobData;
|
||||
const buildCredentials = credentials
|
||||
? {
|
||||
buildCredentials: {
|
||||
@@ -66,48 +34,35 @@ async function prepareJobCommonAsync(
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
platform: Platform.Android,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.S3,
|
||||
bucketKey: jobData.archiveBucketKey,
|
||||
const job = {
|
||||
type: ctx.workflow,
|
||||
platform: Platform.ANDROID,
|
||||
projectRootDirectory,
|
||||
projectArchive: jobData.projectArchive,
|
||||
builderEnvironment: {
|
||||
image: ctx.buildProfile.image,
|
||||
node: ctx.buildProfile.node,
|
||||
yarn: ctx.buildProfile.yarn,
|
||||
ndk: ctx.buildProfile.ndk,
|
||||
expoCli: ctx.buildProfile.expoCli,
|
||||
env: ctx.buildProfile.env,
|
||||
},
|
||||
cache: {
|
||||
...ctx.buildProfile.cache,
|
||||
clear: ctx.commandCtx.clearCache,
|
||||
},
|
||||
secrets: {
|
||||
...(secretEnvs ? { secretEnvs } : {}),
|
||||
...buildCredentials,
|
||||
},
|
||||
};
|
||||
}
|
||||
releaseChannel: ctx.buildProfile.releaseChannel,
|
||||
updates: { channel: ctx.buildProfile.channel },
|
||||
|
||||
async function prepareGenericJobAsync(
|
||||
ctx: BuildContext<Platform.Android>,
|
||||
jobData: JobData,
|
||||
buildProfile: AndroidGenericBuildProfile
|
||||
): Promise<Partial<Android.GenericJob>> {
|
||||
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
|
||||
return {
|
||||
...(await prepareJobCommonAsync(ctx, jobData)),
|
||||
type: Workflow.Generic,
|
||||
gradleCommand: buildProfile.gradleCommand,
|
||||
artifactPath: buildProfile.artifactPath,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
projectRootDirectory,
|
||||
};
|
||||
}
|
||||
gradleCommand: ctx.buildProfile.gradleCommand,
|
||||
artifactPath: ctx.buildProfile.artifactPath,
|
||||
|
||||
async function prepareManagedJobAsync(
|
||||
ctx: BuildContext<Platform.Android>,
|
||||
jobData: JobData,
|
||||
buildProfile: AndroidManagedBuildProfile
|
||||
): Promise<Partial<Android.ManagedJob>> {
|
||||
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
|
||||
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
|
||||
return {
|
||||
...(await prepareJobCommonAsync(ctx, jobData)),
|
||||
type: Workflow.Managed,
|
||||
username: accountName,
|
||||
buildType: buildProfile.buildType,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
projectRootDirectory,
|
||||
username,
|
||||
buildType: ctx.buildProfile.buildType,
|
||||
};
|
||||
|
||||
return sanitizeJob(job);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
|
||||
export async function readVersionCodeAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<number | undefined> {
|
||||
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
const buildGradle = readBuildGradle(projectDir);
|
||||
const matchResult = buildGradle?.match(/versionCode (.*)/);
|
||||
if (matchResult) {
|
||||
return Number(matchResult[1]);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
return AndroidConfig.Version.getVersionCode(exp) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readVersionNameAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<string | undefined> {
|
||||
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
const buildGradle = readBuildGradle(projectDir);
|
||||
const matchResult = buildGradle?.match(/versionName ["'](.*)["']/);
|
||||
if (matchResult) {
|
||||
return matchResult[1];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
return exp.version;
|
||||
}
|
||||
}
|
||||
|
||||
function readBuildGradle(projectDir: string): string | undefined {
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
|
||||
if (!fs.pathExistsSync(buildGradlePath)) {
|
||||
return undefined;
|
||||
}
|
||||
return fs.readFileSync(buildGradlePath, 'utf8');
|
||||
}
|
||||
@@ -1,121 +1,130 @@
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import { ArchiveSource, ArchiveSourceType, Job, Metadata } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { apiClient } from '../api';
|
||||
import log from '../log';
|
||||
import { UploadSessionType } from '../graphql/generated';
|
||||
import { BuildResult } from '../graphql/mutations/BuildMutation';
|
||||
import Log from '../log';
|
||||
import { promptAsync } from '../prompts';
|
||||
import { UploadType, uploadAsync } from '../uploads';
|
||||
import { uploadAsync } from '../uploads';
|
||||
import { formatBytes } from '../utils/files';
|
||||
import { createProgressTracker } from '../utils/progress';
|
||||
import { platformDisplayNames } from './constants';
|
||||
import vcs from '../vcs';
|
||||
import { requestedPlatformDisplayNames } from './constants';
|
||||
import { BuildContext } from './context';
|
||||
import { runLocalBuildAsync } from './local';
|
||||
import { collectMetadata } from './metadata';
|
||||
import { AnalyticsEvent, Platform, TrackingContext } from './types';
|
||||
import Analytics from './utils/analytics';
|
||||
import { Platform, TrackingContext } from './types';
|
||||
import Analytics, { Event } from './utils/analytics';
|
||||
import { printDeprecationWarnings } from './utils/printBuildInfo';
|
||||
import {
|
||||
commitPromptAsync,
|
||||
isGitStatusCleanAsync,
|
||||
makeProjectTarballAsync,
|
||||
showDiffAsync,
|
||||
} from './utils/repository';
|
||||
import { commitPromptAsync, makeProjectTarballAsync } from './utils/repository';
|
||||
|
||||
export interface CredentialsResult<Credentials> {
|
||||
source: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
|
||||
credentials: Credentials;
|
||||
}
|
||||
|
||||
interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration> {
|
||||
export interface JobData<Credentials> {
|
||||
credentials?: Credentials;
|
||||
projectArchive: ArchiveSource;
|
||||
}
|
||||
|
||||
interface Builder<TPlatform extends Platform, Credentials, TJob extends Job> {
|
||||
ctx: BuildContext<TPlatform>;
|
||||
projectConfiguration: ProjectConfiguration;
|
||||
|
||||
ensureCredentialsAsync(
|
||||
ctx: BuildContext<TPlatform>
|
||||
): Promise<CredentialsResult<Credentials> | undefined>;
|
||||
ensureProjectConfiguredAsync(ctx: BuildContext<TPlatform>): Promise<void>;
|
||||
prepareJobAsync(
|
||||
ctx: BuildContext<TPlatform>,
|
||||
jobData: {
|
||||
archiveBucketKey: string;
|
||||
credentials?: Credentials;
|
||||
projectConfiguration?: ProjectConfiguration;
|
||||
}
|
||||
): Promise<Job>;
|
||||
prepareJobAsync(ctx: BuildContext<TPlatform>, jobData: JobData<Credentials>): Promise<Job>;
|
||||
sendBuildRequestAsync(appId: string, job: TJob, metadata: Metadata): Promise<BuildResult>;
|
||||
}
|
||||
|
||||
export async function prepareBuildRequestForPlatformAsync<
|
||||
TPlatform extends Platform,
|
||||
Credentials,
|
||||
ProjectConfiguration
|
||||
>(builder: Builder<TPlatform, Credentials, ProjectConfiguration>): Promise<() => Promise<string>> {
|
||||
TJob extends Job
|
||||
>(builder: Builder<TPlatform, Credentials, TJob>): Promise<() => Promise<string | undefined>> {
|
||||
const credentialsResult = await withAnalyticsAsync(
|
||||
async () => await builder.ensureCredentialsAsync(builder.ctx),
|
||||
{
|
||||
successEvent: AnalyticsEvent.GATHER_CREDENTIALS_SUCCESS,
|
||||
failureEvent: AnalyticsEvent.GATHER_CREDENTIALS_FAIL,
|
||||
successEvent: Event.GATHER_CREDENTIALS_SUCCESS,
|
||||
failureEvent: Event.GATHER_CREDENTIALS_FAIL,
|
||||
trackingCtx: builder.ctx.trackingCtx,
|
||||
}
|
||||
);
|
||||
if (!builder.ctx.commandCtx.skipProjectConfiguration) {
|
||||
await withAnalyticsAsync(async () => await builder.ensureProjectConfiguredAsync(builder.ctx), {
|
||||
successEvent: AnalyticsEvent.CONFIGURE_PROJECT_SUCCESS,
|
||||
failureEvent: AnalyticsEvent.CONFIGURE_PROJECT_FAIL,
|
||||
successEvent: Event.CONFIGURE_PROJECT_SUCCESS,
|
||||
failureEvent: Event.CONFIGURE_PROJECT_FAIL,
|
||||
trackingCtx: builder.ctx.trackingCtx,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await isGitStatusCleanAsync())) {
|
||||
log.addNewLineIfNone();
|
||||
const projectType = builder.ctx.platform === Platform.Android ? 'Android' : 'Xcode';
|
||||
// Currently we are only updaing runtime version durring build, but if it changes in a future
|
||||
// this message should also contain more info on that (or be more generic)
|
||||
await reviewAndCommitChangesAsync(`Update runtime version in the ${projectType} project`, {
|
||||
nonInteractive: builder.ctx.commandCtx.nonInteractive,
|
||||
});
|
||||
if (await vcs.hasUncommittedChangesAsync()) {
|
||||
Log.addNewLineIfNone();
|
||||
await reviewAndCommitChangesAsync(
|
||||
`[EAS Build] Run EAS Build for ${
|
||||
requestedPlatformDisplayNames[builder.ctx.platform as Platform]
|
||||
}`,
|
||||
{
|
||||
nonInteractive: builder.ctx.commandCtx.nonInteractive,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const archiveBucketKey = await uploadProjectAsync(builder.ctx);
|
||||
const projectArchive = builder.ctx.commandCtx.local
|
||||
? ({
|
||||
type: ArchiveSourceType.PATH,
|
||||
path: (await makeProjectTarballAsync()).path,
|
||||
} as const)
|
||||
: ({
|
||||
type: ArchiveSourceType.S3,
|
||||
bucketKey: await uploadProjectAsync(builder.ctx),
|
||||
} as const);
|
||||
|
||||
const metadata = await collectMetadata(builder.ctx, {
|
||||
credentialsSource: credentialsResult?.source,
|
||||
});
|
||||
const job = await builder.prepareJobAsync(builder.ctx, {
|
||||
archiveBucketKey,
|
||||
projectArchive,
|
||||
credentials: credentialsResult?.credentials,
|
||||
projectConfiguration: builder.projectConfiguration,
|
||||
});
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
return await withAnalyticsAsync(
|
||||
async () => {
|
||||
if (log.isDebug) {
|
||||
log(`Starting ${platformDisplayNames[job.platform]} build`);
|
||||
}
|
||||
const {
|
||||
data: { buildId, deprecationInfo },
|
||||
} = await apiClient
|
||||
.post(`projects/${builder.ctx.commandCtx.projectId}/builds`, {
|
||||
json: {
|
||||
job,
|
||||
metadata,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
printDeprecationWarnings(deprecationInfo);
|
||||
return buildId;
|
||||
},
|
||||
{
|
||||
successEvent: AnalyticsEvent.BUILD_REQUEST_SUCCESS,
|
||||
failureEvent: AnalyticsEvent.BUILD_REQUEST_FAIL,
|
||||
trackingCtx: builder.ctx.trackingCtx,
|
||||
if (builder.ctx.commandCtx.local) {
|
||||
await runLocalBuildAsync(job);
|
||||
return undefined;
|
||||
} else {
|
||||
try {
|
||||
return await sendBuildRequestAsync(builder, job, metadata);
|
||||
} catch (error) {
|
||||
if (error?.graphQLErrors?.[0]?.extensions?.errorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
|
||||
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli version.');
|
||||
throw new Error('Build request failed.');
|
||||
} else if (
|
||||
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE'
|
||||
) {
|
||||
Log.error(
|
||||
'EAS Build is down for maintenance, please try again later. Check https://status.expo.dev/ for updates.'
|
||||
);
|
||||
throw new Error('Build request failed.');
|
||||
} else if (
|
||||
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_TOO_MANY_PENDING_BUILDS'
|
||||
) {
|
||||
Log.error(
|
||||
`You have already reached the maximum number of pending ${
|
||||
requestedPlatformDisplayNames[job.platform]
|
||||
} builds for your account. Try again later.`
|
||||
);
|
||||
throw new Error('Build request failed.');
|
||||
} else if (error?.graphQLErrors) {
|
||||
Log.error(
|
||||
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, please report the issue.'
|
||||
);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === 'TURTLE_DEPRECATED_JOB_FORMAT') {
|
||||
log.error('EAS Build API changed, upgrade to latest expo-cli');
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -131,19 +140,22 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
projectTarballPath = projectTarball.path;
|
||||
|
||||
const { bucketKey } = await uploadAsync(
|
||||
UploadType.TURTLE_PROJECT_SOURCES,
|
||||
UploadSessionType.EasBuildProjectSources,
|
||||
projectTarball.path,
|
||||
createProgressTracker({
|
||||
total: projectTarball.size,
|
||||
message: 'Uploading to EAS Build',
|
||||
message: ratio =>
|
||||
`Uploading to EAS Build (${formatBytes(projectTarball.size * ratio)} / ${formatBytes(
|
||||
projectTarball.size
|
||||
)})`,
|
||||
completedMessage: `Uploaded to EAS`,
|
||||
})
|
||||
);
|
||||
return bucketKey;
|
||||
},
|
||||
{
|
||||
successEvent: AnalyticsEvent.PROJECT_UPLOAD_SUCCESS,
|
||||
failureEvent: AnalyticsEvent.PROJECT_UPLOAD_FAIL,
|
||||
successEvent: Event.PROJECT_UPLOAD_SUCCESS,
|
||||
failureEvent: Event.PROJECT_UPLOAD_FAIL,
|
||||
trackingCtx: ctx.trackingCtx,
|
||||
}
|
||||
);
|
||||
@@ -154,11 +166,40 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
}
|
||||
}
|
||||
|
||||
async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJob extends Job>(
|
||||
builder: Builder<TPlatform, Credentials, TJob>,
|
||||
job: TJob,
|
||||
metadata: Metadata
|
||||
): Promise<string> {
|
||||
const { ctx } = builder;
|
||||
return await withAnalyticsAsync(
|
||||
async () => {
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Starting ${requestedPlatformDisplayNames[job.platform]} build`);
|
||||
}
|
||||
|
||||
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
|
||||
ctx.commandCtx.projectId,
|
||||
job,
|
||||
metadata
|
||||
);
|
||||
|
||||
printDeprecationWarnings(deprecationInfo);
|
||||
return build.id;
|
||||
},
|
||||
{
|
||||
successEvent: Event.BUILD_REQUEST_SUCCESS,
|
||||
failureEvent: Event.BUILD_REQUEST_FAIL,
|
||||
trackingCtx: ctx.trackingCtx,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function withAnalyticsAsync<Result>(
|
||||
fn: () => Promise<Result>,
|
||||
analytics: {
|
||||
successEvent: AnalyticsEvent;
|
||||
failureEvent: AnalyticsEvent;
|
||||
successEvent: Event;
|
||||
failureEvent: Event;
|
||||
trackingCtx: TrackingContext;
|
||||
}
|
||||
): Promise<Result> {
|
||||
@@ -182,12 +223,17 @@ enum ShouldCommitChanges {
|
||||
}
|
||||
|
||||
async function reviewAndCommitChangesAsync(
|
||||
commitMessage: string,
|
||||
initialCommitMessage: string,
|
||||
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
|
||||
): Promise<void> {
|
||||
if (process.env.EAS_BUILD_AUTOCOMMIT) {
|
||||
await vcs.commitAsync({ commitMessage: initialCommitMessage, commitAllFiles: false });
|
||||
Log.withTick('Committed changes.');
|
||||
return;
|
||||
}
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode to review and commit changes.'
|
||||
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode or set EAS_BUILD_AUTOCOMMIT=1 in your environment.'
|
||||
);
|
||||
}
|
||||
const { selected } = await promptAsync({
|
||||
@@ -211,10 +257,13 @@ async function reviewAndCommitChangesAsync(
|
||||
"Aborting, run the command again once you're ready. Make sure to commit any changes you've made."
|
||||
);
|
||||
} else if (selected === ShouldCommitChanges.Yes) {
|
||||
await commitPromptAsync(commitMessage);
|
||||
log.withTick('Committed changes.');
|
||||
await commitPromptAsync({ initialCommitMessage });
|
||||
Log.withTick('Committed changes.');
|
||||
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
|
||||
await showDiffAsync();
|
||||
await reviewAndCommitChangesAsync(commitMessage, { nonInteractive, askedFirstTime: false });
|
||||
await vcs.showDiffAsync();
|
||||
await reviewAndCommitChangesAsync(initialCommitMessage, {
|
||||
nonInteractive,
|
||||
askedFirstTime: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,32 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { EasJsonReader } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import log from '../log';
|
||||
import Log from '../log';
|
||||
import { resolveWorkflowAsync } from '../project/workflow';
|
||||
import { promptAsync } from '../prompts';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import { gitAddAsync } from '../utils/git';
|
||||
import vcs from '../vcs';
|
||||
import { configureAndroidAsync } from './android/configure';
|
||||
import { ConfigureContext } from './context';
|
||||
import { configureIosAsync } from './ios/configure';
|
||||
import { RequestedPlatform } from './types';
|
||||
import {
|
||||
commitPromptAsync,
|
||||
ensureGitRepoExistsAsync,
|
||||
isGitStatusCleanAsync,
|
||||
maybeBailOnGitStatusAsync,
|
||||
showDiffAsync,
|
||||
} from './utils/repository';
|
||||
import { commitPromptAsync, maybeBailOnRepoStatusAsync } from './utils/repository';
|
||||
|
||||
const configureCommitMessage = {
|
||||
[RequestedPlatform.Android]: 'Configure EAS Build for Android',
|
||||
[RequestedPlatform.iOS]: 'Configure EAS Build for iOS',
|
||||
[RequestedPlatform.Ios]: 'Configure EAS Build for iOS',
|
||||
[RequestedPlatform.All]: 'Configure EAS Build',
|
||||
};
|
||||
|
||||
export async function configureAsync(options: {
|
||||
platform: RequestedPlatform;
|
||||
projectDir: string;
|
||||
allowExperimental: boolean;
|
||||
}): Promise<void> {
|
||||
await ensureGitRepoExistsAsync();
|
||||
await maybeBailOnGitStatusAsync();
|
||||
await vcs.ensureRepoExistsAsync();
|
||||
await maybeBailOnRepoStatusAsync();
|
||||
|
||||
const { exp } = getConfig(options.projectDir, { skipSDKVersionRequirement: true });
|
||||
|
||||
@@ -39,17 +34,18 @@ export async function configureAsync(options: {
|
||||
user: await ensureLoggedInAsync(),
|
||||
projectDir: options.projectDir,
|
||||
exp,
|
||||
allowExperimental: options.allowExperimental,
|
||||
requestedPlatform: options.platform,
|
||||
shouldConfigureAndroid: [RequestedPlatform.All, RequestedPlatform.Android].includes(
|
||||
options.platform
|
||||
),
|
||||
shouldConfigureIos: [RequestedPlatform.All, RequestedPlatform.iOS].includes(options.platform),
|
||||
hasAndroidNativeProject: await fs.pathExists(path.join(options.projectDir, 'android')),
|
||||
hasIosNativeProject: await fs.pathExists(path.join(options.projectDir, 'ios')),
|
||||
shouldConfigureIos: [RequestedPlatform.All, RequestedPlatform.Ios].includes(options.platform),
|
||||
hasAndroidNativeProject:
|
||||
(await resolveWorkflowAsync(options.projectDir, Platform.ANDROID)) === Workflow.GENERIC,
|
||||
hasIosNativeProject:
|
||||
(await resolveWorkflowAsync(options.projectDir, Platform.IOS)) === Workflow.GENERIC,
|
||||
};
|
||||
|
||||
log.newLine();
|
||||
Log.newLine();
|
||||
await ensureEasJsonExistsAsync(ctx);
|
||||
if (ctx.shouldConfigureAndroid) {
|
||||
await configureAndroidAsync(ctx);
|
||||
@@ -58,15 +54,54 @@ export async function configureAsync(options: {
|
||||
await configureIosAsync(ctx);
|
||||
}
|
||||
|
||||
if (!(await isGitStatusCleanAsync())) {
|
||||
log.newLine();
|
||||
if (await vcs.hasUncommittedChangesAsync()) {
|
||||
Log.newLine();
|
||||
await reviewAndCommitChangesAsync(configureCommitMessage[options.platform]);
|
||||
} else {
|
||||
log.newLine();
|
||||
log.withTick('No changes were necessary, the project is already configured correctly.');
|
||||
Log.newLine();
|
||||
Log.withTick('No changes were necessary, the project is already configured correctly.');
|
||||
}
|
||||
}
|
||||
|
||||
const ANDROID_MANAGED_DEFAULTS = {
|
||||
release: {
|
||||
buildType: 'app-bundle',
|
||||
},
|
||||
development: {
|
||||
buildType: 'development-client',
|
||||
distribution: 'internal',
|
||||
},
|
||||
};
|
||||
const ANDROID_GENERIC_DEFAULTS = {
|
||||
release: {
|
||||
gradleCommand: ':app:bundleRelease',
|
||||
},
|
||||
development: {
|
||||
gradleCommand: ':app:assembleDebug',
|
||||
distribution: 'internal',
|
||||
},
|
||||
};
|
||||
|
||||
const IOS_MANAGED_DEFAULTS = {
|
||||
release: {
|
||||
buildType: 'release',
|
||||
},
|
||||
development: {
|
||||
buildType: 'development-client',
|
||||
distribution: 'internal',
|
||||
},
|
||||
};
|
||||
|
||||
const IOS_GENERIC_DEFAULTS = {
|
||||
release: {
|
||||
schemeBuildConfiguration: 'Release',
|
||||
},
|
||||
development: {
|
||||
schemeBuildConfiguration: 'Debug',
|
||||
distribution: 'internal',
|
||||
},
|
||||
};
|
||||
|
||||
export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<void> {
|
||||
const easJsonPath = path.join(ctx.projectDir, 'eas.json');
|
||||
let existingEasJson;
|
||||
@@ -76,7 +111,7 @@ export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<v
|
||||
await reader.validateAsync();
|
||||
|
||||
existingEasJson = await reader.readRawAsync();
|
||||
log.withTick('Validated eas.json');
|
||||
Log.withTick('Validated eas.json');
|
||||
|
||||
// If we have already populated eas.json with the default fields for the
|
||||
// platform then proceed
|
||||
@@ -98,31 +133,25 @@ export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<v
|
||||
|
||||
const easJson = {
|
||||
builds: {
|
||||
...existingEasJson,
|
||||
...existingEasJson?.builds,
|
||||
...(shouldInitAndroid
|
||||
? {
|
||||
android: {
|
||||
release: {
|
||||
workflow: ctx.hasAndroidNativeProject ? 'generic' : 'managed',
|
||||
},
|
||||
},
|
||||
android: ctx.hasAndroidNativeProject
|
||||
? ANDROID_GENERIC_DEFAULTS
|
||||
: ANDROID_MANAGED_DEFAULTS,
|
||||
}
|
||||
: null),
|
||||
...(shouldInitIOS
|
||||
? {
|
||||
ios: {
|
||||
release: {
|
||||
workflow: ctx.hasIosNativeProject ? 'generic' : 'managed',
|
||||
},
|
||||
},
|
||||
ios: ctx.hasIosNativeProject ? IOS_GENERIC_DEFAULTS : IOS_MANAGED_DEFAULTS,
|
||||
}
|
||||
: null),
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
|
||||
await gitAddAsync(easJsonPath, { intentToAdd: true });
|
||||
log.withTick(`${existingEasJson ? 'Updated' : 'Generated'} eas.json`);
|
||||
await vcs.trackFileAsync(easJsonPath);
|
||||
Log.withTick(`${existingEasJson ? 'Updated' : 'Generated'} eas.json`);
|
||||
}
|
||||
|
||||
enum ShouldCommitChanges {
|
||||
@@ -132,7 +161,7 @@ enum ShouldCommitChanges {
|
||||
}
|
||||
|
||||
async function reviewAndCommitChangesAsync(
|
||||
commitMessage: string,
|
||||
initialCommitMessage: string,
|
||||
askedFirstTime: boolean = true
|
||||
): Promise<void> {
|
||||
const { selected } = await promptAsync({
|
||||
@@ -152,10 +181,10 @@ async function reviewAndCommitChangesAsync(
|
||||
});
|
||||
|
||||
if (selected === ShouldCommitChanges.Yes) {
|
||||
await commitPromptAsync(commitMessage);
|
||||
log.withTick('Committed changes');
|
||||
await commitPromptAsync({ initialCommitMessage });
|
||||
Log.withTick('Committed changes');
|
||||
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
|
||||
await showDiffAsync();
|
||||
await reviewAndCommitChangesAsync(commitMessage, false);
|
||||
await vcs.showDiffAsync();
|
||||
await reviewAndCommitChangesAsync(initialCommitMessage, false);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Platform, RequestedPlatform } from './types';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { RequestedPlatform } from './types';
|
||||
|
||||
export const platformDisplayNames = {
|
||||
[RequestedPlatform.iOS]: 'iOS',
|
||||
export const requestedPlatformDisplayNames: Record<RequestedPlatform, string> = {
|
||||
[RequestedPlatform.Ios]: 'iOS',
|
||||
[RequestedPlatform.Android]: 'Android',
|
||||
[RequestedPlatform.All]: 'Android and iOS',
|
||||
};
|
||||
|
||||
export const platformEmojis = {
|
||||
[Platform.iOS]: '🍎',
|
||||
[Platform.Android]: '🤖',
|
||||
export const appPlatformDisplayNames: Record<AppPlatform, string> = {
|
||||
[AppPlatform.Android]: 'Android',
|
||||
[AppPlatform.Ios]: 'iOS',
|
||||
};
|
||||
|
||||
export const appPlatformEmojis = {
|
||||
[AppPlatform.Ios]: '🍎',
|
||||
[AppPlatform.Android]: '🤖',
|
||||
};
|
||||
@@ -1,10 +1,17 @@
|
||||
import { ExpoConfig, getConfig } from '@expo/config';
|
||||
import { AndroidBuildProfile, EasConfig, iOSBuildProfile } from '@expo/eas-json';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { AndroidBuildProfile, EasConfig, IosBuildProfile } from '@expo/eas-json';
|
||||
import JsonFile from '@expo/json-file';
|
||||
import resolveFrom from 'resolve-from';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getProjectAccountName } from '../project/projectUtils';
|
||||
import { resolveWorkflowAsync } from '../project/workflow';
|
||||
import { findAccountByName } from '../user/Account';
|
||||
import { Actor } from '../user/User';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import { Platform, RequestedPlatform, TrackingContext } from './types';
|
||||
import Analytics, { Event } from './utils/analytics';
|
||||
|
||||
export interface CommandContext {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
@@ -15,9 +22,9 @@ export interface CommandContext {
|
||||
accountName: string;
|
||||
projectName: string;
|
||||
exp: ExpoConfig;
|
||||
trackingCtx: TrackingContext;
|
||||
nonInteractive: boolean;
|
||||
skipCredentialsCheck: boolean;
|
||||
local: boolean;
|
||||
clearCache: boolean;
|
||||
skipProjectConfiguration: boolean;
|
||||
waitForBuildEnd: boolean;
|
||||
}
|
||||
@@ -25,26 +32,27 @@ export interface CommandContext {
|
||||
export async function createCommandContextAsync({
|
||||
requestedPlatform,
|
||||
profile,
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
trackingCtx,
|
||||
nonInteractive = false,
|
||||
skipCredentialsCheck = false,
|
||||
local,
|
||||
clearCache = false,
|
||||
skipProjectConfiguration = false,
|
||||
waitForBuildEnd,
|
||||
}: {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
profile: string;
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
projectDir: string;
|
||||
trackingCtx: TrackingContext;
|
||||
nonInteractive: boolean;
|
||||
skipCredentialsCheck: boolean;
|
||||
local: boolean;
|
||||
clearCache: boolean;
|
||||
skipProjectConfiguration: boolean;
|
||||
waitForBuildEnd: boolean;
|
||||
}): Promise<CommandContext> {
|
||||
const user = await ensureLoggedInAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = getProjectAccountName(exp, user);
|
||||
const projectName = exp.slug;
|
||||
|
||||
@@ -57,9 +65,9 @@ export async function createCommandContextAsync({
|
||||
accountName,
|
||||
projectName,
|
||||
exp,
|
||||
trackingCtx,
|
||||
nonInteractive,
|
||||
skipCredentialsCheck,
|
||||
local,
|
||||
clearCache,
|
||||
skipProjectConfiguration,
|
||||
waitForBuildEnd,
|
||||
};
|
||||
@@ -69,7 +77,6 @@ export interface ConfigureContext {
|
||||
user: Actor;
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
allowExperimental: boolean;
|
||||
requestedPlatform: RequestedPlatform;
|
||||
shouldConfigureAndroid: boolean;
|
||||
shouldConfigureIos: boolean;
|
||||
@@ -77,18 +84,19 @@ export interface ConfigureContext {
|
||||
hasIosNativeProject: boolean;
|
||||
}
|
||||
|
||||
type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
|
||||
type PlatformBuildProfile<T extends Platform> = T extends Platform.ANDROID
|
||||
? AndroidBuildProfile
|
||||
: iOSBuildProfile;
|
||||
: IosBuildProfile;
|
||||
|
||||
export interface BuildContext<T extends Platform> {
|
||||
commandCtx: CommandContext;
|
||||
trackingCtx: TrackingContext;
|
||||
platform: T;
|
||||
buildProfile: PlatformBuildProfile<T>;
|
||||
workflow: Workflow;
|
||||
}
|
||||
|
||||
export function createBuildContext<T extends Platform>({
|
||||
export async function createBuildContextAsync<T extends Platform>({
|
||||
platform,
|
||||
easConfig,
|
||||
commandCtx,
|
||||
@@ -96,19 +104,74 @@ export function createBuildContext<T extends Platform>({
|
||||
platform: T;
|
||||
easConfig: EasConfig;
|
||||
commandCtx: CommandContext;
|
||||
}): BuildContext<T> {
|
||||
}): Promise<BuildContext<T>> {
|
||||
const buildProfile = easConfig.builds[platform] as PlatformBuildProfile<T> | undefined;
|
||||
if (!buildProfile) {
|
||||
throw new Error(`${platform} build profile does not exist`);
|
||||
}
|
||||
const builderTrackingCtx = {
|
||||
...commandCtx.trackingCtx,
|
||||
|
||||
const workflow = await resolveWorkflowAsync(commandCtx.projectDir, platform);
|
||||
const accountId = findAccountByName(commandCtx.user.accounts, commandCtx.accountName)?.id;
|
||||
const devClientProperties = getDevClientEventProperties({
|
||||
platform,
|
||||
projectDir: commandCtx.projectDir,
|
||||
buildProfile,
|
||||
workflow,
|
||||
});
|
||||
const trackingCtx = {
|
||||
tracking_id: uuidv4(),
|
||||
platform,
|
||||
...(accountId && { account_id: accountId }),
|
||||
account_name: commandCtx.accountName,
|
||||
project_id: commandCtx.projectId,
|
||||
project_type: workflow,
|
||||
...devClientProperties,
|
||||
};
|
||||
Analytics.logEvent(Event.BUILD_COMMAND, trackingCtx);
|
||||
return {
|
||||
commandCtx,
|
||||
trackingCtx: builderTrackingCtx,
|
||||
workflow,
|
||||
trackingCtx,
|
||||
platform,
|
||||
buildProfile,
|
||||
};
|
||||
}
|
||||
|
||||
function getDevClientEventProperties({
|
||||
platform,
|
||||
projectDir,
|
||||
buildProfile,
|
||||
workflow,
|
||||
}: {
|
||||
platform: Platform;
|
||||
projectDir: string;
|
||||
buildProfile: AndroidBuildProfile | IosBuildProfile;
|
||||
workflow: Workflow;
|
||||
}): Partial<TrackingContext> {
|
||||
let includesDevClient;
|
||||
const version = tryGetDevClientVersion(projectDir);
|
||||
if (platform === Platform.ANDROID && 'gradleCommand' in buildProfile) {
|
||||
includesDevClient = Boolean(version && buildProfile.gradleCommand?.includes('Debug'));
|
||||
} else if (platform === Platform.IOS && 'schemeBuildConfiguration' in buildProfile) {
|
||||
includesDevClient = Boolean(version && buildProfile.schemeBuildConfiguration === 'Debug');
|
||||
} else if ('buildType' in buildProfile) {
|
||||
includesDevClient = buildProfile.buildType === 'development-client';
|
||||
} else {
|
||||
includesDevClient = false;
|
||||
}
|
||||
|
||||
if (version) {
|
||||
return { dev_client: includesDevClient, dev_client_version: version };
|
||||
} else {
|
||||
return { dev_client: includesDevClient };
|
||||
}
|
||||
}
|
||||
|
||||
function tryGetDevClientVersion(projectDir: string): string | null {
|
||||
try {
|
||||
const pkg = JsonFile.read(resolveFrom(projectDir, 'expo-dev-client/package.json'));
|
||||
return pkg.version?.toString() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,36 @@
|
||||
import { EasJsonReader } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../api';
|
||||
import log from '../log';
|
||||
import { BuildFragment, BuildStatus } from '../graphql/generated';
|
||||
import { BuildQuery } from '../graphql/queries/BuildQuery';
|
||||
import Log from '../log';
|
||||
import { sleep } from '../utils/promise';
|
||||
import vcs from '../vcs';
|
||||
import { prepareAndroidBuildAsync } from './android/build';
|
||||
import { CommandContext } from './context';
|
||||
import { prepareIosBuildAsync } from './ios/build';
|
||||
import { Build, BuildStatus, Platform, RequestedPlatform } from './types';
|
||||
import { Platform, RequestedPlatform } from './types';
|
||||
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
|
||||
import { ensureGitRepoExistsAsync, ensureGitStatusIsCleanAsync } from './utils/repository';
|
||||
import { ensureRepoIsCleanAsync } from './utils/repository';
|
||||
|
||||
export async function buildAsync(commandCtx: CommandContext): Promise<void> {
|
||||
await ensureGitRepoExistsAsync();
|
||||
await ensureGitStatusIsCleanAsync(commandCtx.nonInteractive);
|
||||
await vcs.ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(commandCtx.nonInteractive);
|
||||
|
||||
const scheduledBuilds = await startBuildsAsync(commandCtx);
|
||||
log.newLine();
|
||||
if (commandCtx.local) {
|
||||
return;
|
||||
}
|
||||
Log.newLine();
|
||||
printLogsUrls(commandCtx.accountName, scheduledBuilds);
|
||||
log.newLine();
|
||||
Log.newLine();
|
||||
|
||||
if (commandCtx.waitForBuildEnd) {
|
||||
const builds = await waitForBuildEndAsync(
|
||||
commandCtx,
|
||||
scheduledBuilds.map(i => i.buildId)
|
||||
);
|
||||
log.newLine();
|
||||
const builds = await waitForBuildEndAsync(scheduledBuilds.map(i => i.buildId));
|
||||
printBuildResults(commandCtx.accountName, builds);
|
||||
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +40,7 @@ async function startBuildsAsync(
|
||||
const shouldBuildAndroid = [RequestedPlatform.Android, RequestedPlatform.All].includes(
|
||||
commandCtx.requestedPlatform
|
||||
);
|
||||
const shouldBuildiOS = [RequestedPlatform.iOS, RequestedPlatform.All].includes(
|
||||
const shouldBuildIos = [RequestedPlatform.Ios, RequestedPlatform.All].includes(
|
||||
commandCtx.requestedPlatform
|
||||
);
|
||||
const easConfig = await new EasJsonReader(
|
||||
@@ -47,84 +50,102 @@ async function startBuildsAsync(
|
||||
|
||||
const builds: {
|
||||
platform: Platform;
|
||||
sendBuildRequestAsync: () => Promise<string>;
|
||||
sendBuildRequestAsync: () => Promise<string | undefined>;
|
||||
}[] = [];
|
||||
if (shouldBuildAndroid) {
|
||||
const sendBuildRequestAsync = await prepareAndroidBuildAsync(commandCtx, easConfig);
|
||||
builds.push({ platform: Platform.Android, sendBuildRequestAsync });
|
||||
builds.push({ platform: Platform.ANDROID, sendBuildRequestAsync });
|
||||
}
|
||||
if (shouldBuildiOS) {
|
||||
if (shouldBuildIos) {
|
||||
const sendBuildRequestAsync = await prepareIosBuildAsync(commandCtx, easConfig);
|
||||
builds.push({ platform: Platform.iOS, sendBuildRequestAsync });
|
||||
builds.push({ platform: Platform.IOS, sendBuildRequestAsync });
|
||||
}
|
||||
return Promise.all(
|
||||
builds.map(async ({ platform, sendBuildRequestAsync }) => ({
|
||||
platform,
|
||||
buildId: await sendBuildRequestAsync(),
|
||||
}))
|
||||
);
|
||||
return (
|
||||
await Promise.all(
|
||||
builds.map(async ({ platform, sendBuildRequestAsync }) => ({
|
||||
platform,
|
||||
buildId: await sendBuildRequestAsync(),
|
||||
}))
|
||||
)
|
||||
).filter((build): build is { platform: Platform; buildId: string } => !!build.buildId);
|
||||
}
|
||||
|
||||
async function waitForBuildEndAsync(
|
||||
commandCtx: CommandContext,
|
||||
buildIds: string[],
|
||||
{ timeoutSec = 1800, intervalSec = 30 } = {}
|
||||
): Promise<(Build | null)[]> {
|
||||
log('Waiting for build to complete. You can press Ctrl+C to exit.');
|
||||
{ timeoutSec = 3600, intervalSec = 30 } = {}
|
||||
): Promise<(BuildFragment | null)[]> {
|
||||
Log.log('Waiting for build to complete. You can press Ctrl+C to exit.');
|
||||
const spinner = ora().start();
|
||||
let time = new Date().getTime();
|
||||
const endTime = time + timeoutSec * 1000;
|
||||
while (time <= endTime) {
|
||||
const builds: (Build | null)[] = await Promise.all(
|
||||
const builds: (BuildFragment | null)[] = await Promise.all(
|
||||
buildIds.map(async buildId => {
|
||||
try {
|
||||
const { data } = await apiClient
|
||||
.get(`projects/${commandCtx.projectId}/builds/${buildId}`)
|
||||
.json();
|
||||
return data;
|
||||
return await BuildQuery.byIdAsync(buildId, { useCache: false });
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
if (builds.length === 1) {
|
||||
switch (builds[0]?.status) {
|
||||
case BuildStatus.FINISHED:
|
||||
const build = nullthrows(builds[0]);
|
||||
switch (build.status) {
|
||||
case BuildStatus.Finished:
|
||||
spinner.succeed('Build finished');
|
||||
return builds;
|
||||
case BuildStatus.IN_QUEUE:
|
||||
case BuildStatus.New:
|
||||
spinner.text = 'Build created';
|
||||
break;
|
||||
case BuildStatus.InQueue:
|
||||
spinner.text = 'Build queued...';
|
||||
break;
|
||||
case BuildStatus.IN_PROGRESS:
|
||||
case BuildStatus.Canceled:
|
||||
spinner.text = 'Build canceled';
|
||||
spinner.stopAndPersist();
|
||||
return builds;
|
||||
case BuildStatus.InProgress:
|
||||
spinner.text = 'Build in progress...';
|
||||
break;
|
||||
case BuildStatus.ERRORED:
|
||||
case BuildStatus.Errored:
|
||||
spinner.fail('Build failed');
|
||||
throw new Error(`Standalone build failed!`);
|
||||
if (build.error) {
|
||||
return builds;
|
||||
} else {
|
||||
throw new Error(`Standalone build failed!`);
|
||||
}
|
||||
default:
|
||||
spinner.warn('Unknown status.');
|
||||
throw new Error(`Unknown status: ${builds} - aborting!`);
|
||||
}
|
||||
} else {
|
||||
if (builds.filter(build => build?.status === BuildStatus.FINISHED).length === builds.length) {
|
||||
if (builds.filter(build => build?.status === BuildStatus.Finished).length === builds.length) {
|
||||
spinner.succeed('All builds have finished');
|
||||
return builds;
|
||||
} else if (
|
||||
builds.filter(build =>
|
||||
build?.status ? [BuildStatus.FINISHED, BuildStatus.ERRORED].includes(build.status) : false
|
||||
build?.status
|
||||
? [BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(
|
||||
build.status
|
||||
)
|
||||
: false
|
||||
).length === builds.length
|
||||
) {
|
||||
spinner.fail('Some of the builds failed');
|
||||
spinner.fail('Some of the builds were canceled or failed.');
|
||||
return builds;
|
||||
} else {
|
||||
const inQueue = builds.filter(build => build?.status === BuildStatus.IN_QUEUE).length;
|
||||
const inProgress = builds.filter(build => build?.status === BuildStatus.IN_PROGRESS).length;
|
||||
const errored = builds.filter(build => build?.status === BuildStatus.ERRORED).length;
|
||||
const finished = builds.filter(build => build?.status === BuildStatus.FINISHED).length;
|
||||
const newBuilds = builds.filter(build => build?.status === BuildStatus.New).length;
|
||||
const inQueue = builds.filter(build => build?.status === BuildStatus.InQueue).length;
|
||||
const inProgress = builds.filter(build => build?.status === BuildStatus.InProgress).length;
|
||||
const errored = builds.filter(build => build?.status === BuildStatus.Errored).length;
|
||||
const finished = builds.filter(build => build?.status === BuildStatus.Finished).length;
|
||||
const canceled = builds.filter(build => build?.status === BuildStatus.Canceled).length;
|
||||
const unknownState = builds.length - inQueue - inProgress - errored - finished;
|
||||
spinner.text = [
|
||||
newBuilds && `Builds created: ${newBuilds}`,
|
||||
inQueue && `Builds in queue: ${inQueue}`,
|
||||
inProgress && `Builds in progress: ${inProgress}`,
|
||||
canceled && `Builds canceled: ${canceled}`,
|
||||
errored && chalk.red(`Builds failed: ${errored}`),
|
||||
finished && chalk.green(`Builds finished: ${finished}`),
|
||||
unknownState && chalk.red(`Builds in unknown state: ${unknownState}`),
|
||||
@@ -141,3 +162,12 @@ async function waitForBuildEndAsync(
|
||||
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
|
||||
);
|
||||
}
|
||||
|
||||
function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | null)[]): void {
|
||||
const failedBuilds = (maybeBuilds.filter(i => i) as BuildFragment[]).filter(
|
||||
i => i.status === BuildStatus.Errored
|
||||
);
|
||||
if (failedBuilds.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { CredentialsProvider } from '../credentials/CredentialsProvider';
|
||||
import log from '../log';
|
||||
import { confirmAsync, promptAsync } from '../prompts';
|
||||
import { platformDisplayNames } from './constants';
|
||||
|
||||
function logCredentials(target: 'local' | 'remote', platform: Platform) {
|
||||
let message = `Using ${target} ${platformDisplayNames[platform]} credentials`;
|
||||
if (target === 'local') message += ` ${chalk.dim('(credentials.json)')}`;
|
||||
if (target === 'remote') message += ` ${chalk.dim('(Expo server)')}`;
|
||||
log.succeed(message);
|
||||
}
|
||||
|
||||
async function ensureCredentialsAutoAsync(
|
||||
provider: CredentialsProvider,
|
||||
workflow: Workflow,
|
||||
nonInteractive: boolean
|
||||
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
|
||||
const platform = platformDisplayNames[provider.platform];
|
||||
switch (workflow) {
|
||||
case Workflow.Managed:
|
||||
if (await provider.hasLocalAsync()) {
|
||||
return CredentialsSource.LOCAL;
|
||||
} else {
|
||||
return CredentialsSource.REMOTE;
|
||||
}
|
||||
case Workflow.Generic: {
|
||||
const hasLocal = await provider.hasLocalAsync();
|
||||
const hasRemote = await provider.hasRemoteAsync();
|
||||
if (hasRemote && hasLocal) {
|
||||
if (!(await provider.isLocalSyncedAsync())) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Contents of your local credentials.json for ${platform} are not the same as credentials on Expo servers. To use the desired credentials, set the "builds.${platform}.{profile}.credentialsSource" field in the credentials.json file to one of the following: "local", "remote".`
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
`Contents of your local credentials.json for ${platform} are not the same as credentials on Expo servers`
|
||||
);
|
||||
}
|
||||
|
||||
const { select } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'select',
|
||||
message: 'Which credentials do you want to use for this build?',
|
||||
choices: [
|
||||
{ title: 'Local credentials.json', value: CredentialsSource.LOCAL },
|
||||
{ title: 'Credentials stored on Expo servers.', value: CredentialsSource.REMOTE },
|
||||
],
|
||||
});
|
||||
return select;
|
||||
} else {
|
||||
return CredentialsSource.LOCAL;
|
||||
}
|
||||
} else if (hasLocal) {
|
||||
logCredentials('local', provider.platform);
|
||||
return CredentialsSource.LOCAL;
|
||||
} else if (hasRemote) {
|
||||
logCredentials('remote', provider.platform);
|
||||
return CredentialsSource.REMOTE;
|
||||
} else {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}. Either configure credentials.json, or launch the build without "--non-interactive" flag to get a prompt to generate credentials automatically.`
|
||||
);
|
||||
} else if (log.isDebug) {
|
||||
log.warn(
|
||||
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}`
|
||||
);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `${platform} credentials not found, generate now?`,
|
||||
});
|
||||
if (confirm) {
|
||||
return CredentialsSource.REMOTE;
|
||||
} else {
|
||||
throw new Error(`Aborting build process, credentials are not configured for ${platform}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCredentialsAsync(
|
||||
provider: CredentialsProvider,
|
||||
workflow: Workflow,
|
||||
src: CredentialsSource,
|
||||
nonInteractive: boolean
|
||||
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
|
||||
switch (src) {
|
||||
case CredentialsSource.LOCAL:
|
||||
logCredentials('local', provider.platform);
|
||||
return CredentialsSource.LOCAL;
|
||||
case CredentialsSource.REMOTE:
|
||||
logCredentials('remote', provider.platform);
|
||||
return CredentialsSource.REMOTE;
|
||||
case CredentialsSource.AUTO:
|
||||
return await ensureCredentialsAutoAsync(provider, workflow, nonInteractive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ArchiveSource, ArchiveSourceType, Metadata, Workflow } from '@expo/eas-build-job';
|
||||
|
||||
import {
|
||||
BuildCredentialsSource,
|
||||
BuildMetadataInput,
|
||||
BuildWorkflow,
|
||||
DistributionType,
|
||||
ProjectArchiveSourceInput,
|
||||
ProjectArchiveSourceType,
|
||||
} from '../graphql/generated';
|
||||
|
||||
export function transformProjectArchive(archiveSource: ArchiveSource): ProjectArchiveSourceInput {
|
||||
if (archiveSource.type === ArchiveSourceType.S3) {
|
||||
return {
|
||||
type: ProjectArchiveSourceType.S3,
|
||||
bucketKey: archiveSource.bucketKey,
|
||||
};
|
||||
} else if (archiveSource.type === ArchiveSourceType.URL) {
|
||||
return {
|
||||
type: ProjectArchiveSourceType.Url,
|
||||
url: archiveSource.url,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported project archive source type: '${archiveSource.type}'`);
|
||||
}
|
||||
}
|
||||
|
||||
export function transformMetadata(metadata: Metadata): BuildMetadataInput {
|
||||
return {
|
||||
...metadata,
|
||||
credentialsSource:
|
||||
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
|
||||
distribution: metadata.distribution && transformDistribution(metadata.distribution),
|
||||
workflow: metadata.workflow && transformWorkflow(metadata.workflow),
|
||||
};
|
||||
}
|
||||
|
||||
function transformCredentialsSource(
|
||||
credentialsSource: Metadata['credentialsSource']
|
||||
): BuildCredentialsSource {
|
||||
if (credentialsSource === 'local') {
|
||||
return BuildCredentialsSource.Local;
|
||||
} else {
|
||||
return BuildCredentialsSource.Remote;
|
||||
}
|
||||
}
|
||||
|
||||
function transformDistribution(distribution: Metadata['distribution']): DistributionType {
|
||||
if (distribution === 'internal') {
|
||||
return DistributionType.Internal;
|
||||
} else if (distribution === 'simulator') {
|
||||
return DistributionType.Simulator;
|
||||
} else {
|
||||
return DistributionType.Store;
|
||||
}
|
||||
}
|
||||
|
||||
export function transformWorkflow(workflow: Workflow): BuildWorkflow {
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
return BuildWorkflow.Generic;
|
||||
} else {
|
||||
return BuildWorkflow.Managed;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,22 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import plist from '@expo/plist';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import log from '../../log';
|
||||
import Log from '../../log';
|
||||
import { getProjectAccountName } from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { gitAddAsync } from '../../utils/git';
|
||||
import vcs from '../../vcs';
|
||||
import { ensureValidVersions } from '../utils/updates';
|
||||
import { readPlistAsync, writePlistAsync } from './plist';
|
||||
|
||||
export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
|
||||
ensureValidVersions(exp);
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
let xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
|
||||
|
||||
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, exp, xcodeProject)) {
|
||||
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, xcodeProject)) {
|
||||
xcodeProject = IOSConfig.Updates.ensureBundleReactNativePhaseContainsConfigurationScript(
|
||||
projectDir,
|
||||
exp,
|
||||
xcodeProject
|
||||
);
|
||||
await fs.writeFile(IOSConfig.Paths.getPBXProjectPath(projectDir), xcodeProject.writeSync());
|
||||
@@ -41,7 +39,7 @@ export async function syncUpdatesConfigurationAsync(
|
||||
try {
|
||||
await ensureUpdatesConfiguredAsync(projectDir, exp);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
Log.error(
|
||||
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
|
||||
);
|
||||
throw error;
|
||||
@@ -54,8 +52,8 @@ export async function syncUpdatesConfigurationAsync(
|
||||
}
|
||||
|
||||
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
|
||||
log.warn(
|
||||
'Native project configuration is not synced with values present in you app.json, run "eas build:configure" to make sure all values are applied in the native project'
|
||||
Log.warn(
|
||||
'Native project configuration is not synced with values present in your app.json, run "eas build:configure" to make sure all values are applied in the native project'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,7 +61,7 @@ export async function syncUpdatesConfigurationAsync(
|
||||
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
|
||||
const xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
|
||||
|
||||
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, exp, xcodeProject)) {
|
||||
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, xcodeProject)) {
|
||||
const script = 'expo-updates/scripts/create-manifest-ios.sh';
|
||||
const buildPhase = '"Bundle React Native code and images"';
|
||||
throw new Error(`Path to ${script} is missing in a ${buildPhase} build phase.`);
|
||||
@@ -77,13 +75,7 @@ async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig)
|
||||
|
||||
async function readExpoPlistAsync(projectDir: string): Promise<IOSConfig.ExpoPlist> {
|
||||
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
|
||||
|
||||
let expoPlist = {};
|
||||
if (await fs.pathExists(expoPlistPath)) {
|
||||
const expoPlistContent = await fs.readFile(expoPlistPath, 'utf8');
|
||||
expoPlist = plist.parse(expoPlistContent);
|
||||
}
|
||||
return expoPlist;
|
||||
return (await readPlistAsync(expoPlistPath)) as IOSConfig.ExpoPlist;
|
||||
}
|
||||
|
||||
async function writeExpoPlistAsync(
|
||||
@@ -91,9 +83,25 @@ async function writeExpoPlistAsync(
|
||||
expoPlist: IOSConfig.ExpoPlist
|
||||
): Promise<void> {
|
||||
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
|
||||
const expoPlistContent = plist.build(expoPlist);
|
||||
await writePlistAsync(expoPlistPath, expoPlist);
|
||||
await vcs.trackFileAsync(expoPlistPath);
|
||||
}
|
||||
|
||||
await fs.mkdirp(path.dirname(expoPlistPath));
|
||||
await fs.writeFile(expoPlistPath, expoPlistContent);
|
||||
await gitAddAsync(expoPlistPath, { intentToAdd: true });
|
||||
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
|
||||
try {
|
||||
const expoPlist = await readExpoPlistAsync(projectDir);
|
||||
return expoPlist[IOSConfig.Updates.Config.RELEASE_CHANNEL] ?? null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readChannelSafelyAsync(projectDir: string): Promise<string | null> {
|
||||
try {
|
||||
const expoPlist: any = await readExpoPlistAsync(projectDir); // TODO-JJ remove any once IOSConfig.ExpoPlist is updated to include `EXUpdatesRequestHeaders : Record<string,string>`
|
||||
const updatesRequestHeaders = expoPlist['EXUpdatesRequestHeaders'] ?? {}; //TODO-JJ 'EXUpdatesRequestHeaders' IosConfig.Updates.Config.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY once https://github.com/expo/expo-cli/pull/3571 is published
|
||||
return updatesRequestHeaders['expo-channel-name'] ?? null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
|
||||
import { readChannelSafelyAsync } from '../UpdatesModule';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
afterAll(() => {
|
||||
// do not remove the following line
|
||||
// this fixes a weird error with tempy in @expo/image-utils
|
||||
fs.removeSync(os.tmpdir());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
// do not remove the following line
|
||||
// this fixes a weird error with tempy in @expo/image-utils
|
||||
fs.mkdirpSync(os.tmpdir());
|
||||
});
|
||||
|
||||
const testChannel = 'test-channel';
|
||||
describe(readChannelSafelyAsync, () => {
|
||||
it('retrieves the channel when it is defined natively', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/expo-project/Supporting/Expo.plist': ExpoPlistWithChannel,
|
||||
'ios/expo-project/AppDelegate.m': '',
|
||||
},
|
||||
'/expo-project'
|
||||
);
|
||||
const channel = await readChannelSafelyAsync('/expo-project');
|
||||
expect(channel).toBe(testChannel);
|
||||
});
|
||||
it('returns null if the channel is not defined natively', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/expo-project/Supporting/Expo.plist': ExpoPlistWithOutChannel,
|
||||
'ios/expo-project/AppDelegate.m': '',
|
||||
},
|
||||
'/expo-project'
|
||||
);
|
||||
const channel = await readChannelSafelyAsync('/expo-project');
|
||||
expect(channel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const ExpoPlistWithChannel = `
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>EXUpdatesCheckOnLaunch</key>
|
||||
<string>ALWAYS</string>
|
||||
<key>EXUpdatesEnabled</key>
|
||||
<true/>
|
||||
<key>EXUpdatesLaunchWaitMs</key>
|
||||
<integer>0</integer>
|
||||
<key>EXUpdatesReleaseChannel</key>
|
||||
<string>default</string>
|
||||
<key>EXUpdatesSDKVersion</key>
|
||||
<string>40.0.0</string>
|
||||
<key>EXUpdatesURL</key>
|
||||
<string>https://exp.host/@user/expo-project</string>
|
||||
<key>EXUpdatesRequestHeaders</key>
|
||||
<dict>
|
||||
<key>expo-channel-name</key>
|
||||
<string>${testChannel}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
const ExpoPlistWithOutChannel = `
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>EXUpdatesCheckOnLaunch</key>
|
||||
<string>ALWAYS</string>
|
||||
<key>EXUpdatesEnabled</key>
|
||||
<true/>
|
||||
<key>EXUpdatesLaunchWaitMs</key>
|
||||
<integer>0</integer>
|
||||
<key>EXUpdatesReleaseChannel</key>
|
||||
<string>default</string>
|
||||
<key>EXUpdatesSDKVersion</key>
|
||||
<string>40.0.0</string>
|
||||
<key>EXUpdatesURL</key>
|
||||
<string>https://exp.host/@user/expo-project</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
@@ -0,0 +1,244 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
|
||||
import { readPlistAsync } from '../plist';
|
||||
import {
|
||||
BumpStrategy,
|
||||
bumpVersionAsync,
|
||||
bumpVersionInAppJsonAsync,
|
||||
readBuildNumberAsync,
|
||||
readShortVersionAsync,
|
||||
} from '../version';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
afterAll(() => {
|
||||
// do not remove the following line
|
||||
// this fixes a weird error with tempy in @expo/image-utils
|
||||
fs.removeSync(os.tmpdir());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
// do not remove the following line
|
||||
// this fixes a weird error with tempy in @expo/image-utils
|
||||
fs.mkdirpSync(os.tmpdir());
|
||||
});
|
||||
|
||||
// generic workflow
|
||||
describe(bumpVersionAsync, () => {
|
||||
it('bumps expo.ios.buildNumber and CFBundleVersion when strategy = BumpStrategy.BUILD_NUMBER', async () => {
|
||||
const fakeExp = initGenericProject();
|
||||
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.BUILD_NUMBER,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('2');
|
||||
expect(appJSON.expo.version).toBe('1.0.0');
|
||||
expect(appJSON.expo.ios.buildNumber).toBe('2');
|
||||
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.0');
|
||||
expect(infoPlist['CFBundleVersion']).toBe('2');
|
||||
});
|
||||
|
||||
it('bumps expo.version and CFBundleShortVersionString when strategy = BumpStrategy.SHORT_VERSION', async () => {
|
||||
const fakeExp = initGenericProject();
|
||||
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.SHORT_VERSION,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.1');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
expect(appJSON.expo.version).toBe('1.0.1');
|
||||
expect(appJSON.expo.ios.buildNumber).toBe('1');
|
||||
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.1');
|
||||
expect(infoPlist['CFBundleVersion']).toBe('1');
|
||||
});
|
||||
|
||||
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
|
||||
const fakeExp = initGenericProject();
|
||||
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.NOOP,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
expect(appJSON.expo.version).toBe('1.0.0');
|
||||
expect(appJSON.expo.ios.buildNumber).toBe('1');
|
||||
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.0');
|
||||
expect(infoPlist['CFBundleVersion']).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
// managed workflow
|
||||
describe(bumpVersionInAppJsonAsync, () => {
|
||||
it('bumps expo.ios.buildNumber when strategy = BumpStrategy.BUILD_NUMBER', async () => {
|
||||
const fakeExp = initManagedProject();
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.BUILD_NUMBER,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('2');
|
||||
expect(appJSON.expo.version).toBe('1.0.0');
|
||||
expect(appJSON.expo.ios.buildNumber).toBe('2');
|
||||
});
|
||||
|
||||
it('bumps expo.version when strategy = BumpStrategy.SHORT_VERSION', async () => {
|
||||
const fakeExp = initManagedProject();
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.SHORT_VERSION,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
expect(fakeExp.version).toBe('1.0.1');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
expect(appJSON.expo.version).toBe('1.0.1');
|
||||
expect(appJSON.expo.ios.buildNumber).toBe('1');
|
||||
});
|
||||
|
||||
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
|
||||
const fakeExp = initManagedProject();
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.NOOP,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
expect(appJSON.expo.version).toBe('1.0.0');
|
||||
expect(appJSON.expo.ios.buildNumber).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe(readBuildNumberAsync, () => {
|
||||
describe('generic project', () => {
|
||||
it('reads the build number from native code', async () => {
|
||||
const exp = initGenericProject();
|
||||
const buildNumber = await readBuildNumberAsync('/repo', exp);
|
||||
expect(buildNumber).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed project', () => {
|
||||
it('reads the build number from expo config', async () => {
|
||||
const exp = initManagedProject();
|
||||
const buildNumber = await readBuildNumberAsync('/repo', exp);
|
||||
expect(buildNumber).toBe('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(readShortVersionAsync, () => {
|
||||
describe('generic project', () => {
|
||||
it('reads the short version from native code', async () => {
|
||||
const exp = initGenericProject();
|
||||
const shortVersion = await readShortVersionAsync('/repo', exp);
|
||||
expect(shortVersion).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed project', () => {
|
||||
it('reads the version from app config', async () => {
|
||||
const exp = initGenericProject();
|
||||
const shortVersion = await readShortVersionAsync('/repo', exp);
|
||||
expect(shortVersion).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function initGenericProject(): ExpoConfig {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: {
|
||||
version: '1.0.0',
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./ios/myproject/Info.plist': `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>`,
|
||||
},
|
||||
'/repo'
|
||||
);
|
||||
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
version: '1.0.0',
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
},
|
||||
};
|
||||
return fakeExp;
|
||||
}
|
||||
|
||||
function initManagedProject(): ExpoConfig {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: {
|
||||
version: '1.0.0',
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
'/repo'
|
||||
);
|
||||
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
version: '1.0.0',
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
},
|
||||
};
|
||||
return fakeExp;
|
||||
}
|
||||
@@ -1,93 +1,84 @@
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { Ios, Job, Metadata, Workflow } from '@expo/eas-build-job';
|
||||
import { EasConfig } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import path from 'path';
|
||||
|
||||
import log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { prepareBuildRequestForPlatformAsync } from '../build';
|
||||
import { BuildContext, CommandContext, createBuildContext } from '../context';
|
||||
import { IosCredentials } from '../../credentials/ios/types';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
|
||||
import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
|
||||
import { resolveTargetsAsync } from '../../project/ios/target';
|
||||
import { JobData, prepareBuildRequestForPlatformAsync } from '../build';
|
||||
import { BuildContext, CommandContext, createBuildContextAsync } from '../context';
|
||||
import { transformMetadata } from '../graphql';
|
||||
import { Platform } from '../types';
|
||||
import { ensureBundleIdentifierIsValidAsync } from './bundleIdentifer';
|
||||
import { validateAndSyncProjectConfigurationAsync } from './configure';
|
||||
import { ensureIosCredentialsAsync } from './credentials';
|
||||
import { transformJob } from './graphql';
|
||||
import { prepareJobAsync } from './prepareJob';
|
||||
|
||||
export async function prepareIosBuildAsync(
|
||||
commandCtx: CommandContext,
|
||||
easConfig: EasConfig
|
||||
): Promise<() => Promise<string>> {
|
||||
const buildCtx = createBuildContext<Platform.iOS>({
|
||||
): Promise<() => Promise<string | undefined>> {
|
||||
const buildCtx = await createBuildContextAsync<Platform.IOS>({
|
||||
commandCtx,
|
||||
platform: Platform.iOS,
|
||||
platform: Platform.IOS,
|
||||
easConfig,
|
||||
});
|
||||
const { buildProfile } = buildCtx;
|
||||
|
||||
if (
|
||||
buildCtx.buildProfile.workflow === Workflow.Generic &&
|
||||
!(await fs.pathExists(path.join(commandCtx.projectDir, 'ios')))
|
||||
) {
|
||||
throw new Error(
|
||||
`"ios" directory not found. If you're trying to build a managed project, set ${chalk.bold(
|
||||
`builds.ios.${commandCtx.profile}.workflow`
|
||||
)} in "eas.json" to "managed".`
|
||||
if (buildCtx.workflow === Workflow.MANAGED) {
|
||||
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(
|
||||
commandCtx.projectDir,
|
||||
commandCtx.exp
|
||||
);
|
||||
}
|
||||
|
||||
let iosNativeProjectScheme: string | undefined;
|
||||
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
|
||||
iosNativeProjectScheme = buildCtx.buildProfile.scheme ?? (await resolveSchemeAsync(buildCtx));
|
||||
}
|
||||
await ensureBundleIdentifierIsValidAsync(commandCtx.projectDir);
|
||||
const xcodeBuildContext = await resolveXcodeBuildContextAsync(
|
||||
{
|
||||
projectDir: commandCtx.projectDir,
|
||||
nonInteractive: commandCtx.nonInteractive,
|
||||
exp: commandCtx.exp,
|
||||
},
|
||||
buildProfile
|
||||
);
|
||||
const targets = await resolveTargetsAsync(
|
||||
{
|
||||
projectDir: commandCtx.projectDir,
|
||||
exp: commandCtx.exp,
|
||||
},
|
||||
xcodeBuildContext
|
||||
);
|
||||
|
||||
return await prepareBuildRequestForPlatformAsync({
|
||||
ctx: buildCtx,
|
||||
projectConfiguration: {
|
||||
iosNativeProjectScheme,
|
||||
ensureCredentialsAsync: async (ctx: BuildContext<Platform.IOS>) => {
|
||||
return ensureIosCredentialsAsync(ctx, targets);
|
||||
},
|
||||
ensureCredentialsAsync: ensureIosCredentialsAsync,
|
||||
ensureProjectConfiguredAsync: async () => {
|
||||
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
|
||||
await validateAndSyncProjectConfigurationAsync(commandCtx.projectDir, commandCtx.exp);
|
||||
}
|
||||
await validateAndSyncProjectConfigurationAsync({
|
||||
projectDir: commandCtx.projectDir,
|
||||
exp: commandCtx.exp,
|
||||
buildProfile,
|
||||
});
|
||||
},
|
||||
prepareJobAsync: async (
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
jobData: JobData<IosCredentials>
|
||||
): Promise<Job> => {
|
||||
return await prepareJobAsync(ctx, { ...jobData, buildScheme: xcodeBuildContext.buildScheme });
|
||||
},
|
||||
sendBuildRequestAsync: async (
|
||||
appId: string,
|
||||
job: Ios.Job,
|
||||
metadata: Metadata
|
||||
): Promise<BuildResult> => {
|
||||
const graphqlMetadata = transformMetadata(metadata);
|
||||
const graphqlJob = transformJob(job);
|
||||
return await BuildMutation.createIosBuildAsync({
|
||||
appId,
|
||||
job: graphqlJob,
|
||||
metadata: graphqlMetadata,
|
||||
});
|
||||
},
|
||||
prepareJobAsync,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSchemeAsync(ctx: BuildContext<Platform.iOS>): Promise<string> {
|
||||
const schemes = IOSConfig.Scheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
|
||||
if (schemes.length === 1) {
|
||||
return schemes[0];
|
||||
}
|
||||
|
||||
const sortedSchemes = sortBy(schemes);
|
||||
log.newLine();
|
||||
log(
|
||||
`We've found multiple schemes in your Xcode project: ${chalk.bold(sortedSchemes.join(', '))}`
|
||||
);
|
||||
log(
|
||||
`You can specify the scheme you want to build at ${chalk.bold(
|
||||
`builds.ios.${ctx.commandCtx.profile}.scheme`
|
||||
)} in eas.json.`
|
||||
);
|
||||
if (ctx.commandCtx.nonInteractive) {
|
||||
const withoutTvOS = sortedSchemes.filter(i => !i.includes('tvOS'));
|
||||
const scheme = withoutTvOS.length > 0 ? withoutTvOS[0] : sortedSchemes[0];
|
||||
log(`You've run Expo CLI in non-interactive mode, choosing the ${chalk.bold(scheme)} scheme.`);
|
||||
log.newLine();
|
||||
return scheme;
|
||||
} else {
|
||||
const { selectedScheme } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'selectedScheme',
|
||||
message: 'Which scheme would you like to build now?',
|
||||
choices: sortedSchemes.map(scheme => ({ title: scheme, value: scheme })),
|
||||
});
|
||||
log.newLine();
|
||||
return selectedScheme as string;
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import log from '../../log';
|
||||
import {
|
||||
ensureAppIdentifierIsDefinedAsync,
|
||||
getProjectConfigDescription,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { Platform } from '../types';
|
||||
|
||||
enum BundleIdentiferSource {
|
||||
XcodeProject,
|
||||
AppJson,
|
||||
}
|
||||
|
||||
function isBundleIdentifierValid(bundleIdentifier: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(bundleIdentifier);
|
||||
}
|
||||
|
||||
export async function ensureBundleIdentifierIsValidAsync(projectDir: string) {
|
||||
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.iOS);
|
||||
if (!isBundleIdentifierValid(bundleIdentifier)) {
|
||||
const configDescription = getProjectConfigDescription(projectDir);
|
||||
log.error(
|
||||
`Invalid format of iOS bundleId. Only alphanumeric characters, '.' and '-' are allowed, and each '.' must be followed by a letter.`
|
||||
);
|
||||
log.error(`Update "ios.bundleIdentifier" in ${configDescription} and run this command again.`);
|
||||
throw new Error('Invalid bundleIdentifier');
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureBundleIdentifierAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<void> {
|
||||
const configDescription = getProjectConfigDescription(projectDir);
|
||||
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdenitifer.getBundleIdentifierFromPbxproj(
|
||||
projectDir
|
||||
);
|
||||
const bundleIdentifierFromConfig = IOSConfig.BundleIdenitifer.getBundleIdentifier(exp);
|
||||
if (bundleIdentifierFromPbxproj && bundleIdentifierFromConfig) {
|
||||
if (bundleIdentifierFromPbxproj !== bundleIdentifierFromConfig) {
|
||||
log.addNewLineIfNone();
|
||||
log.warn(
|
||||
`We detected that your Xcode project is configured with a different bundle identifier than the one defined in ${configDescription}.`
|
||||
);
|
||||
const hasBundleIdentifierInStaticConfig = await hasBundleIdentifierInStaticConfigAsync(
|
||||
projectDir,
|
||||
exp
|
||||
);
|
||||
if (!hasBundleIdentifierInStaticConfig) {
|
||||
log(`If you choose the one defined in ${configDescription} we'll automatically configure your Xcode project with it.
|
||||
However, if you choose the one defined in the Xcode project you'll have to update ${configDescription} on your own.`);
|
||||
}
|
||||
log.newLine();
|
||||
const { bundleIdentifierSource } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'bundleIdentifierSource',
|
||||
message: 'Which bundle identifier should we use?',
|
||||
choices: [
|
||||
{
|
||||
title: `${chalk.bold(bundleIdentifierFromPbxproj)} - In Xcode project`,
|
||||
value: BundleIdentiferSource.XcodeProject,
|
||||
},
|
||||
{
|
||||
title: `${chalk.bold(bundleIdentifierFromConfig)} - In your ${configDescription}`,
|
||||
value: BundleIdentiferSource.AppJson,
|
||||
},
|
||||
],
|
||||
});
|
||||
switch (bundleIdentifierSource) {
|
||||
case BundleIdentiferSource.AppJson: {
|
||||
IOSConfig.BundleIdenitifer.setBundleIdentifierForPbxproj(
|
||||
projectDir,
|
||||
bundleIdentifierFromConfig
|
||||
);
|
||||
break;
|
||||
}
|
||||
case BundleIdentiferSource.XcodeProject: {
|
||||
if (hasBundleIdentifierInStaticConfig) {
|
||||
await updateAppJsonConfigAsync(projectDir, exp, bundleIdentifierFromPbxproj);
|
||||
} else {
|
||||
throw new Error(missingBundleIdentifierMessage(configDescription));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!bundleIdentifierFromPbxproj && !bundleIdentifierFromConfig) {
|
||||
throw new Error(missingBundleIdentifierMessage(configDescription));
|
||||
} else if (bundleIdentifierFromPbxproj && !bundleIdentifierFromConfig) {
|
||||
if (getConfigFilePaths(projectDir).staticConfigPath) {
|
||||
await updateAppJsonConfigAsync(projectDir, exp, bundleIdentifierFromPbxproj);
|
||||
} else {
|
||||
throw new Error(missingBundleIdentifierMessage(configDescription));
|
||||
}
|
||||
} else if (!bundleIdentifierFromPbxproj && bundleIdentifierFromConfig) {
|
||||
IOSConfig.BundleIdenitifer.setBundleIdentifierForPbxproj(
|
||||
projectDir,
|
||||
bundleIdentifierFromConfig
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function missingBundleIdentifierMessage(configDescription: string): string {
|
||||
return `Please define "ios.bundleIdentifier" in ${configDescription} and run "eas build:configure" again.`;
|
||||
}
|
||||
|
||||
async function updateAppJsonConfigAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
newBundleIdentifier: string
|
||||
): Promise<void> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
assert(paths.staticConfigPath, "can't update dynamic configs");
|
||||
|
||||
const rawStaticConfig = await fs.readJSON(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = {
|
||||
...rawStaticConfig.expo,
|
||||
ios: { ...rawStaticConfig.expo?.ios, bundleIdentifier: newBundleIdentifier },
|
||||
};
|
||||
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
|
||||
|
||||
exp.ios = { ...exp.ios, bundleIdentifier: newBundleIdentifier };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if static config exists and if ios.bundleIdentifier is defined there.
|
||||
* It will return false if the value in static config is different than "ios.bundleIdentifier" in ExpoConfig
|
||||
*/
|
||||
async function hasBundleIdentifierInStaticConfigAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<boolean> {
|
||||
if (!exp.ios?.bundleIdentifier) {
|
||||
return false;
|
||||
}
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (!paths.staticConfigPath) {
|
||||
return false;
|
||||
}
|
||||
const rawStaticConfig = await fs.readJson(paths.staticConfigPath);
|
||||
return rawStaticConfig?.expo?.ios?.bundleIdentifier === exp.ios.bundleIdentifier;
|
||||
}
|
||||
@@ -1,41 +1,63 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { IosBuildProfile, VersionAutoIncrement } from '@expo/eas-json';
|
||||
|
||||
import log from '../../log';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
ensureBundleIdentifierIsDefinedForManagedProjectAsync,
|
||||
warnIfBundleIdentifierDefinedInAppConfigForGenericProject,
|
||||
} from '../../project/ios/bundleIdentifier';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { ConfigureContext } from '../context';
|
||||
import { isExpoUpdatesInstalled } from '../utils/updates';
|
||||
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
|
||||
import {
|
||||
configureBundleIdentifierAsync,
|
||||
ensureBundleIdentifierIsValidAsync,
|
||||
} from './bundleIdentifer';
|
||||
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
|
||||
|
||||
export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
|
||||
if (!ctx.hasIosNativeProject) {
|
||||
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
|
||||
return;
|
||||
}
|
||||
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
|
||||
await ensureBundleIdentifierIsValidAsync(ctx.projectDir);
|
||||
|
||||
warnIfBundleIdentifierDefinedInAppConfigForGenericProject(ctx.projectDir, ctx.exp);
|
||||
|
||||
if (isExpoUpdatesInstalled(ctx.projectDir)) {
|
||||
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
|
||||
}
|
||||
log.withTick('iOS project configured');
|
||||
Log.withTick('iOS project configured');
|
||||
}
|
||||
|
||||
export async function validateAndSyncProjectConfigurationAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<void> {
|
||||
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdenitifer.getBundleIdentifierFromPbxproj(
|
||||
projectDir
|
||||
);
|
||||
if (!bundleIdentifierFromPbxproj || bundleIdentifierFromPbxproj !== exp.ios?.bundleIdentifier) {
|
||||
throw new Error(
|
||||
'Bundle identifier is not configured correctly in your Xcode project. Please run "eas build:configure" to configure it.'
|
||||
);
|
||||
export async function validateAndSyncProjectConfigurationAsync({
|
||||
projectDir,
|
||||
exp,
|
||||
buildProfile,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
buildProfile: IosBuildProfile;
|
||||
}): Promise<void> {
|
||||
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
|
||||
const { autoIncrement } = buildProfile;
|
||||
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement);
|
||||
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
if (isExpoUpdatesInstalled(projectDir)) {
|
||||
await syncUpdatesConfigurationAsync(projectDir, exp);
|
||||
}
|
||||
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
|
||||
} else {
|
||||
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
|
||||
}
|
||||
if (isExpoUpdatesInstalled(projectDir)) {
|
||||
await syncUpdatesConfigurationAsync(projectDir, exp);
|
||||
}
|
||||
|
||||
function resolveVersionBumpStrategy(autoIncrement: VersionAutoIncrement): BumpStrategy {
|
||||
if (autoIncrement === true) {
|
||||
return BumpStrategy.BUILD_NUMBER;
|
||||
} else if (autoIncrement === false) {
|
||||
return BumpStrategy.NOOP;
|
||||
} else if (autoIncrement === 'buildNumber') {
|
||||
return BumpStrategy.BUILD_NUMBER;
|
||||
} else {
|
||||
return BumpStrategy.SHORT_VERSION;
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,49 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { CredentialsSource, DistributionType } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
|
||||
import { createCredentialsContextAsync } from '../../credentials/context';
|
||||
import IosCredentialsProvider, {
|
||||
IosCredentials,
|
||||
} from '../../credentials/ios/IosCredentialsProvider';
|
||||
import { AppLookupParams } from '../../credentials/ios/credentials';
|
||||
import IosCredentialsProvider from '../../credentials/ios/IosCredentialsProvider';
|
||||
import { getAppFromContext } from '../../credentials/ios/actions/BuildCredentialsUtils';
|
||||
import { resolveEntitlementsJsonAsync } from '../../credentials/ios/appstore/entitlements';
|
||||
import { IosCredentials, Target } from '../../credentials/ios/types';
|
||||
import { CredentialsResult } from '../build';
|
||||
import { BuildContext } from '../context';
|
||||
import { ensureCredentialsAsync } from '../credentials';
|
||||
import { Platform } from '../types';
|
||||
import { logCredentialsSource } from '../utils/credentials';
|
||||
|
||||
export async function ensureIosCredentialsAsync(
|
||||
ctx: BuildContext<Platform.iOS>
|
||||
buildCtx: BuildContext<Platform.IOS>,
|
||||
targets: Target[]
|
||||
): Promise<CredentialsResult<IosCredentials> | undefined> {
|
||||
if (!shouldProvideCredentials(ctx)) {
|
||||
if (!shouldProvideCredentials(buildCtx)) {
|
||||
return;
|
||||
}
|
||||
assert(ctx.commandCtx.exp?.ios?.bundleIdentifier, 'ios.bundleIdentifier is required');
|
||||
return await resolveIosCredentialsAsync(ctx.commandCtx.projectDir, {
|
||||
app: {
|
||||
accountName: ctx.commandCtx.accountName,
|
||||
projectName: ctx.commandCtx.projectName,
|
||||
bundleIdentifier: ctx.commandCtx.exp.ios.bundleIdentifier,
|
||||
},
|
||||
workflow: ctx.buildProfile.workflow,
|
||||
credentialsSource: ctx.buildProfile.credentialsSource,
|
||||
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
|
||||
nonInteractive: ctx.commandCtx.nonInteractive,
|
||||
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
|
||||
|
||||
const { projectDir } = buildCtx.commandCtx;
|
||||
|
||||
const ctx = await createCredentialsContextAsync(projectDir, {
|
||||
nonInteractive: buildCtx.commandCtx.nonInteractive,
|
||||
});
|
||||
}
|
||||
|
||||
interface ResolveCredentialsParams {
|
||||
app: AppLookupParams;
|
||||
workflow: Workflow;
|
||||
credentialsSource: CredentialsSource;
|
||||
distribution: DistributionType;
|
||||
nonInteractive: boolean;
|
||||
skipCredentialsCheck: boolean;
|
||||
}
|
||||
const provider = new IosCredentialsProvider(ctx, {
|
||||
app: getAppFromContext(ctx),
|
||||
targets,
|
||||
iosCapabilitiesOptions: {
|
||||
entitlements: await resolveEntitlementsJsonAsync(
|
||||
buildCtx.commandCtx.projectDir,
|
||||
buildCtx.workflow
|
||||
),
|
||||
},
|
||||
distribution: buildCtx.buildProfile.distribution ?? 'store',
|
||||
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
|
||||
});
|
||||
|
||||
export async function resolveIosCredentialsAsync(
|
||||
projectDir: string,
|
||||
params: ResolveCredentialsParams
|
||||
): Promise<CredentialsResult<IosCredentials>> {
|
||||
const provider = new IosCredentialsProvider(
|
||||
await createCredentialsContextAsync(projectDir, {
|
||||
nonInteractive: params.nonInteractive,
|
||||
}),
|
||||
{
|
||||
app: params.app,
|
||||
distribution: params.distribution,
|
||||
skipCredentialsCheck: params.skipCredentialsCheck,
|
||||
}
|
||||
);
|
||||
const credentialsSource = await ensureCredentialsAsync(
|
||||
provider,
|
||||
params.workflow,
|
||||
params.credentialsSource,
|
||||
params.nonInteractive
|
||||
);
|
||||
const { credentialsSource } = buildCtx.buildProfile;
|
||||
|
||||
logCredentialsSource(credentialsSource, Platform.IOS);
|
||||
return {
|
||||
credentials: await provider.getCredentialsAsync(credentialsSource),
|
||||
source: credentialsSource,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(ctx: BuildContext<Platform.iOS>): boolean {
|
||||
return true;
|
||||
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
|
||||
return buildCtx.buildProfile.distribution !== 'simulator';
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Env, Ios } from '@expo/eas-build-job';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import {
|
||||
DistributionType,
|
||||
IosBuildType,
|
||||
IosJobInput,
|
||||
IosJobSecretsInput,
|
||||
} from '../../graphql/generated';
|
||||
import { transformProjectArchive, transformWorkflow } from '../graphql';
|
||||
|
||||
export function transformJob(job: Ios.Job): IosJobInput {
|
||||
return {
|
||||
type: transformWorkflow(job.type),
|
||||
projectArchive: transformProjectArchive(job.projectArchive),
|
||||
projectRootDirectory: job.projectRootDirectory,
|
||||
releaseChannel: job.releaseChannel,
|
||||
updates: job.updates,
|
||||
distribution: job.distribution && transformDistributionType(job.distribution),
|
||||
secrets: transformIosSecrets(job.secrets),
|
||||
builderEnvironment: job.builderEnvironment,
|
||||
cache: job.cache,
|
||||
|
||||
scheme: job.scheme,
|
||||
buildConfiguration: job.buildConfiguration,
|
||||
artifactPath: job.artifactPath,
|
||||
|
||||
buildType: job.buildType && transformBuildType(job.buildType),
|
||||
username: job.username,
|
||||
};
|
||||
}
|
||||
|
||||
function transformDistributionType(distributionType: Ios.DistributionType): DistributionType {
|
||||
if (distributionType === 'store') {
|
||||
return DistributionType.Store;
|
||||
} else if (distributionType === 'internal') {
|
||||
return DistributionType.Internal;
|
||||
} else {
|
||||
return DistributionType.Simulator;
|
||||
}
|
||||
}
|
||||
|
||||
function transformIosSecrets(secrets: {
|
||||
buildCredentials?: Ios.BuildCredentials;
|
||||
env?: Env;
|
||||
}): IosJobSecretsInput {
|
||||
const buildCredentials: IosJobSecretsInput['buildCredentials'] = [];
|
||||
|
||||
for (const targetName of Object.keys(secrets.buildCredentials ?? {})) {
|
||||
buildCredentials.push({
|
||||
targetName,
|
||||
provisioningProfileBase64: nullthrows(secrets.buildCredentials?.[targetName])
|
||||
.provisioningProfileBase64,
|
||||
distributionCertificate: nullthrows(secrets.buildCredentials?.[targetName])
|
||||
.distributionCertificate,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
buildCredentials,
|
||||
environmentSecrets: secrets.env,
|
||||
};
|
||||
}
|
||||
|
||||
function transformBuildType(buildType: Ios.BuildType): IosBuildType {
|
||||
if (buildType === Ios.BuildType.DEVELOPMENT_CLIENT) {
|
||||
return IosBuildType.DevelopmentClient;
|
||||
} else {
|
||||
return IosBuildType.Release;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import plist from '@expo/plist';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
export async function readPlistAsync(plistPath: string): Promise<object> {
|
||||
if (await fs.pathExists(plistPath)) {
|
||||
const expoPlistContent = await fs.readFile(plistPath, 'utf8');
|
||||
return plist.parse(expoPlistContent);
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function writePlistAsync(
|
||||
plistPath: string,
|
||||
plistObject: IOSConfig.ExpoPlist | IOSConfig.InfoPlist
|
||||
): Promise<void> {
|
||||
const contents = plist.build(plistObject);
|
||||
await fs.mkdirp(path.dirname(plistPath));
|
||||
await fs.writeFile(plistPath, contents);
|
||||
}
|
||||
@@ -1,118 +1,89 @@
|
||||
import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceType,
|
||||
Job,
|
||||
Workflow,
|
||||
iOS,
|
||||
sanitizeJob,
|
||||
} from '@expo/eas-build-job';
|
||||
import { iOSGenericBuildProfile, iOSManagedBuildProfile } from '@expo/eas-json';
|
||||
import { ArchiveSource, Ios, Job, sanitizeJob } from '@expo/eas-build-job';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { readSecretEnvsAsync } from '../../credentials/credentialsJson/read';
|
||||
import { IosCredentials } from '../../credentials/ios/IosCredentialsProvider';
|
||||
import { getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { gitRootDirectoryAsync } from '../../utils/git';
|
||||
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
|
||||
import { getUsername } from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import vcs from '../../vcs';
|
||||
import { BuildContext } from '../context';
|
||||
import { Platform } from '../types';
|
||||
|
||||
interface JobData {
|
||||
archiveBucketKey: string;
|
||||
projectArchive: ArchiveSource;
|
||||
credentials?: IosCredentials;
|
||||
projectConfiguration: {
|
||||
iosNativeProjectScheme?: string;
|
||||
};
|
||||
buildScheme: string;
|
||||
}
|
||||
|
||||
export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
jobData: JobData
|
||||
): Promise<Job> {
|
||||
if (ctx.buildProfile.workflow === Workflow.Generic) {
|
||||
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
|
||||
return sanitizeJob(partialJob);
|
||||
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
|
||||
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
|
||||
return sanitizeJob(partialJob);
|
||||
} else {
|
||||
throw new Error("Unknown workflow. Shouldn't happen");
|
||||
const projectRootDirectory =
|
||||
path.relative(await vcs.getRootPathAsync(), ctx.commandCtx.projectDir) || '.';
|
||||
const username = getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync());
|
||||
const buildCredentials: Ios.Job['secrets']['buildCredentials'] = {};
|
||||
if (jobData.credentials) {
|
||||
const targetNames = Object.keys(jobData.credentials);
|
||||
for (const targetName of targetNames) {
|
||||
buildCredentials[targetName] = prepareTargetCredentials(jobData.credentials[targetName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CommonJobProperties {
|
||||
platform: Platform.iOS;
|
||||
projectArchive: ArchiveSource;
|
||||
releaseChannel: string;
|
||||
secrets: {
|
||||
buildCredentials?: {
|
||||
provisioningProfileBase64: string;
|
||||
distributionCertificate: {
|
||||
dataBase64: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
secretEnvs?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareJobCommonAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
jobData: JobData
|
||||
): Promise<Partial<CommonJobProperties>> {
|
||||
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
|
||||
const buildCredentials = jobData.credentials
|
||||
? {
|
||||
buildCredentials: {
|
||||
provisioningProfileBase64: jobData.credentials.provisioningProfile,
|
||||
distributionCertificate: {
|
||||
dataBase64: jobData.credentials.distributionCertificate.certP12,
|
||||
password: jobData.credentials.distributionCertificate.certPassword,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
platform: Platform.iOS,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.S3,
|
||||
bucketKey: jobData.archiveBucketKey,
|
||||
const job = {
|
||||
type: ctx.workflow,
|
||||
platform: Platform.IOS,
|
||||
projectArchive: jobData.projectArchive,
|
||||
projectRootDirectory,
|
||||
distribution: ctx.buildProfile.distribution,
|
||||
builderEnvironment: {
|
||||
image: resolveImage(ctx),
|
||||
node: ctx.buildProfile.node,
|
||||
yarn: ctx.buildProfile.yarn,
|
||||
bundler: ctx.buildProfile.bundler,
|
||||
cocoapods: ctx.buildProfile.cocoapods,
|
||||
fastlane: ctx.buildProfile.fastlane,
|
||||
expoCli: ctx.buildProfile.expoCli,
|
||||
env: ctx.buildProfile.env,
|
||||
},
|
||||
cache: {
|
||||
...ctx.buildProfile.cache,
|
||||
clear: ctx.commandCtx.clearCache,
|
||||
},
|
||||
secrets: {
|
||||
...(secretEnvs ? { secretEnvs } : {}),
|
||||
...buildCredentials,
|
||||
buildCredentials,
|
||||
},
|
||||
releaseChannel: ctx.buildProfile.releaseChannel,
|
||||
updates: { channel: ctx.buildProfile.channel },
|
||||
|
||||
scheme: jobData.buildScheme,
|
||||
buildConfiguration: ctx.buildProfile.schemeBuildConfiguration,
|
||||
artifactPath: ctx.buildProfile.artifactPath,
|
||||
|
||||
buildType: ctx.buildProfile.buildType,
|
||||
username,
|
||||
};
|
||||
|
||||
return sanitizeJob(job);
|
||||
}
|
||||
|
||||
function resolveImage(ctx: BuildContext<Platform.IOS>): Ios.BuilderEnvironment['image'] {
|
||||
// see https://linear.app/expo/issue/ENG-1396/make-default-image-dependent-on-sdk-version
|
||||
if (!ctx.buildProfile.image && ctx.commandCtx.exp.sdkVersion) {
|
||||
const majorSdkVersion = semver.major(ctx.commandCtx.exp.sdkVersion);
|
||||
if (majorSdkVersion <= 41) {
|
||||
return 'macos-catalina-10.15-xcode-12.4';
|
||||
}
|
||||
}
|
||||
return ctx.buildProfile.image ?? 'default';
|
||||
}
|
||||
|
||||
function prepareTargetCredentials(targetCredentials: TargetCredentials): Ios.TargetCredentials {
|
||||
return {
|
||||
provisioningProfileBase64: targetCredentials.provisioningProfile,
|
||||
distributionCertificate: {
|
||||
dataBase64: targetCredentials.distributionCertificate.certificateP12,
|
||||
password: targetCredentials.distributionCertificate.certificatePassword,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareGenericJobAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
jobData: JobData,
|
||||
buildProfile: iOSGenericBuildProfile
|
||||
): Promise<Partial<iOS.GenericJob>> {
|
||||
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
|
||||
return {
|
||||
...(await prepareJobCommonAsync(ctx, jobData)),
|
||||
type: Workflow.Generic,
|
||||
scheme: jobData.projectConfiguration.iosNativeProjectScheme,
|
||||
artifactPath: buildProfile.artifactPath,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
projectRootDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareManagedJobAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
jobData: JobData,
|
||||
buildProfile: iOSManagedBuildProfile
|
||||
): Promise<Partial<iOS.ManagedJob>> {
|
||||
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
|
||||
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
|
||||
return {
|
||||
...(await prepareJobCommonAsync(ctx, jobData)),
|
||||
type: Workflow.Managed,
|
||||
username: accountName,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
projectRootDirectory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
import semver from 'semver';
|
||||
|
||||
import Log from '../../log';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { updateAppJsonConfigAsync } from '../utils/appJson';
|
||||
import { readPlistAsync, writePlistAsync } from './plist';
|
||||
|
||||
export enum BumpStrategy {
|
||||
SHORT_VERSION,
|
||||
BUILD_NUMBER,
|
||||
NOOP,
|
||||
}
|
||||
|
||||
export async function bumpVersionAsync({
|
||||
bumpStrategy,
|
||||
projectDir,
|
||||
exp,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
bumpStrategy: BumpStrategy;
|
||||
}): Promise<void> {
|
||||
if (bumpStrategy === BumpStrategy.NOOP) {
|
||||
return;
|
||||
}
|
||||
ensureStaticConfigExists(projectDir);
|
||||
const infoPlist = await readInfoPlistAsync(projectDir);
|
||||
await bumpVersionInAppJsonAsync({ bumpStrategy, projectDir, exp });
|
||||
Log.log('Updated versions in app.json');
|
||||
await writeVersionsToInfoPlistAsync({ projectDir, exp, infoPlist });
|
||||
Log.log('Synchronized versions with Info.plist');
|
||||
}
|
||||
|
||||
export async function bumpVersionInAppJsonAsync({
|
||||
bumpStrategy,
|
||||
projectDir,
|
||||
exp,
|
||||
}: {
|
||||
bumpStrategy: BumpStrategy;
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
}): Promise<void> {
|
||||
if (bumpStrategy === BumpStrategy.NOOP) {
|
||||
return;
|
||||
}
|
||||
ensureStaticConfigExists(projectDir);
|
||||
Log.addNewLineIfNone();
|
||||
if (bumpStrategy === BumpStrategy.SHORT_VERSION) {
|
||||
const shortVersion = IOSConfig.Version.getVersion(exp);
|
||||
if (semver.valid(shortVersion)) {
|
||||
const bumpedShortVersion = nullthrows(semver.inc(shortVersion, 'patch'));
|
||||
Log.log(
|
||||
`Bumping ${chalk.bold('expo.version')} from ${chalk.bold(shortVersion)} to ${chalk.bold(
|
||||
bumpedShortVersion
|
||||
)}`
|
||||
);
|
||||
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
|
||||
config.version = bumpedShortVersion;
|
||||
});
|
||||
} else {
|
||||
Log.log(`${chalk.bold('expo.version')} = ${chalk.bold(shortVersion)} is not a valid semver`);
|
||||
const { bumpedShortVersion } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'bumpedShortVersion',
|
||||
message: 'What is the next version?',
|
||||
});
|
||||
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
|
||||
config.version = bumpedShortVersion;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const buildNumber = IOSConfig.Version.getBuildNumber(exp);
|
||||
if (buildNumber.match(/^\d+(\.\d+)*$/)) {
|
||||
const comps = buildNumber.split('.');
|
||||
comps[comps.length - 1] = String(Number(comps[comps.length - 1]) + 1);
|
||||
const bumpedBuildNumber = comps.join('.');
|
||||
Log.log(
|
||||
`Bumping ${chalk.bold('expo.ios.buildNumber')} from ${chalk.bold(
|
||||
buildNumber
|
||||
)} to ${chalk.bold(bumpedBuildNumber)}`
|
||||
);
|
||||
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
|
||||
config.ios = { ...config.ios, buildNumber: String(bumpedBuildNumber) };
|
||||
});
|
||||
} else {
|
||||
Log.log(`${chalk.bold('expo.ios.buildNumber')} = ${chalk.bold(buildNumber)} is not a number`);
|
||||
const { bumpedBuildNumber } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'bumpedBuildNumber',
|
||||
message: 'What is the next build number?',
|
||||
});
|
||||
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
|
||||
config.ios = { ...config.ios, buildNumber: String(bumpedBuildNumber) };
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readShortVersionAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<string | undefined> {
|
||||
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
const infoPlist = await readInfoPlistAsync(projectDir);
|
||||
return infoPlist.CFBundleShortVersionString;
|
||||
} else {
|
||||
return exp.version;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBuildNumberAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<string | undefined> {
|
||||
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
const infoPlist = await readInfoPlistAsync(projectDir);
|
||||
return infoPlist.CFBundleVersion;
|
||||
} else {
|
||||
return IOSConfig.Version.getBuildNumber(exp);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeVersionsToInfoPlistAsync({
|
||||
projectDir,
|
||||
exp,
|
||||
infoPlist,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
infoPlist: IOSConfig.InfoPlist;
|
||||
}): Promise<IOSConfig.InfoPlist> {
|
||||
let updatedInfoPlist = IOSConfig.Version.setVersion(exp, infoPlist);
|
||||
updatedInfoPlist = IOSConfig.Version.setBuildNumber(exp, updatedInfoPlist);
|
||||
await writeInfoPlistAsync({ projectDir, infoPlist: updatedInfoPlist });
|
||||
return updatedInfoPlist;
|
||||
}
|
||||
|
||||
async function readInfoPlistAsync(projectDir: string): Promise<IOSConfig.InfoPlist> {
|
||||
const infoPlistPath = IOSConfig.Paths.getInfoPlistPath(projectDir);
|
||||
return (await readPlistAsync(infoPlistPath)) as IOSConfig.InfoPlist;
|
||||
}
|
||||
|
||||
async function writeInfoPlistAsync({
|
||||
projectDir,
|
||||
infoPlist,
|
||||
}: {
|
||||
projectDir: string;
|
||||
infoPlist: IOSConfig.InfoPlist;
|
||||
}): Promise<void> {
|
||||
const infoPlistPath = IOSConfig.Paths.getInfoPlistPath(projectDir);
|
||||
await writePlistAsync(infoPlistPath, infoPlist);
|
||||
}
|
||||
|
||||
function ensureStaticConfigExists(projectDir: string): void {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (!paths.staticConfigPath) {
|
||||
throw new Error('autoIncrement option is not supported when using app.config.js');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import Log from '../log';
|
||||
|
||||
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
|
||||
|
||||
export async function runLocalBuildAsync(job: Job): Promise<void> {
|
||||
const execNameOrPath =
|
||||
process.env.EAS_LOCAL_BUILD_PLUGIN_PATH ??
|
||||
(await findWithNodeResolution()) ??
|
||||
PLUGIN_PACKAGE_NAME;
|
||||
try {
|
||||
const arg = Buffer.from(JSON.stringify({ job })).toString('base64');
|
||||
await spawnAsync(execNameOrPath, [arg], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
Log.warn(`Could not resolve executable ${execNameOrPath}.`);
|
||||
Log.warn(
|
||||
`Install ${PLUGIN_PACKAGE_NAME} package from npm e.g. ${chalk.bold(
|
||||
`npm install -g ${PLUGIN_PACKAGE_NAME}`
|
||||
)} and make sure it's in PATH.`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findWithNodeResolution(): Promise<string | undefined> {
|
||||
try {
|
||||
const resolvedPath = path.resolve(
|
||||
path.dirname(require.resolve(PLUGIN_PACKAGE_NAME)),
|
||||
'../bin/run'
|
||||
);
|
||||
if (await fs.pathExists(resolvedPath)) {
|
||||
return resolvedPath;
|
||||
}
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,8 +1,24 @@
|
||||
import { CredentialsSource, DistributionType } from '@expo/eas-json';
|
||||
import { Metadata } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
|
||||
import { getAppIdentifierAsync } from '../project/projectUtils';
|
||||
import { getApplicationIdAsync } from '../project/android/applicationId';
|
||||
import { getBundleIdentifierAsync } from '../project/ios/bundleIdentifier';
|
||||
import { getUsername } from '../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import vcs from '../vcs';
|
||||
import {
|
||||
readChannelSafelyAsync as readAndroidChannelSafelyAsync,
|
||||
readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync,
|
||||
} from './android/UpdatesModule';
|
||||
import { readVersionCodeAsync, readVersionNameAsync } from './android/version';
|
||||
import { BuildContext } from './context';
|
||||
import { BuildMetadata, Platform } from './types';
|
||||
import {
|
||||
readChannelSafelyAsync as readIosChannelSafelyAsync,
|
||||
readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync,
|
||||
} from './ios/UpdatesModule';
|
||||
import { readBuildNumberAsync, readShortVersionAsync } from './ios/version';
|
||||
import { Platform } from './types';
|
||||
import { isExpoUpdatesInstalled } from './utils/updates';
|
||||
|
||||
/**
|
||||
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
|
||||
@@ -18,17 +34,104 @@ export async function collectMetadata<T extends Platform>(
|
||||
}: {
|
||||
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
|
||||
}
|
||||
): Promise<BuildMetadata> {
|
||||
): Promise<Metadata> {
|
||||
const channelOrReleaseChannel = await resolveChannelOrReleaseChannelAsync(ctx);
|
||||
return {
|
||||
appVersion: ctx.commandCtx.exp.version!,
|
||||
trackingContext: ctx.trackingCtx,
|
||||
appVersion: await resolveAppVersionAsync(ctx),
|
||||
appBuildVersion: await resolveAppBuildVersionAsync(ctx),
|
||||
cliVersion: packageJSON.version,
|
||||
workflow: ctx.buildProfile.workflow,
|
||||
workflow: ctx.workflow,
|
||||
credentialsSource,
|
||||
sdkVersion: ctx.commandCtx.exp.sdkVersion,
|
||||
trackingContext: ctx.trackingCtx,
|
||||
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
|
||||
runtimeVersion: ctx.commandCtx.exp.runtimeVersion,
|
||||
...channelOrReleaseChannel,
|
||||
distribution: ctx.buildProfile.distribution ?? 'store',
|
||||
appName: ctx.commandCtx.exp.name,
|
||||
appIdentifier:
|
||||
(await getAppIdentifierAsync(ctx.commandCtx.projectDir, ctx.platform)) ?? undefined,
|
||||
appIdentifier: await resolveAppIdentifierAsync(ctx),
|
||||
buildProfile: ctx.commandCtx.profile,
|
||||
gitCommitHash: await vcs.getCommitHashAsync(),
|
||||
username: getUsername(ctx.commandCtx.exp, await ensureLoggedInAsync()),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAppVersionAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
): Promise<string | undefined> {
|
||||
if (ctx.platform === Platform.IOS) {
|
||||
return await readShortVersionAsync(ctx.commandCtx.projectDir, ctx.commandCtx.exp);
|
||||
} else {
|
||||
return await readVersionNameAsync(ctx.commandCtx.projectDir, ctx.commandCtx.exp);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAppBuildVersionAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
): Promise<string | undefined> {
|
||||
if (ctx.platform === Platform.IOS) {
|
||||
return await readBuildNumberAsync(ctx.commandCtx.projectDir, ctx.commandCtx.exp);
|
||||
} else {
|
||||
const versionCode = await readVersionCodeAsync(ctx.commandCtx.projectDir, ctx.commandCtx.exp);
|
||||
return versionCode !== undefined ? String(versionCode) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAppIdentifierAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
): Promise<string> {
|
||||
if (ctx.platform === Platform.IOS) {
|
||||
return await getBundleIdentifierAsync(ctx.commandCtx.projectDir, ctx.commandCtx.exp);
|
||||
} else {
|
||||
return await getApplicationIdAsync(ctx.commandCtx.projectDir, ctx.commandCtx.exp);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveChannelOrReleaseChannelAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
): Promise<{ channel: string } | { releaseChannel: string } | null> {
|
||||
if (!isExpoUpdatesInstalled(ctx.commandCtx.projectDir)) {
|
||||
return null;
|
||||
}
|
||||
if (ctx.buildProfile.channel) {
|
||||
return { channel: ctx.buildProfile.channel };
|
||||
}
|
||||
if (ctx.buildProfile.releaseChannel) {
|
||||
return { releaseChannel: ctx.buildProfile.releaseChannel };
|
||||
}
|
||||
const channel = await getNativeChannelAsync(ctx);
|
||||
if (channel) {
|
||||
return { channel };
|
||||
}
|
||||
const releaseChannel = await getNativeReleaseChannelAsync(ctx);
|
||||
return { releaseChannel };
|
||||
}
|
||||
|
||||
async function getNativeReleaseChannelAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
): Promise<string> {
|
||||
switch (ctx.platform) {
|
||||
case Platform.ANDROID: {
|
||||
return (await readAndroidReleaseChannelSafelyAsync(ctx.commandCtx.projectDir)) ?? 'default';
|
||||
}
|
||||
case Platform.IOS: {
|
||||
return (await readIosReleaseChannelSafelyAsync(ctx.commandCtx.projectDir)) ?? 'default';
|
||||
}
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
async function getNativeChannelAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
): Promise<string | undefined> {
|
||||
switch (ctx.platform) {
|
||||
case Platform.ANDROID: {
|
||||
return (await readAndroidChannelSafelyAsync(ctx.commandCtx.projectDir)) ?? undefined;
|
||||
}
|
||||
case Platform.IOS: {
|
||||
return (await readIosChannelSafelyAsync(ctx.commandCtx.projectDir)) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,124 +1,18 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import {
|
||||
AndroidBuildProfile,
|
||||
CredentialsSource,
|
||||
DistributionType,
|
||||
iOSBuildProfile,
|
||||
} from '@expo/eas-json';
|
||||
export { Platform } from '@expo/eas-build-job';
|
||||
|
||||
export enum RequestedPlatform {
|
||||
Android = 'android',
|
||||
iOS = 'ios',
|
||||
Ios = 'ios',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export { Platform };
|
||||
|
||||
export enum BuildStatus {
|
||||
NEW = 'new',
|
||||
IN_QUEUE = 'in-queue',
|
||||
IN_PROGRESS = 'in-progress',
|
||||
ERRORED = 'errored',
|
||||
FINISHED = 'finished',
|
||||
CANCELED = 'canceled',
|
||||
}
|
||||
|
||||
export type TrackingContext = Record<string, string | number>;
|
||||
|
||||
export interface Build {
|
||||
id: string;
|
||||
status: BuildStatus;
|
||||
platform: Platform;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
artifacts?: BuildArtifacts;
|
||||
metadata?: Partial<BuildMetadata>;
|
||||
}
|
||||
|
||||
interface BuildArtifacts {
|
||||
buildUrl?: string;
|
||||
logsUrl: string;
|
||||
}
|
||||
|
||||
export type BuildMetadata = {
|
||||
/**
|
||||
* Application version (the expo.version key in app.json/app.config.js)
|
||||
*/
|
||||
appVersion: string;
|
||||
|
||||
/**
|
||||
* EAS CLI version
|
||||
*/
|
||||
cliVersion: string;
|
||||
|
||||
/**
|
||||
* Build workflow
|
||||
* It's either 'generic' or 'managed'
|
||||
*/
|
||||
workflow: Workflow;
|
||||
|
||||
/**
|
||||
* Credentials source
|
||||
* Credentials could be obtained either from credential.json or Expo servers.
|
||||
*/
|
||||
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
|
||||
|
||||
/**
|
||||
* Expo SDK version
|
||||
* It's determined by the expo package version in package.json.
|
||||
* It's undefined if the expo package is not installed for the project.
|
||||
*/
|
||||
sdkVersion?: string;
|
||||
|
||||
/**
|
||||
* Release channel (for expo-updates)
|
||||
* It's undefined if the expo-updates package is not installed for the project.
|
||||
*/
|
||||
releaseChannel?: string;
|
||||
|
||||
/**
|
||||
* Tracking context
|
||||
* It's used to track build process across different Expo services and tools.
|
||||
*/
|
||||
trackingContext: TrackingContext;
|
||||
|
||||
/**
|
||||
* Distribution type
|
||||
* Indicates whether this is a build for store or internal distribution.
|
||||
*/
|
||||
distribution: DistributionType;
|
||||
|
||||
/**
|
||||
* App name (expo.name in app.json/app.config.js)
|
||||
*/
|
||||
appName?: string;
|
||||
|
||||
/**
|
||||
* App identifier:
|
||||
* - iOS builds: the bundle identifier (expo.ios.bundleIdentifier in app.json/app.config.js)
|
||||
* - Android builds: the application id (expo.android.package in app.json/app.config.js)
|
||||
*/
|
||||
appIdentifier?: string;
|
||||
};
|
||||
|
||||
export type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
|
||||
? AndroidBuildProfile
|
||||
: iOSBuildProfile;
|
||||
|
||||
export enum AnalyticsEvent {
|
||||
BUILD_COMMAND = 'builds cli build command',
|
||||
PROJECT_UPLOAD_SUCCESS = 'builds cli project upload success',
|
||||
PROJECT_UPLOAD_FAIL = 'builds cli project upload fail',
|
||||
GATHER_CREDENTIALS_SUCCESS = 'builds cli gather credentials success',
|
||||
GATHER_CREDENTIALS_FAIL = 'builds cli gather credentials fail',
|
||||
CONFIGURE_PROJECT_SUCCESS = 'builds cli configure project success',
|
||||
CONFIGURE_PROJECT_FAIL = 'builds cli configure project fail',
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'builds cli build request fail',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'builds cli build status',
|
||||
|
||||
CREDENTIALS_SYNC_COMMAND = 'builds cli credentials sync command',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'builds cli credentials sync update local success',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'builds cli credentials sync update local fail',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'builds cli credentials sync update remote success',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'builds cli credentials sync update remote fail',
|
||||
}
|
||||
export type TrackingContext = Record<string, string | number | boolean>;
|
||||
@@ -1,3 +1,27 @@
|
||||
import { logEvent } from '../../analytics';
|
||||
|
||||
export enum Event {
|
||||
BUILD_COMMAND = 'build cli build command',
|
||||
PROJECT_UPLOAD_SUCCESS = 'build cli project upload success',
|
||||
PROJECT_UPLOAD_FAIL = 'build cli project upload fail',
|
||||
GATHER_CREDENTIALS_SUCCESS = 'build cli gather credentials success',
|
||||
GATHER_CREDENTIALS_FAIL = 'build cli gather credentials fail',
|
||||
CONFIGURE_PROJECT_SUCCESS = 'build cli configure project success',
|
||||
CONFIGURE_PROJECT_FAIL = 'build cli configure project fail',
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
CREDENTIALS_SYNC_COMMAND = 'build cli credentials sync command',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'build cli credentials sync update local success',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'build cli credentials sync update local fail',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'build cli credentials sync update remote success',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',
|
||||
}
|
||||
|
||||
export default {
|
||||
logEvent: (a: any, b: any) => {},
|
||||
logEvent(name: Event, properties: Record<string, any>) {
|
||||
logEvent(name, properties);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as ExpoConfig from '@expo/config';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
export async function updateAppJsonConfigAsync(
|
||||
{
|
||||
projectDir,
|
||||
exp,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig.ExpoConfig;
|
||||
},
|
||||
modifyConfig: (config: any) => void
|
||||
): Promise<void> {
|
||||
const paths = ExpoConfig.getConfigFilePaths(projectDir);
|
||||
assert(paths.staticConfigPath, "can't update dynamic config");
|
||||
|
||||
const rawStaticConfig = await fs.readJSON(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = rawStaticConfig.expo ?? {};
|
||||
modifyConfig(rawStaticConfig.expo);
|
||||
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
|
||||
|
||||
modifyConfig(exp);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import Log from '../../log';
|
||||
import { requestedPlatformDisplayNames } from '../constants';
|
||||
|
||||
export function logCredentialsSource(credentialsSource: CredentialsSource, platform: Platform) {
|
||||
let message = `Using ${credentialsSource} ${requestedPlatformDisplayNames[platform]} credentials`;
|
||||
if (credentialsSource === CredentialsSource.LOCAL)
|
||||
message += ` ${chalk.dim('(credentials.json)')}`;
|
||||
else if (credentialsSource === CredentialsSource.REMOTE)
|
||||
message += ` ${chalk.dim('(Expo server)')}`;
|
||||
Log.succeed(message);
|
||||
}
|
||||
@@ -1,69 +1,127 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildStatus as GraphQLBuildStatus,
|
||||
} from '../../graphql/generated';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { platformDisplayNames } from '../constants';
|
||||
import { Build, BuildStatus } from '../types';
|
||||
import { appPlatformDisplayNames } from '../constants';
|
||||
import { getBuildLogsUrl } from './url';
|
||||
|
||||
interface Options {
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
export default function formatBuild(build: Build, { accountName }: Options) {
|
||||
const fields: { label: string; value: string }[] = [
|
||||
export function formatGraphQLBuild(build: BuildFragment) {
|
||||
const actor = getActorName(build);
|
||||
const account = build.project.__typename === 'App' ? build.project.ownerAccount.name : 'unknown';
|
||||
const fields: { label: string; value?: string | null }[] = [
|
||||
{ label: 'ID', value: build.id },
|
||||
{
|
||||
label: 'Platform',
|
||||
value: platformDisplayNames[build.platform],
|
||||
value: appPlatformDisplayNames[build.platform],
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case BuildStatus.IN_QUEUE:
|
||||
case GraphQLBuildStatus.New:
|
||||
return chalk.blue('new');
|
||||
case GraphQLBuildStatus.InQueue:
|
||||
return chalk.blue('in queue');
|
||||
case BuildStatus.IN_PROGRESS:
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return chalk.blue('in progress');
|
||||
case BuildStatus.FINISHED:
|
||||
case GraphQLBuildStatus.Canceled:
|
||||
return chalk.gray('canceled');
|
||||
case GraphQLBuildStatus.Finished:
|
||||
return chalk.green('finished');
|
||||
case BuildStatus.ERRORED:
|
||||
case GraphQLBuildStatus.Errored:
|
||||
return chalk.red('errored');
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Distribution',
|
||||
value: build.distribution?.toLowerCase(),
|
||||
},
|
||||
{
|
||||
label: 'Release Channel',
|
||||
value: build.releaseChannel,
|
||||
},
|
||||
{
|
||||
label: 'Channel',
|
||||
value: build.channel,
|
||||
},
|
||||
{
|
||||
label: 'SDK Version',
|
||||
value: build.sdkVersion,
|
||||
},
|
||||
{
|
||||
label: 'Runtime Version',
|
||||
value: build.runtimeVersion,
|
||||
},
|
||||
{
|
||||
label: 'Version',
|
||||
value: build.appVersion,
|
||||
},
|
||||
{
|
||||
label: build.platform === AppPlatform.Android ? 'Version code' : 'Build number',
|
||||
value: build.appBuildVersion,
|
||||
},
|
||||
{
|
||||
label: 'Commit',
|
||||
value: build.gitCommitHash,
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
value: getBuildLogsUrl({ buildId: build.id, account: accountName }),
|
||||
value: getBuildLogsUrl({ buildId: build.id, account }),
|
||||
},
|
||||
{
|
||||
label: 'Artifact',
|
||||
get value() {
|
||||
if (build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS) {
|
||||
return '<in progress>';
|
||||
switch (build.status) {
|
||||
case GraphQLBuildStatus.New:
|
||||
case GraphQLBuildStatus.InQueue:
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return '<in progress>';
|
||||
case GraphQLBuildStatus.Canceled:
|
||||
case GraphQLBuildStatus.Errored:
|
||||
return null;
|
||||
case GraphQLBuildStatus.Finished: {
|
||||
const url = build.artifacts?.buildUrl;
|
||||
return url ? url : chalk.red('not found');
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = build.artifacts?.buildUrl;
|
||||
|
||||
if (!url) {
|
||||
return chalk.red('not found');
|
||||
}
|
||||
|
||||
return url;
|
||||
},
|
||||
},
|
||||
{ label: 'Started at', value: new Date(build.createdAt).toLocaleString() },
|
||||
{
|
||||
label: 'Finished at',
|
||||
value:
|
||||
build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS
|
||||
? '<in progress>'
|
||||
: new Date(build.updatedAt).toLocaleString(),
|
||||
value: [
|
||||
GraphQLBuildStatus.New,
|
||||
GraphQLBuildStatus.InQueue,
|
||||
GraphQLBuildStatus.InProgress,
|
||||
].includes(build.status)
|
||||
? '<in progress>'
|
||||
: new Date(build.updatedAt).toLocaleString(),
|
||||
},
|
||||
// TODO: we can do it once we migrate to GraphQL
|
||||
// { label: 'Started by', get: build => 'TODO' },
|
||||
{ label: 'Started by', value: actor ?? 'unknown' },
|
||||
];
|
||||
|
||||
return formatFields(fields);
|
||||
const filteredFields = fields.filter(({ value }) => value !== undefined && value !== null) as {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
return formatFields(filteredFields);
|
||||
}
|
||||
|
||||
const getActorName = (build: BuildFragment): string => {
|
||||
if (!build.initiatingActor) {
|
||||
return 'unknown';
|
||||
} else if (build.initiatingActor.__typename === 'User') {
|
||||
return build.initiatingActor.username;
|
||||
} else {
|
||||
return build.initiatingActor.firstName ?? 'unknown';
|
||||
}
|
||||
};
|
||||
@@ -1,16 +1,23 @@
|
||||
import { DistributionType } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import indentString from 'indent-string';
|
||||
import qrcodeTerminal from 'qrcode-terminal';
|
||||
|
||||
import log from '../../log';
|
||||
import { platformDisplayNames, platformEmojis } from '../constants';
|
||||
import { Build } from '../types';
|
||||
import { getBuildLogsUrl } from './url';
|
||||
|
||||
export interface DeprecationInfo {
|
||||
type: 'user-facing' | 'internal';
|
||||
message: string;
|
||||
}
|
||||
import {
|
||||
BuildError,
|
||||
BuildFragment,
|
||||
BuildStatus,
|
||||
DistributionType,
|
||||
EasBuildDeprecationInfo,
|
||||
EasBuildDeprecationInfoType,
|
||||
} from '../../graphql/generated';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import {
|
||||
appPlatformDisplayNames,
|
||||
appPlatformEmojis,
|
||||
requestedPlatformDisplayNames,
|
||||
} from '../constants';
|
||||
import { getBuildLogsUrl, getInternalDistributionInstallUrl } from './url';
|
||||
|
||||
export function printLogsUrls(
|
||||
accountName: string,
|
||||
@@ -22,69 +29,101 @@ export function printLogsUrls(
|
||||
buildId,
|
||||
account: accountName,
|
||||
});
|
||||
log(`Build details: ${chalk.underline(logsUrl)}`);
|
||||
Log.log(`Build details: ${chalk.underline(logsUrl)}`);
|
||||
} else {
|
||||
builds.forEach(({ buildId, platform }) => {
|
||||
const logsUrl = getBuildLogsUrl({
|
||||
buildId,
|
||||
account: accountName,
|
||||
});
|
||||
log(`${platformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`);
|
||||
Log.log(
|
||||
`${requestedPlatformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function printBuildResults(accountName: string, builds: (Build | null)[]): void {
|
||||
export function printBuildResults(accountName: string, builds: (BuildFragment | null)[]): void {
|
||||
Log.newLine();
|
||||
if (builds.length === 1) {
|
||||
const [build] = builds;
|
||||
assert(build, 'Build should be defined');
|
||||
printBuildResult(accountName, build);
|
||||
} else {
|
||||
(builds.filter(i => i) as Build[])
|
||||
.filter(build => build.status === 'finished')
|
||||
.forEach(build => printBuildResult(accountName, build));
|
||||
(builds.filter(i => i) as BuildFragment[]).forEach(build =>
|
||||
printBuildResult(accountName, build)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printBuildResult(accountName: string, build: Build): void {
|
||||
if (build.metadata?.distribution === DistributionType.INTERNAL) {
|
||||
function printBuildResult(accountName: string, build: BuildFragment): void {
|
||||
Log.addNewLineIfNone();
|
||||
if (build.status === BuildStatus.Errored) {
|
||||
const userError = build.error;
|
||||
Log.error(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} build failed${userError ? ':' : ''}`
|
||||
);
|
||||
if (userError) {
|
||||
printUserError(userError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (build.status === BuildStatus.Canceled) {
|
||||
Log.error(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} build was canceled`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (build.distribution === DistributionType.Internal) {
|
||||
const logsUrl = getBuildLogsUrl({
|
||||
buildId: build.id,
|
||||
account: accountName,
|
||||
});
|
||||
log(
|
||||
`${platformEmojis[build.platform]} Open this link on your ${
|
||||
platformDisplayNames[build.platform]
|
||||
} devices to install the app:`
|
||||
const installUrl = getInternalDistributionInstallUrl(build);
|
||||
qrcodeTerminal.generate(installUrl, code => Log.log(`${indentString(code, 2)}\n`));
|
||||
Log.log(
|
||||
`${appPlatformEmojis[build.platform]} Open this link on your ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} devices (or scan the QR code) to install the app:`
|
||||
);
|
||||
log(`${chalk.underline(logsUrl)}`);
|
||||
log.newLine();
|
||||
Log.log(`${chalk.underline(logsUrl)}`);
|
||||
} else {
|
||||
// TODO: it looks like buildUrl could possibly be undefined, based on the code below.
|
||||
// we should account for this case better if it is possible
|
||||
const url = build.artifacts?.buildUrl ?? '';
|
||||
log(`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} app:`);
|
||||
log(`${chalk.underline(url)}`);
|
||||
log.newLine();
|
||||
Log.log(`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`);
|
||||
Log.log(`${chalk.underline(url)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): void {
|
||||
export function printDeprecationWarnings(deprecationInfo?: EasBuildDeprecationInfo | null): void {
|
||||
if (!deprecationInfo) {
|
||||
return;
|
||||
}
|
||||
if (deprecationInfo.type === 'internal') {
|
||||
log.warn('This command is using API that soon will be deprecated, please update expo-cli.');
|
||||
log.warn("Changes won't affect your project config.");
|
||||
log.warn(deprecationInfo.message);
|
||||
} else if (deprecationInfo.type === 'user-facing') {
|
||||
log.warn('This command is using API that soon will be deprecated, please update expo-cli.');
|
||||
log.warn(
|
||||
'There might be some changes necessary to your project config, latest expo-cli will provide more specific error messages.'
|
||||
if (deprecationInfo.type === EasBuildDeprecationInfoType.Internal) {
|
||||
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
|
||||
Log.warn("Changes won't affect your project config.");
|
||||
Log.warn(deprecationInfo.message);
|
||||
} else if (deprecationInfo.type === EasBuildDeprecationInfoType.UserFacing) {
|
||||
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
|
||||
Log.warn(
|
||||
'There might be some changes necessary to your project config, latest eas-cli will provide more specific error messages.'
|
||||
);
|
||||
log.warn(deprecationInfo.message);
|
||||
Log.warn(deprecationInfo.message);
|
||||
} else {
|
||||
log.warn('An unexpected warning was encountered. Please report it as a bug:');
|
||||
log.warn(deprecationInfo);
|
||||
Log.warn('An unexpected warning was encountered. Please report it as a bug:');
|
||||
Log.warn(deprecationInfo);
|
||||
}
|
||||
}
|
||||
|
||||
export function printUserError(error: BuildError): void {
|
||||
Log.error(error.message);
|
||||
if (error.docsUrl) {
|
||||
Log.error(learnMore(error.docsUrl, { dim: false }));
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,24 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import tar from 'tar';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import log from '../../log';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import {
|
||||
doesGitRepoExistAsync,
|
||||
getGitDiffOutputAsync,
|
||||
gitDiffAsync,
|
||||
gitRootDirectoryAsync,
|
||||
gitStatusAsync,
|
||||
isGitInstalledAsync,
|
||||
} from '../../utils/git';
|
||||
import { formatBytes } from '../../utils/files';
|
||||
import { getTmpDirectory } from '../../utils/paths';
|
||||
import { endTimer, formatMilliseconds, startTimer } from '../../utils/timer';
|
||||
import vcs from '../../vcs';
|
||||
|
||||
async function ensureGitRepoExistsAsync(): Promise<void> {
|
||||
if (!(await isGitInstalledAsync())) {
|
||||
throw new Error('git command not found, install it before proceeding');
|
||||
}
|
||||
|
||||
if (await doesGitRepoExistAsync()) {
|
||||
export async function maybeBailOnRepoStatusAsync(): Promise<void> {
|
||||
if (!(await vcs.hasUncommittedChangesAsync())) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.warn("It looks like you haven't initialized the git repository yet.");
|
||||
log.warn('EAS Build requires you to use a git repository for your project.');
|
||||
|
||||
const confirmInit = await confirmAsync({
|
||||
message: `Would you like to run 'git init' in the current directory?`,
|
||||
});
|
||||
if (!confirmInit) {
|
||||
throw new Error(
|
||||
'A git repository is required for building your project. Initialize it and run this command again.'
|
||||
);
|
||||
}
|
||||
await spawnAsync('git', ['init']);
|
||||
|
||||
log("We're going to make an initial commit for you repository.");
|
||||
|
||||
await spawnAsync('git', ['add', '-A']);
|
||||
await commitPromptAsync('Initial commit');
|
||||
}
|
||||
|
||||
async function isGitStatusCleanAsync(): Promise<boolean> {
|
||||
const changes = await gitStatusAsync();
|
||||
return changes.length === 0;
|
||||
}
|
||||
|
||||
async function maybeBailOnGitStatusAsync(): Promise<void> {
|
||||
if (await isGitStatusCleanAsync()) {
|
||||
return;
|
||||
}
|
||||
log.addNewLineIfNone();
|
||||
log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
|
||||
log(
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(`${chalk.bold('Warning!')} Your repository working tree is dirty.`);
|
||||
Log.log(
|
||||
`It's recommended to ${chalk.bold(
|
||||
'commit all your changes before proceeding'
|
||||
)}, so you can revert the changes made by this command if necessary.`
|
||||
@@ -71,13 +32,13 @@ async function maybeBailOnGitStatusAsync(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void> {
|
||||
if (await isGitStatusCleanAsync()) {
|
||||
export async function ensureRepoIsCleanAsync(nonInteractive = false): Promise<void> {
|
||||
if (!(await vcs.hasUncommittedChangesAsync())) {
|
||||
return;
|
||||
}
|
||||
log.addNewLineIfNone();
|
||||
log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
|
||||
log(
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(`${chalk.bold('Warning!')} Your repository working tree is dirty.`);
|
||||
Log.log(
|
||||
`This operation needs to be run on a clean working tree, please ${chalk.bold(
|
||||
'commit all your changes before proceeding'
|
||||
)}.`
|
||||
@@ -89,16 +50,34 @@ async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void
|
||||
message: `Commit changes to git?`,
|
||||
});
|
||||
if (answer) {
|
||||
await commitPromptAsync();
|
||||
await commitPromptAsync({ commitAllFiles: true });
|
||||
} else {
|
||||
throw new Error('Please commit all changes. Aborting...');
|
||||
}
|
||||
}
|
||||
|
||||
async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
|
||||
export async function commitPromptAsync({
|
||||
initialCommitMessage,
|
||||
commitAllFiles,
|
||||
}: {
|
||||
initialCommitMessage?: string;
|
||||
commitAllFiles?: boolean;
|
||||
} = {}): Promise<void> {
|
||||
const { message } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'message',
|
||||
message: 'Commit message:',
|
||||
initial: initialCommitMessage,
|
||||
validate: (input: string) => input !== '',
|
||||
});
|
||||
await vcs.commitAsync({ commitAllFiles, commitMessage: message });
|
||||
}
|
||||
|
||||
export async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
|
||||
const spinner = ora('Compressing project files');
|
||||
|
||||
await fs.mkdirp(getTmpDirectory());
|
||||
const shallowClonePath = path.join(getTmpDirectory(), `${uuidv4()}-shallow-clone`);
|
||||
const tarPath = path.join(getTmpDirectory(), `${uuidv4()}.tar.gz`);
|
||||
|
||||
// If the compression takes longer then a second, show the spinner.
|
||||
@@ -109,58 +88,36 @@ async function makeProjectTarballAsync(): Promise<{ path: string; size: number }
|
||||
() => {
|
||||
spinner.start();
|
||||
},
|
||||
log.isDebug ? 1 : 1000
|
||||
Log.isDebug ? 1 : 1000
|
||||
);
|
||||
// TODO: Possibly warn after more time about unoptimized assets.
|
||||
const compressTimerLabel = 'makeProjectTarballAsync';
|
||||
startTimer(compressTimerLabel);
|
||||
|
||||
try {
|
||||
await spawnAsync(
|
||||
'git',
|
||||
['archive', '--format=tar.gz', '--prefix', 'project/', '-o', tarPath, 'HEAD'],
|
||||
{ cwd: await gitRootDirectoryAsync() }
|
||||
);
|
||||
} finally {
|
||||
// Stop the timer
|
||||
await vcs.makeShallowCopyAsync(shallowClonePath);
|
||||
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
|
||||
'.',
|
||||
]);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
|
||||
const duration = endTimer(compressTimerLabel);
|
||||
if (spinner.isSpinning) {
|
||||
const prettyTime = formatMilliseconds(duration);
|
||||
spinner.succeed(`Compressed project files ${chalk.dim(prettyTime)}`);
|
||||
spinner.fail();
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await fs.remove(shallowClonePath);
|
||||
}
|
||||
clearTimeout(timer);
|
||||
|
||||
const { size } = await fs.stat(tarPath);
|
||||
const duration = endTimer(compressTimerLabel);
|
||||
if (spinner.isSpinning) {
|
||||
const prettyTime = formatMilliseconds(duration);
|
||||
spinner.succeed(
|
||||
`Compressed project files ${chalk.dim(`${prettyTime} (${formatBytes(size)})`)}`
|
||||
);
|
||||
}
|
||||
|
||||
return { size, path: tarPath };
|
||||
}
|
||||
|
||||
async function showDiffAsync() {
|
||||
const outputTooLarge = (await getGitDiffOutputAsync()).split(/\r\n|\r|\n/).length > 100;
|
||||
await gitDiffAsync({ withPager: outputTooLarge });
|
||||
}
|
||||
|
||||
async function commitPromptAsync(initialCommitMessage?: string): Promise<void> {
|
||||
const { message } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'message',
|
||||
message: 'Commit message:',
|
||||
initial: initialCommitMessage,
|
||||
validate: (input: string) => input !== '',
|
||||
});
|
||||
|
||||
// Add changed files only
|
||||
await spawnAsync('git', ['add', '-u']);
|
||||
await spawnAsync('git', ['commit', '-m', message]);
|
||||
}
|
||||
|
||||
export {
|
||||
isGitStatusCleanAsync,
|
||||
showDiffAsync,
|
||||
commitPromptAsync,
|
||||
ensureGitRepoExistsAsync,
|
||||
ensureGitStatusIsCleanAsync,
|
||||
maybeBailOnGitStatusAsync,
|
||||
makeProjectTarballAsync,
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getExpoWebsiteBaseUrl } from '../../api';
|
||||
import assert from 'assert';
|
||||
|
||||
import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../../api';
|
||||
import { AppPlatform, BuildFragment } from '../../graphql/generated';
|
||||
|
||||
export function getBuildLogsUrl({
|
||||
buildId,
|
||||
@@ -13,3 +16,15 @@ export function getBuildLogsUrl({
|
||||
export function getArtifactUrl(artifactId: string): string {
|
||||
return `${getExpoWebsiteBaseUrl()}/artifacts/${artifactId}`;
|
||||
}
|
||||
|
||||
export function getInternalDistributionInstallUrl(build: BuildFragment): string {
|
||||
if (build.platform === AppPlatform.Ios) {
|
||||
return `itms-services://?action=download-manifest;url=${getExpoApiBaseUrl()}/--/api/v2/projects/${
|
||||
build.project.id
|
||||
}/builds/${build.id}/manifest.plist`;
|
||||
}
|
||||
|
||||
assert(build.artifacts?.buildUrl, 'buildUrl is missing');
|
||||
|
||||
return build.artifacts.buildUrl;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import {
|
||||
AnalyticsEvent,
|
||||
flushAsync as flushAnalyticsAsync,
|
||||
initAsync as initAnalyticsAsync,
|
||||
logEvent,
|
||||
} from '../analytics';
|
||||
import { getUserAsync } from '../user/User';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
|
||||
export default abstract class EasCommand extends Command {
|
||||
/**
|
||||
* When user data is unavailable locally, determines if the command will
|
||||
* force the user to log in
|
||||
*/
|
||||
protected requiresAuthentication = true;
|
||||
|
||||
async init() {
|
||||
await initAnalyticsAsync();
|
||||
|
||||
if (this.requiresAuthentication) {
|
||||
await ensureLoggedInAsync();
|
||||
} else {
|
||||
await getUserAsync();
|
||||
}
|
||||
logEvent(AnalyticsEvent.ACTION, {
|
||||
// id is assigned by oclif in constructor based on the filepath:
|
||||
// commands/submit === submit, commands/build/list === build:list
|
||||
action: `eas ${this.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
async finally(err: Error) {
|
||||
await flushAnalyticsAsync();
|
||||
return super.finally(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { flushAsync, initAsync, logEvent } from '../../analytics';
|
||||
import { jester as mockJester } from '../../credentials/__tests__/fixtures-constants';
|
||||
import { getUserAsync } from '../../user/User';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import EasCommand from '../EasCommand';
|
||||
import TestEasCommand from './TestEasCommand';
|
||||
|
||||
describe(EasCommand.name, () => {
|
||||
beforeAll(() => {
|
||||
TestEasCommand.prototype.authValue = jest.fn().mockImplementation(() => true);
|
||||
jest.mock('../../user/User', () => ({ getUserAsync: jest.fn(() => mockJester) }));
|
||||
jest.mock('../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
|
||||
jest.mock('../../analytics', () => {
|
||||
const { AnalyticsEvent } = jest.requireActual('../../analytics');
|
||||
return {
|
||||
AnalyticsEvent,
|
||||
logEvent: jest.fn(),
|
||||
initAsync: jest.fn(),
|
||||
flushAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(ensureLoggedInAsync as jest.Mock).mockClear();
|
||||
(initAsync as jest.Mock).mockClear();
|
||||
(flushAsync as jest.Mock).mockClear();
|
||||
(logEvent as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('without exceptions', () => {
|
||||
it('ensures the user is logged in', async () => {
|
||||
await TestEasCommand.run();
|
||||
|
||||
expect(ensureLoggedInAsync).toHaveReturnedWith(mockJester);
|
||||
});
|
||||
|
||||
it('ensures the user data is read from cache', async () => {
|
||||
(TestEasCommand.prototype.authValue as jest.Mock).mockImplementationOnce(() => false);
|
||||
|
||||
await TestEasCommand.run();
|
||||
|
||||
expect(getUserAsync).toHaveReturnedWith(mockJester);
|
||||
});
|
||||
|
||||
it('initializes analytics', async () => {
|
||||
await TestEasCommand.run();
|
||||
|
||||
expect(initAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes analytics', async () => {
|
||||
await TestEasCommand.run();
|
||||
|
||||
expect(flushAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs events', async () => {
|
||||
await TestEasCommand.run();
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('action', {
|
||||
action: `eas ${TestEasCommand.id}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('after exceptions', () => {
|
||||
it('flushes analytics', async () => {
|
||||
try {
|
||||
await TestEasCommand.run().then(() => {
|
||||
throw new Error('foo');
|
||||
});
|
||||
} catch (error) {}
|
||||
|
||||
expect(flushAsync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import EasCommand from '../EasCommand';
|
||||
|
||||
export default class TestEasCommand extends EasCommand {
|
||||
requiresAuthentication = this.authValue();
|
||||
|
||||
authValue() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async run() {}
|
||||
}
|
||||
|
||||
TestEasCommand.id = 'TestEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import log from '../../log';
|
||||
import Log from '../../log';
|
||||
import { showLoginPromptAsync } from '../../user/actions';
|
||||
|
||||
export default class AccountLogin extends Command {
|
||||
@@ -9,8 +9,8 @@ export default class AccountLogin extends Command {
|
||||
static aliases = ['login'];
|
||||
|
||||
async run() {
|
||||
log('Log in to EAS');
|
||||
Log.log('Log in to EAS');
|
||||
await showLoginPromptAsync();
|
||||
log('Logged in');
|
||||
Log.log('Logged in');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import log from '../../log';
|
||||
import Log from '../../log';
|
||||
import { logoutAsync } from '../../user/User';
|
||||
|
||||
export default class AccountLogout extends Command {
|
||||
@@ -10,6 +10,6 @@ export default class AccountLogout extends Command {
|
||||
|
||||
async run() {
|
||||
await logoutAsync();
|
||||
log('Logged out');
|
||||
Log.log('Logged out');
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Command } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../log';
|
||||
import { getUserAsync } from '../../user/User';
|
||||
import { getActorDisplayName } from '../../user/actions';
|
||||
import Log from '../../log';
|
||||
import { getActorDisplayName, getUserAsync } from '../../user/User';
|
||||
|
||||
export default class AccountView extends Command {
|
||||
static description = 'show the username you are logged in as';
|
||||
@@ -13,9 +12,9 @@ export default class AccountView extends Command {
|
||||
async run() {
|
||||
const user = await getUserAsync();
|
||||
if (user) {
|
||||
log(chalk.green(getActorDisplayName(user)));
|
||||
Log.log(chalk.green(getActorDisplayName(user)));
|
||||
} else {
|
||||
log.warn('Not logged in');
|
||||
Log.warn('Not logged in');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import Log from '../log';
|
||||
import UserSettings from '../user/UserSettings';
|
||||
|
||||
export default class AnalyticsView extends Command {
|
||||
static description = 'View or change analytics settings';
|
||||
|
||||
static args = [{ name: 'STATUS', options: ['on', 'off'] }];
|
||||
|
||||
async run() {
|
||||
const { STATUS: status } = this.parse(AnalyticsView).args;
|
||||
if (status) {
|
||||
await UserSettings.setAsync('amplitudeEnabled', status === 'on');
|
||||
Log.withTick(`${status === 'on' ? 'Enabling' : 'Disabling'} analytics.`);
|
||||
} else {
|
||||
const amplitudeEnabled = await UserSettings.getAsync('amplitudeEnabled', null);
|
||||
Log.log(
|
||||
`Analytics are ${
|
||||
amplitudeEnabled === false ? 'disabled' : 'enabled'
|
||||
} on this eas-cli installation.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
CreateUpdateBranchForAppMutation,
|
||||
CreateUpdateBranchForAppMutationVariables,
|
||||
UpdateBranch,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import vcs from '../../vcs';
|
||||
|
||||
export async function createUpdateBranchOnAppAsync({
|
||||
appId,
|
||||
name,
|
||||
}: CreateUpdateBranchForAppMutationVariables): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
|
||||
const result = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
|
||||
gql`
|
||||
mutation createUpdateBranchForApp($appId: ID!, $name: String!) {
|
||||
updateBranch {
|
||||
createUpdateBranchForApp(appId: $appId, name: $name) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
name,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const newBranch = result.updateBranch.createUpdateBranchForApp;
|
||||
if (!newBranch) {
|
||||
throw new Error(`Could not create branch ${name}.`);
|
||||
}
|
||||
return newBranch;
|
||||
}
|
||||
|
||||
export default class BranchCreate extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Create a branch on the current project.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the branch to create',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: 'return a json with the new branch ID and name.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { name },
|
||||
flags,
|
||||
} = this.parse(BranchCreate);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const fullName = await getProjectFullNameAsync(exp);
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!name) {
|
||||
const validationMessage = 'Branch name may not be empty.';
|
||||
if (flags.json) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Please name the branch:',
|
||||
initial:
|
||||
(await vcs.getBranchNameAsync()) || `branch-${Math.random().toString(36).substr(2, 4)}`,
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const newBranch = await createUpdateBranchOnAppAsync({ appId: projectId, name });
|
||||
|
||||
if (flags.json) {
|
||||
Log.log(newBranch);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Created a new branch: ${chalk.bold(newBranch.name)} on project ${chalk.bold(fullName)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
DeleteUpdateBranchMutation,
|
||||
DeleteUpdateBranchMutationVariables,
|
||||
DeleteUpdateBranchResult,
|
||||
GetBranchInfoQuery,
|
||||
GetBranchInfoQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../prompts';
|
||||
|
||||
async function getBranchInfoAsync({
|
||||
appId,
|
||||
name,
|
||||
}: GetBranchInfoQueryVariables): Promise<GetBranchInfoQuery> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetBranchInfoQuery, GetBranchInfoQueryVariables>(
|
||||
gql`
|
||||
query GetBranchInfo($appId: String!, $name: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateBranchByName(name: $name) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
name,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function deleteBranchOnAppAsync({
|
||||
branchId,
|
||||
}: DeleteUpdateBranchMutationVariables): Promise<DeleteUpdateBranchResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeleteUpdateBranchMutation, DeleteUpdateBranchMutationVariables>(
|
||||
gql`
|
||||
mutation DeleteUpdateBranch($branchId: ID!) {
|
||||
updateBranch {
|
||||
deleteUpdateBranch(branchId: $branchId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
branchId,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateBranch.deleteUpdateBranch;
|
||||
}
|
||||
|
||||
export default class BranchDelete extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Republish an update group';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the branch to delete',
|
||||
},
|
||||
];
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: `return JSON with the edited branch's ID and name.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { name },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(BranchDelete);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const fullName = await getProjectFullNameAsync(exp);
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!name) {
|
||||
const validationMessage = 'branch name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Please enter the name of the branch to delete:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const data = await getBranchInfoAsync({ appId: projectId, name });
|
||||
const branchId = data.app?.byId.updateBranchByName?.id;
|
||||
if (!branchId) {
|
||||
throw new Error(`Could not find branch ${name} on ${fullName}`);
|
||||
}
|
||||
|
||||
if (!jsonFlag) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`You are about to permamently delete branch: "${name}" and all of the updates published on it.` +
|
||||
`\nThis action is irreversible.`
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
|
||||
if (!confirmed) {
|
||||
Log.error(`Cancelled deletion of branch: "${name}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const deletionResult = await deleteBranchOnAppAsync({
|
||||
branchId,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(deletionResult));
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Deleted branch "${name}" and all of its updates on project ${chalk.bold(fullName)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import CliTable from 'cli-table3';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { BranchesByAppQuery, BranchesByAppQueryVariables } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
|
||||
import { UPDATE_COLUMNS, formatUpdate, getPlatformsForGroup } from '../../update/utils';
|
||||
|
||||
const BRANCHES_LIMIT = 10_000;
|
||||
|
||||
export async function listBranchesAsync({ projectId }: { projectId: string }) {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<BranchesByAppQuery, BranchesByAppQueryVariables>(
|
||||
gql`
|
||||
query BranchesByAppQuery($appId: String!, $limit: Int!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateBranches(offset: 0, limit: $limit) {
|
||||
id
|
||||
name
|
||||
updates(offset: 0, limit: 10) {
|
||||
id
|
||||
actor {
|
||||
__typename
|
||||
id
|
||||
... on User {
|
||||
username
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
createdAt
|
||||
message
|
||||
runtimeVersion
|
||||
group
|
||||
platform
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId: projectId,
|
||||
limit: BRANCHES_LIMIT,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data?.app?.byId.updateBranches ?? [];
|
||||
}
|
||||
|
||||
export default class BranchList extends Command {
|
||||
static hidden = true;
|
||||
|
||||
static description = 'List all branches on this project.';
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: 'return output as JSON',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { flags } = this.parse(BranchList);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const branches = await listBranchesAsync({ projectId });
|
||||
if (flags.json) {
|
||||
Log.log(JSON.stringify(branches, null, 2));
|
||||
} else {
|
||||
const table = new CliTable({
|
||||
head: ['branch', ...UPDATE_COLUMNS],
|
||||
wordWrap: true,
|
||||
});
|
||||
|
||||
table.push(
|
||||
...branches.map(branch => [
|
||||
branch.name,
|
||||
formatUpdate(branch.updates[0]),
|
||||
branch.updates[0]?.runtimeVersion ?? 'N/A',
|
||||
branch.updates[0]?.group ?? 'N/A',
|
||||
getPlatformsForGroup({
|
||||
updates: branch.updates,
|
||||
group: branch.updates[0]?.group,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Branches with their most recent update group:'));
|
||||
Log.log(table.toString());
|
||||
if (branches.length >= BRANCHES_LIMIT) {
|
||||
Log.warn(`Showing first ${BRANCHES_LIMIT} branches, some results might be omitted.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { getConfig, getDefaultTarget } from '@expo/config';
|
||||
import { getRuntimeVersionForSDKVersion } from '@expo/sdk-runtime-versions';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import dateFormat from 'dateformat';
|
||||
import gql from 'graphql-tag';
|
||||
import { uniqBy } from 'lodash';
|
||||
import ora from 'ora';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
Actor,
|
||||
GetUpdateGroupAsyncQuery,
|
||||
RootQueryUpdatesByGroupArgs,
|
||||
Update,
|
||||
UpdateInfoGroup,
|
||||
} from '../../graphql/generated';
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
|
||||
import {
|
||||
PublishPlatform,
|
||||
buildBundlesAsync,
|
||||
buildUpdateInfoGroupAsync,
|
||||
collectAssets,
|
||||
uploadAssetsAsync,
|
||||
} from '../../project/publish';
|
||||
import { promptAsync, selectAsync } from '../../prompts';
|
||||
import { formatUpdate } from '../../update/utils';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import vcs from '../../vcs';
|
||||
import { listBranchesAsync } from './list';
|
||||
import { viewUpdateBranchAsync } from './view';
|
||||
|
||||
export const defaultPublishPlatforms: PublishPlatform[] = ['android', 'ios'];
|
||||
type PlatformFlag = PublishPlatform | 'all';
|
||||
|
||||
async function getUpdateGroupAsync({
|
||||
group,
|
||||
}: RootQueryUpdatesByGroupArgs): Promise<GetUpdateGroupAsyncQuery['updatesByGroup']> {
|
||||
const { updatesByGroup } = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetUpdateGroupAsyncQuery, RootQueryUpdatesByGroupArgs>(
|
||||
gql`
|
||||
query getUpdateGroupAsync($group: ID!) {
|
||||
updatesByGroup(group: $group) {
|
||||
id
|
||||
group
|
||||
runtimeVersion
|
||||
manifestFragment
|
||||
platform
|
||||
message
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
group,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return updatesByGroup;
|
||||
}
|
||||
|
||||
export default class BranchPublish extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Publish an update group to a branch.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'name',
|
||||
description: 'Name of the branch to publish on',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
message: flags.string({
|
||||
description: 'Short message describing the updates.',
|
||||
required: false,
|
||||
}),
|
||||
republish: flags.boolean({
|
||||
description: 'republish an update group',
|
||||
exclusive: ['input-dir', 'skip-bundler'],
|
||||
}),
|
||||
group: flags.string({
|
||||
description: 'update group to republish',
|
||||
exclusive: ['input-dir', 'skip-bundler'],
|
||||
}),
|
||||
'input-dir': flags.string({
|
||||
description: 'location of the bundle',
|
||||
default: 'dist',
|
||||
required: false,
|
||||
}),
|
||||
'skip-bundler': flags.boolean({
|
||||
description: `skip running Expo CLI to bundle the app before publishing`,
|
||||
default: false,
|
||||
}),
|
||||
platform: flags.enum({
|
||||
char: 'p',
|
||||
description: `Only publish to a single platform`,
|
||||
options: [...defaultPublishPlatforms, 'all'],
|
||||
default: 'all',
|
||||
required: false,
|
||||
}),
|
||||
json: flags.boolean({
|
||||
description: `return a json with the new update group.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { name },
|
||||
flags: {
|
||||
json: jsonFlag,
|
||||
message,
|
||||
republish,
|
||||
group,
|
||||
'input-dir': inputDir,
|
||||
'skip-bundler': skipBundler,
|
||||
},
|
||||
} = this.parse(BranchPublish);
|
||||
const platformFlag = this.parse(BranchPublish).flags.platform as PlatformFlag;
|
||||
// If a group was specified, that means we are republishing it.
|
||||
republish = group ? true : republish;
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
let { runtimeVersion, sdkVersion } = exp;
|
||||
|
||||
// When a SDK version is supplied instead of a runtime version and we're in the managed workflow
|
||||
// construct the runtimeVersion with special meaning indicating that the runtime is an
|
||||
// Expo SDK preset runtime that can be launched in Expo Go.
|
||||
const isManagedProject = getDefaultTarget(projectDir) === 'managed';
|
||||
if (!runtimeVersion && sdkVersion && isManagedProject) {
|
||||
Log.withTick('Generating runtime version from sdk version');
|
||||
runtimeVersion = getRuntimeVersionForSDKVersion(sdkVersion);
|
||||
}
|
||||
|
||||
if (!runtimeVersion) {
|
||||
throw new Error(
|
||||
"Couldn't find 'runtimeVersion'. Please specify it under the 'expo' key in 'app.json'"
|
||||
);
|
||||
}
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!name) {
|
||||
const validationMessage = 'branch name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
|
||||
const branches = await listBranchesAsync({ projectId });
|
||||
name = await selectAsync<string>(
|
||||
'which branch would you like to publish on?',
|
||||
branches.map(branch => {
|
||||
return {
|
||||
title: `${branch.name} ${chalk.grey(
|
||||
`- current update: ${formatUpdate(branch.updates[0])}`
|
||||
)}`,
|
||||
value: branch.name,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
assert(name, 'branch name must be specified.');
|
||||
|
||||
const { id: branchId, updates } = await viewUpdateBranchAsync({
|
||||
appId: projectId,
|
||||
name,
|
||||
});
|
||||
|
||||
let updateInfoGroup: UpdateInfoGroup = {};
|
||||
let oldMessage: string, oldRuntimeVersion: string;
|
||||
if (republish) {
|
||||
// If we are republishing, we don't need to worry about building the bundle or uploading the assets.
|
||||
// Instead we get the `updateInfoGroup` from the update we wish to republish.
|
||||
let updatesToRepublish: Pick<
|
||||
Update,
|
||||
'group' | 'message' | 'runtimeVersion' | 'manifestFragment' | 'platform'
|
||||
>[];
|
||||
if (group) {
|
||||
updatesToRepublish = await getUpdateGroupAsync({ group });
|
||||
} else {
|
||||
// Drop into interactive mode if the user has not specified an update group to republish.
|
||||
if (jsonFlag) {
|
||||
throw new Error('You must specify the update group to republish.');
|
||||
}
|
||||
|
||||
const updateGroups = uniqBy(updates, u => u.group)
|
||||
.filter(update => {
|
||||
// Only show groups that have updates on the specified platform(s).
|
||||
return platformFlag === 'all' || update.platform === platformFlag;
|
||||
})
|
||||
.map(update => ({
|
||||
title: formatUpdateTitle(update),
|
||||
value: update.group,
|
||||
}));
|
||||
if (updateGroups.length === 0) {
|
||||
throw new Error(
|
||||
`There are no updates on branch "${name}" published on the platform(s) ${platformFlag}. Did you mean to publish a new update instead?`
|
||||
);
|
||||
}
|
||||
|
||||
const selectedUpdateGroup = await selectAsync<string>(
|
||||
'which update would you like to republish?',
|
||||
updateGroups
|
||||
);
|
||||
updatesToRepublish = updates.filter(update => update.group === selectedUpdateGroup);
|
||||
}
|
||||
const updatesToRepublishFilteredByPlatform = updatesToRepublish.filter(
|
||||
// Only republish to the specified platforms
|
||||
update => platformFlag === 'all' || update.platform === platformFlag
|
||||
);
|
||||
if (updatesToRepublishFilteredByPlatform.length === 0) {
|
||||
throw new Error(
|
||||
`There are no updates on branch "${name}" published on the platform(s) "${platformFlag}" with group ID "${
|
||||
group ? group : updatesToRepublish[0].group
|
||||
}". Did you mean to publish a new update instead?`
|
||||
);
|
||||
}
|
||||
|
||||
let publicationPlatformMessage: string;
|
||||
if (platformFlag === 'all') {
|
||||
if (updatesToRepublishFilteredByPlatform.length !== defaultPublishPlatforms.length) {
|
||||
Log.warn(`You are republishing an update that wasn't published for all platforms.`);
|
||||
}
|
||||
publicationPlatformMessage = `The republished update will appear on the same plaforms it was originally published on: ${updatesToRepublishFilteredByPlatform
|
||||
.map(update => update.platform)
|
||||
.join(',')}`;
|
||||
} else {
|
||||
publicationPlatformMessage = `The republished update will appear only on: ${platformFlag}`;
|
||||
}
|
||||
Log.withTick(publicationPlatformMessage);
|
||||
|
||||
for (const update of updatesToRepublishFilteredByPlatform) {
|
||||
const { manifestFragment } = update;
|
||||
const platform = update.platform as PublishPlatform;
|
||||
|
||||
updateInfoGroup[platform] = JSON.parse(manifestFragment);
|
||||
}
|
||||
|
||||
// These are the same for each member of an update group
|
||||
group = updatesToRepublishFilteredByPlatform[0].group;
|
||||
oldMessage = updatesToRepublishFilteredByPlatform[0].message ?? '';
|
||||
oldRuntimeVersion = updatesToRepublishFilteredByPlatform[0].runtimeVersion;
|
||||
} else {
|
||||
// build bundle and upload assets for a new publish
|
||||
if (!skipBundler) {
|
||||
await buildBundlesAsync({ projectDir, inputDir });
|
||||
}
|
||||
|
||||
const assetSpinner = ora('Uploading assets...').start();
|
||||
try {
|
||||
const platforms = platformFlag === 'all' ? defaultPublishPlatforms : [platformFlag];
|
||||
const assets = collectAssets({ inputDir: inputDir!, platforms });
|
||||
await uploadAssetsAsync(assets);
|
||||
updateInfoGroup = await buildUpdateInfoGroupAsync(assets);
|
||||
assetSpinner.succeed('Uploaded assets!');
|
||||
} catch (e) {
|
||||
assetSpinner.fail('Failed to upload assets');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
const validationMessage = 'publish message may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ publishMessage: message } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'publishMessage',
|
||||
message: `Please enter a publication message.`,
|
||||
initial: republish
|
||||
? `Republish "${oldMessage!}" - group: ${group}`
|
||||
: (await vcs.getLastCommitMessageAsync())?.trim(),
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
let newUpdateGroup;
|
||||
const publishSpinner = ora('Publishing...').start();
|
||||
try {
|
||||
newUpdateGroup = await PublishMutation.publishUpdateGroupAsync({
|
||||
branchId,
|
||||
updateInfoGroup,
|
||||
runtimeVersion: republish ? oldRuntimeVersion! : runtimeVersion,
|
||||
message,
|
||||
});
|
||||
publishSpinner.succeed('Published!');
|
||||
} catch (e) {
|
||||
publishSpinner.fail('Failed to published updates');
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(newUpdateGroup));
|
||||
} else {
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'branch', value: name },
|
||||
{ label: 'runtime version', value: runtimeVersion },
|
||||
{ label: 'update group ID', value: newUpdateGroup.group },
|
||||
{ label: 'message', value: message },
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdateTitle(
|
||||
update: Pick<Update, 'message' | 'createdAt' | 'runtimeVersion'> & {
|
||||
actor?: Pick<Actor, 'firstName'> | null;
|
||||
}
|
||||
): string {
|
||||
const { message, createdAt, actor, runtimeVersion } = update;
|
||||
return `[${dateFormat(createdAt, 'mmm dd HH:MM')} by ${
|
||||
actor?.firstName ?? 'unknown'
|
||||
}, runtimeVersion: ${runtimeVersion}] ${message}`;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
EditUpdateBranchInput,
|
||||
EditUpdateBranchMutation,
|
||||
EditUpdateBranchMutationVariables,
|
||||
UpdateBranch,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
async function renameUpdateBranchOnAppAsync({
|
||||
appId,
|
||||
name,
|
||||
newName,
|
||||
}: EditUpdateBranchInput): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<EditUpdateBranchMutation, EditUpdateBranchMutationVariables>(
|
||||
gql`
|
||||
mutation EditUpdateBranch($input: EditUpdateBranchInput!) {
|
||||
updateBranch {
|
||||
editUpdateBranch(input: $input) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
input: {
|
||||
appId,
|
||||
name,
|
||||
newName,
|
||||
},
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateBranch.editUpdateBranch;
|
||||
}
|
||||
|
||||
export default class BranchRename extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Rename a branch.';
|
||||
|
||||
static flags = {
|
||||
from: flags.string({
|
||||
description: 'current name of the branch.',
|
||||
required: false,
|
||||
}),
|
||||
to: flags.string({
|
||||
description: 'new name of the branch.',
|
||||
required: false,
|
||||
}),
|
||||
json: flags.boolean({
|
||||
description: `return a json with the edited branch's ID and name.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
flags: { json: jsonFlag, from: currentName, to: newName },
|
||||
} = this.parse(BranchRename);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const fullName = await getProjectFullNameAsync(exp);
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!currentName) {
|
||||
const validationMessage = 'current name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ currentName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'currentName',
|
||||
message: 'Please enter the current name of the branch to rename:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!newName) {
|
||||
const validationMessage = 'new name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ newName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'newName',
|
||||
message: `Please rename ${currentName}`,
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const editedBranch = await renameUpdateBranchOnAppAsync({
|
||||
appId: projectId,
|
||||
name: currentName!,
|
||||
newName: newName!,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(editedBranch));
|
||||
return;
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Renamed branch from ${currentName} to ${chalk.bold(
|
||||
editedBranch.name
|
||||
)} on project ${chalk.bold(fullName)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import gql from 'graphql-tag';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
Maybe,
|
||||
Robot,
|
||||
Update,
|
||||
UpdateBranch,
|
||||
User,
|
||||
ViewBranchQuery,
|
||||
ViewBranchQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { UPDATE_COLUMNS, formatUpdate, getPlatformsForGroup } from '../../update/utils';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
|
||||
const PAGE_LIMIT = 10_000;
|
||||
|
||||
type TruncatedUpdate = Pick<Update, 'group' | 'message' | 'createdAt' | 'actor'>;
|
||||
|
||||
export async function viewUpdateBranchAsync({
|
||||
appId,
|
||||
name,
|
||||
}: Pick<ViewBranchQueryVariables, 'appId' | 'name'>): Promise<
|
||||
Pick<UpdateBranch, 'id' | 'name'> & {
|
||||
updates: (Pick<
|
||||
Update,
|
||||
'id' | 'group' | 'message' | 'createdAt' | 'runtimeVersion' | 'platform' | 'manifestFragment'
|
||||
> & {
|
||||
actor?: Maybe<Pick<User, 'firstName' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
|
||||
})[];
|
||||
}
|
||||
> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<ViewBranchQuery, ViewBranchQueryVariables>(
|
||||
gql`
|
||||
query ViewBranch($appId: String!, $name: String!, $limit: Int!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateBranchByName(name: $name) {
|
||||
id
|
||||
name
|
||||
updates(offset: 0, limit: $limit) {
|
||||
id
|
||||
group
|
||||
message
|
||||
createdAt
|
||||
runtimeVersion
|
||||
platform
|
||||
manifestFragment
|
||||
actor {
|
||||
id
|
||||
... on User {
|
||||
username
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
name,
|
||||
limit: PAGE_LIMIT,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const updateBranch = data.app?.byId.updateBranchByName;
|
||||
if (!updateBranch) {
|
||||
throw new Error(`Could not find branch "${name}"`);
|
||||
}
|
||||
return updateBranch;
|
||||
}
|
||||
|
||||
export default class BranchView extends Command {
|
||||
static hidden = true;
|
||||
static description = 'View a branch.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the branch to view',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: `return a json with the branch's ID name and recent update groups.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { name },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(BranchView);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!name) {
|
||||
const validationMessage = 'Branch name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Please enter the name of the branch to view:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const UpdateBranch = await viewUpdateBranchAsync({
|
||||
appId: projectId,
|
||||
name,
|
||||
});
|
||||
|
||||
const updates = Object.values(groupBy(UpdateBranch.updates, u => u.group)).map(
|
||||
group => group[0]
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify({ ...UpdateBranch, updates }));
|
||||
return;
|
||||
}
|
||||
|
||||
const groupTable = new Table({
|
||||
head: UPDATE_COLUMNS,
|
||||
wordWrap: true,
|
||||
});
|
||||
|
||||
for (const update of updates) {
|
||||
groupTable.push([
|
||||
formatUpdate(update),
|
||||
update.runtimeVersion,
|
||||
update.group,
|
||||
getPlatformsForGroup({
|
||||
updates: UpdateBranch.updates,
|
||||
group: update.group,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Branch:'));
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: UpdateBranch.name },
|
||||
{ label: 'ID', value: UpdateBranch.id },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Recently published update groups:'));
|
||||
Log.log(groupTable.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
import ora from 'ora';
|
||||
|
||||
import { appPlatformEmojis } from '../../build/constants';
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
Build,
|
||||
BuildStatus,
|
||||
CancelBuildMutation,
|
||||
CancelBuildMutationVariables,
|
||||
} from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { confirmAsync, selectAsync } from '../../prompts';
|
||||
|
||||
async function cancelBuildAsync(buildId: string): Promise<Pick<Build, 'id' | 'status'>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<CancelBuildMutation, CancelBuildMutationVariables>(
|
||||
gql`
|
||||
mutation CancelBuildMutation($buildId: ID!) {
|
||||
build(buildId: $buildId) {
|
||||
cancel {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ buildId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.build!.cancel;
|
||||
}
|
||||
|
||||
function formatUnfinishedBuild(
|
||||
build: Pick<Build, 'id' | 'platform' | 'status' | 'createdAt'>
|
||||
): string {
|
||||
const platform = appPlatformEmojis[build.platform];
|
||||
const startTime = new Date(build.createdAt).toLocaleString();
|
||||
let statusText: string;
|
||||
if (build.status === BuildStatus.New) {
|
||||
statusText = 'new';
|
||||
} else if (build.status === BuildStatus.InQueue) {
|
||||
statusText = 'in queue';
|
||||
} else {
|
||||
statusText = 'in progress';
|
||||
}
|
||||
const status = chalk.blue(statusText);
|
||||
return `${platform} Started at: ${startTime}, Status: ${status}, Id: ${build.id}`;
|
||||
}
|
||||
|
||||
async function selectBuildToCancelAsync(
|
||||
projectId: string,
|
||||
projectFullName: string
|
||||
): Promise<string | null> {
|
||||
const spinner = ora().start('Fetching the uncompleted builds…');
|
||||
let builds;
|
||||
try {
|
||||
const [newBuilds, inQueueBuilds, inProgressBuilds] = await Promise.all([
|
||||
BuildQuery.allForAppAsync(projectId, { status: BuildStatus.New }),
|
||||
BuildQuery.allForAppAsync(projectId, { status: BuildStatus.InQueue }),
|
||||
BuildQuery.allForAppAsync(projectId, { status: BuildStatus.InProgress }),
|
||||
]);
|
||||
spinner.stop();
|
||||
builds = [...newBuilds, ...inQueueBuilds, ...inProgressBuilds];
|
||||
} catch (error) {
|
||||
spinner.fail(
|
||||
`Something went wrong and we couldn't fetch the builds for the project ${projectFullName}.`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
if (builds.length === 0) {
|
||||
Log.warn(`There aren't any uncompleted builds for the project ${projectFullName}.`);
|
||||
return null;
|
||||
} else if (builds.length === 1) {
|
||||
Log.log('Found one build');
|
||||
Log.log(formatUnfinishedBuild(builds[0]));
|
||||
await confirmAsync({
|
||||
message: 'Do you want to cancel it?',
|
||||
});
|
||||
return builds[0].id;
|
||||
} else {
|
||||
const buildId = await selectAsync<string>(
|
||||
'Which build do you want to cancel?',
|
||||
builds.map(build => ({
|
||||
title: formatUnfinishedBuild(build),
|
||||
value: build.id,
|
||||
}))
|
||||
);
|
||||
return buildId;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBuildExistsAsync(buildId: string): Promise<void> {
|
||||
try {
|
||||
await BuildQuery.byIdAsync(buildId);
|
||||
} catch (err) {
|
||||
throw new Error(`Couldn't find a build matching the id ${buildId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default class BuildCancel extends Command {
|
||||
static description = 'Cancel a build.';
|
||||
|
||||
static args = [{ name: 'BUILD_ID' }];
|
||||
|
||||
async run() {
|
||||
const { BUILD_ID: buildIdFromArg } = this.parse(BuildCancel).args;
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectFullName = await getProjectFullNameAsync(exp);
|
||||
|
||||
if (buildIdFromArg) {
|
||||
await ensureBuildExistsAsync(buildIdFromArg);
|
||||
}
|
||||
|
||||
const buildId = buildIdFromArg || (await selectBuildToCancelAsync(projectId, projectFullName));
|
||||
if (!buildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = ora().start('Canceling the build…');
|
||||
try {
|
||||
const { status } = await cancelBuildAsync(buildId);
|
||||
if (status === BuildStatus.Canceled) {
|
||||
spinner.succeed('Build canceled');
|
||||
} else {
|
||||
spinner.text = 'Build is already completed';
|
||||
spinner.stopAndPersist();
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(`Something went wrong and we couldn't cancel your build ${buildId}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import chalk from 'chalk';
|
||||
|
||||
import { configureAsync } from '../../build/configure';
|
||||
import { RequestedPlatform } from '../../build/types';
|
||||
import log, { learnMore } from '../../log';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { findProjectRootAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
@@ -17,39 +17,25 @@ export default class BuildConfigure extends Command {
|
||||
char: 'p',
|
||||
options: ['android', 'ios', 'all'],
|
||||
}),
|
||||
'allow-experimental': flags.boolean({
|
||||
description: 'Enable experimental configuration steps.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { flags } = this.parse(BuildConfigure);
|
||||
log(
|
||||
Log.log(
|
||||
'💡 The following process will configure your iOS and/or Android project to be compatible with EAS Build. These changes only apply to your local project files and you can safely revert them at any time.'
|
||||
);
|
||||
log.newLine();
|
||||
Log.newLine();
|
||||
|
||||
const platform =
|
||||
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
|
||||
const allowExperimental = flags['allow-experimental'];
|
||||
|
||||
if (allowExperimental) {
|
||||
log.warn(
|
||||
`Project configuration will execute some additional steps that might fail if structure of your native project is significantly different from ${chalk.bold(
|
||||
'expo eject'
|
||||
)} or ${chalk.bold('expo init')}`
|
||||
);
|
||||
}
|
||||
|
||||
await ensureLoggedInAsync();
|
||||
await configureAsync({
|
||||
platform,
|
||||
allowExperimental,
|
||||
projectDir: (await findProjectRootAsync()) ?? process.cwd(),
|
||||
});
|
||||
|
||||
log.newLine();
|
||||
Log.newLine();
|
||||
logSuccess(platform);
|
||||
}
|
||||
}
|
||||
@@ -66,14 +52,13 @@ function logSuccess(platform: RequestedPlatform) {
|
||||
storesText = 'the Apple App Store';
|
||||
}
|
||||
|
||||
log(`🎉 Your ${platformsText} ready to build.
|
||||
Log.log(`🎉 Your ${platformsText} ready to build.
|
||||
|
||||
- Run ${chalk.bold('eas build')} when you are ready to create your first build.
|
||||
- Once the build is completed, run ${chalk.bold('eas submit')} to upload the app to ${storesText}
|
||||
- ${learnMore(
|
||||
'https://docs.expo.io/build/introduction',
|
||||
'Learn more about other capabilities of EAS Build'
|
||||
)}`);
|
||||
- ${learnMore('https://docs.expo.io/build/introduction', {
|
||||
learnMoreMessage: 'Learn more about other capabilities of EAS Build',
|
||||
})}`);
|
||||
}
|
||||
|
||||
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
@@ -88,7 +73,7 @@ async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
},
|
||||
{
|
||||
title: 'iOS',
|
||||
value: RequestedPlatform.iOS,
|
||||
value: RequestedPlatform.Ios,
|
||||
},
|
||||
{
|
||||
title: 'Android',
|
||||
|
||||
@@ -1,41 +1,32 @@
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import { ExpoConfig, getConfig } from '@expo/config';
|
||||
import { EasJsonReader } from '@expo/eas-json';
|
||||
import { flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import { configureAsync } from '../../build/configure';
|
||||
import { createCommandContextAsync } from '../../build/context';
|
||||
import { buildAsync } from '../../build/create';
|
||||
import { AnalyticsEvent, Build as BuildType, RequestedPlatform } from '../../build/types';
|
||||
import Analytics from '../../build/utils/analytics';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import { isGitStatusCleanAsync } from '../../build/utils/repository';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import log, { learnMore } from '../../log';
|
||||
import { RequestedPlatform } from '../../build/types';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import {
|
||||
isEasEnabledForProjectAsync,
|
||||
warnEasUnavailable,
|
||||
} from '../../project/isEasEnabledForProject';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import vcs from '../../vcs';
|
||||
|
||||
export default class Build extends Command {
|
||||
export default class Build extends EasCommand {
|
||||
static description = 'Start a build';
|
||||
|
||||
static flags = {
|
||||
platform: flags.enum({ char: 'p', options: ['android', 'ios', 'all'] }),
|
||||
'skip-credentials-check': flags.boolean({
|
||||
default: false,
|
||||
description: 'Skip validation of build credentials',
|
||||
hidden: true,
|
||||
}),
|
||||
'skip-project-configuration': flags.boolean({
|
||||
default: false,
|
||||
@@ -49,15 +40,23 @@ export default class Build extends Command {
|
||||
default: false,
|
||||
description: 'Run command in --non-interactive mode',
|
||||
}),
|
||||
local: flags.boolean({
|
||||
default: false,
|
||||
description: 'Run build locally [experimental]',
|
||||
}),
|
||||
wait: flags.boolean({
|
||||
default: true,
|
||||
allowNo: true,
|
||||
description: 'Wait for build(s) to complete',
|
||||
}),
|
||||
'clear-cache': flags.boolean({
|
||||
default: false,
|
||||
description: 'Clear cache before the build',
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = this.parse(Build);
|
||||
const user = await ensureLoggedInAsync();
|
||||
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
if (!flags.platform && nonInteractive) {
|
||||
@@ -66,104 +65,57 @@ export default class Build extends Command {
|
||||
const platform =
|
||||
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
if (flags['skip-credentials-check']) {
|
||||
Log.warnDeprecatedFlag(
|
||||
'skip-credentials-check',
|
||||
'Build credential validation is always skipped with the --non-interactive flag. You can also skip interactively.'
|
||||
);
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
let { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!flags.local && !(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
|
||||
accountName,
|
||||
platform,
|
||||
projectId,
|
||||
user,
|
||||
});
|
||||
if (accountHasPendingBuilds) {
|
||||
if (flags.local && !verifyOptionsForLocalBuilds(platform)) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureProjectConfiguredAsync(projectDir);
|
||||
|
||||
const trackingCtx = {
|
||||
tracking_id: uuidv4(),
|
||||
requested_platform: flags.platform,
|
||||
};
|
||||
Analytics.logEvent(AnalyticsEvent.BUILD_COMMAND, trackingCtx);
|
||||
exp = (await ensureProjectConfiguredAsync(projectDir, platform)) ?? exp;
|
||||
|
||||
const commandCtx = await createCommandContextAsync({
|
||||
requestedPlatform: platform,
|
||||
profile: flags.profile,
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
trackingCtx,
|
||||
nonInteractive,
|
||||
skipCredentialsCheck: flags['skip-credentials-check'],
|
||||
clearCache: flags['clear-cache'],
|
||||
local: flags.local,
|
||||
skipProjectConfiguration: flags['skip-project-configuration'],
|
||||
waitForBuildEnd: flags.wait,
|
||||
});
|
||||
|
||||
await buildAsync(commandCtx);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureNoPendingBuildsExistAsync({
|
||||
user,
|
||||
platform,
|
||||
accountName,
|
||||
projectId,
|
||||
}: {
|
||||
user: Actor;
|
||||
platform: RequestedPlatform;
|
||||
accountName: string;
|
||||
projectId: string;
|
||||
}): Promise<boolean> {
|
||||
// allow expo admins to run as many builds as they wish
|
||||
if (user.isExpoAdmin) {
|
||||
function verifyOptionsForLocalBuilds(platform: RequestedPlatform): boolean {
|
||||
if (platform === RequestedPlatform.All) {
|
||||
Log.error('Builds for multiple platforms are not supported with flag --local');
|
||||
return false;
|
||||
} else if (process.platform !== 'darwin' && platform === RequestedPlatform.Ios) {
|
||||
Log.error('Unsupported platform, macOS is required to build apps for iOS');
|
||||
return false;
|
||||
}
|
||||
|
||||
const appPlatforms = toAppPlatforms(platform);
|
||||
const maybePendingBuilds = await Promise.all(
|
||||
appPlatforms.map(appPlatform => BuildQuery.getPendingBuildIdAsync(accountName, appPlatform))
|
||||
);
|
||||
const pendingBuilds = maybePendingBuilds.filter(i => i !== null);
|
||||
if (pendingBuilds.length > 0) {
|
||||
log.newLine();
|
||||
log.error(
|
||||
'Your other builds are still pending. Wait for them to complete before running this command again.'
|
||||
);
|
||||
log.newLine();
|
||||
const results = await Promise.all(
|
||||
pendingBuilds.map(pendingBuild => {
|
||||
return apiClient.get<{ data: BuildType }>(
|
||||
`projects/${projectId}/builds/${pendingBuild?.id}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
log(formatBuild(result.body.data, { accountName }));
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toAppPlatforms(requestedPlatform: RequestedPlatform): AppPlatform[] {
|
||||
if (requestedPlatform === RequestedPlatform.All) {
|
||||
return [AppPlatform.Android, AppPlatform.Ios];
|
||||
} else if (requestedPlatform === RequestedPlatform.Android) {
|
||||
return [AppPlatform.Android];
|
||||
} else {
|
||||
return [AppPlatform.Ios];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +131,7 @@ async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
},
|
||||
{
|
||||
title: 'iOS',
|
||||
value: RequestedPlatform.iOS,
|
||||
value: RequestedPlatform.Ios,
|
||||
},
|
||||
{
|
||||
title: 'Android',
|
||||
@@ -190,24 +142,37 @@ async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
return platform;
|
||||
}
|
||||
|
||||
async function ensureProjectConfiguredAsync(projectDir: string): Promise<void> {
|
||||
if (await fs.pathExists(path.join(projectDir, 'eas.json'))) {
|
||||
return;
|
||||
async function ensureProjectConfiguredAsync(
|
||||
projectDir: string,
|
||||
platform: RequestedPlatform
|
||||
): Promise<ExpoConfig | null> {
|
||||
const platformsToConfigure = await getPlatformsToConfigureAsync(projectDir, platform);
|
||||
|
||||
if (!platformsToConfigure) {
|
||||
return null;
|
||||
}
|
||||
const confirm = await confirmAsync({
|
||||
message: "This app isn't setup for building with EAS. Set it up now?",
|
||||
});
|
||||
|
||||
// Ensure the prompt is consistent with the platforms we need to configure
|
||||
let message = 'This project is not configured to build with EAS. Set it up now?';
|
||||
if (platformsToConfigure === RequestedPlatform.Ios) {
|
||||
message = 'Your iOS project is not configured to build with EAS. Set it up now?';
|
||||
} else if (platformsToConfigure === RequestedPlatform.Android) {
|
||||
message = 'Your Android project is not configured to build with EAS. Set it up now?';
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({ message });
|
||||
if (confirm) {
|
||||
await configureAsync({
|
||||
projectDir,
|
||||
platform: RequestedPlatform.All,
|
||||
allowExperimental: false,
|
||||
platform: platformsToConfigure,
|
||||
});
|
||||
if (!(await isGitStatusCleanAsync())) {
|
||||
if (await vcs.hasUncommittedChangesAsync()) {
|
||||
throw new Error(
|
||||
'Build process requires clean git working tree, please commit all your changes and run `eas build` again'
|
||||
'Build process requires clean working tree, please commit all your changes and run `eas build` again'
|
||||
);
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
return exp;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Aborting, please run ${chalk.bold('eas build:configure')} or create eas.json (${learnMore(
|
||||
@@ -216,3 +181,30 @@ async function ensureProjectConfiguredAsync(projectDir: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getPlatformsToConfigureAsync(
|
||||
projectDir: string,
|
||||
platform: RequestedPlatform
|
||||
): Promise<RequestedPlatform | null> {
|
||||
if (!(await fs.pathExists(path.join(projectDir, 'eas.json')))) {
|
||||
return platform;
|
||||
}
|
||||
|
||||
const easConfig = await new EasJsonReader(projectDir, platform).readRawAsync();
|
||||
if (platform === RequestedPlatform.All) {
|
||||
if (easConfig.builds?.android && easConfig.builds?.ios) {
|
||||
return null;
|
||||
} else if (easConfig.builds?.ios) {
|
||||
return RequestedPlatform.Android;
|
||||
} else if (easConfig.builds?.android) {
|
||||
return RequestedPlatform.Ios;
|
||||
}
|
||||
} else if (
|
||||
(platform === RequestedPlatform.Android || platform === RequestedPlatform.Ios) &&
|
||||
easConfig.builds?.[platform]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return platform;
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import { Build } from '../../build/types';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import log from '../../log';
|
||||
import { BuildStatus, RequestedPlatform } from '../../build/types';
|
||||
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
|
||||
import { AppPlatform, BuildStatus as GraphQLBuildStatus } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
@@ -17,34 +18,46 @@ export default class BuildList extends Command {
|
||||
static description = 'list all builds for your project';
|
||||
|
||||
static flags = {
|
||||
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
|
||||
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished'] }),
|
||||
platform: flags.enum({
|
||||
options: [RequestedPlatform.All, RequestedPlatform.Android, RequestedPlatform.Ios],
|
||||
}),
|
||||
status: flags.enum({
|
||||
options: [
|
||||
BuildStatus.NEW,
|
||||
BuildStatus.IN_QUEUE,
|
||||
BuildStatus.IN_PROGRESS,
|
||||
BuildStatus.ERRORED,
|
||||
BuildStatus.FINISHED,
|
||||
BuildStatus.CANCELED,
|
||||
],
|
||||
}),
|
||||
limit: flags.integer(),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { platform, status, limit = 10 } = this.parse(BuildList).flags;
|
||||
const { platform: requestedPlatform, status: buildStatus, limit = 10 } = this.parse(
|
||||
BuildList
|
||||
).flags;
|
||||
|
||||
const platform = toAppPlatform(requestedPlatform);
|
||||
const graphqlBuildStatus = toGraphQLBuildStatus(buildStatus);
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const projectName = await getProjectFullNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectName = await getProjectFullNameAsync(exp);
|
||||
|
||||
const spinner = ora().start('Fetching the build list for the project…');
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<{ data: { builds: Build[] } }>(
|
||||
`projects/${projectId}/builds`,
|
||||
{
|
||||
searchParams: { platform, status, limit },
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
const { builds } = response.body.data;
|
||||
const builds = await BuildQuery.allForAppAsync(projectId, {
|
||||
limit,
|
||||
platform,
|
||||
status: graphqlBuildStatus,
|
||||
});
|
||||
|
||||
if (builds.length) {
|
||||
if (platform || status) {
|
||||
if (platform || graphqlBuildStatus) {
|
||||
spinner.succeed(
|
||||
`Showing ${builds.length} matching builds for the project ${projectName}`
|
||||
);
|
||||
@@ -53,10 +66,10 @@ export default class BuildList extends Command {
|
||||
}
|
||||
|
||||
const list = builds
|
||||
.map(build => formatBuild(build, { accountName }))
|
||||
.map(build => formatGraphQLBuild(build))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`);
|
||||
|
||||
log(`\n${list}`);
|
||||
Log.log(`\n${list}`);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
|
||||
}
|
||||
@@ -66,3 +79,31 @@ export default class BuildList extends Command {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toAppPlatform = (requestedPlatform?: RequestedPlatform): AppPlatform | undefined => {
|
||||
if (!requestedPlatform || requestedPlatform === RequestedPlatform.All) {
|
||||
return undefined;
|
||||
} else if (requestedPlatform === RequestedPlatform.Android) {
|
||||
return AppPlatform.Android;
|
||||
} else {
|
||||
return AppPlatform.Ios;
|
||||
}
|
||||
};
|
||||
|
||||
const toGraphQLBuildStatus = (buildStatus: BuildStatus): GraphQLBuildStatus | undefined => {
|
||||
if (!buildStatus) {
|
||||
return undefined;
|
||||
} else if (buildStatus === BuildStatus.NEW) {
|
||||
return GraphQLBuildStatus.New;
|
||||
} else if (buildStatus === BuildStatus.IN_QUEUE) {
|
||||
return GraphQLBuildStatus.InQueue;
|
||||
} else if (buildStatus === BuildStatus.IN_PROGRESS) {
|
||||
return GraphQLBuildStatus.InProgress;
|
||||
} else if (buildStatus === BuildStatus.ERRORED) {
|
||||
return GraphQLBuildStatus.Errored;
|
||||
} else if (buildStatus === BuildStatus.FINISHED) {
|
||||
return GraphQLBuildStatus.Finished;
|
||||
} else {
|
||||
return GraphQLBuildStatus.Canceled;
|
||||
}
|
||||
};
|
||||
@@ -1,14 +1,13 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import { HTTPError } from 'got';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import { Build } from '../../build/types';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import log from '../../log';
|
||||
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
|
||||
import { BuildFragment } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
@@ -16,40 +15,30 @@ import {
|
||||
export default class BuildView extends Command {
|
||||
static description = 'view a build for your project';
|
||||
|
||||
static args = [{ name: 'buildId' }];
|
||||
static args = [{ name: 'BUILD_ID' }];
|
||||
|
||||
async run() {
|
||||
const { buildId } = this.parse(BuildView).args;
|
||||
const { BUILD_ID: buildId } = this.parse(BuildView).args;
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const projectName = await getProjectFullNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectName = await getProjectFullNameAsync(exp);
|
||||
|
||||
const spinner = ora().start('Fetching the build…');
|
||||
|
||||
try {
|
||||
let build: Build;
|
||||
let build: BuildFragment;
|
||||
|
||||
if (buildId == null) {
|
||||
const response = await apiClient.get<{ data: { builds: Build[] } }>(
|
||||
`projects/${projectId}/builds`,
|
||||
{
|
||||
searchParams: { limit: 1 },
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
build = response.body.data.builds[0];
|
||||
if (buildId) {
|
||||
build = await BuildQuery.byIdAsync(buildId);
|
||||
} else {
|
||||
const response = await apiClient.get<{ data: Build }>(
|
||||
`projects/${projectId}/builds/${buildId}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
build = response.body.data;
|
||||
const builds = await BuildQuery.allForAppAsync(projectId, { limit: 1 });
|
||||
if (builds.length === 0) {
|
||||
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
|
||||
return;
|
||||
}
|
||||
build = builds[0];
|
||||
}
|
||||
|
||||
if (buildId) {
|
||||
@@ -58,29 +47,17 @@ export default class BuildView extends Command {
|
||||
spinner.succeed(`Showing the last build for the project ${projectName}`);
|
||||
}
|
||||
|
||||
log(`\n${formatBuild(build, { accountName })}`);
|
||||
} catch (e) {
|
||||
const error = e as HTTPError;
|
||||
|
||||
if (error.response.statusCode === 400) {
|
||||
if (buildId) {
|
||||
spinner.fail(
|
||||
`Couldn't find a build for the project ${projectName} matching the id ${buildId}`
|
||||
);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
|
||||
}
|
||||
Log.log(`\n${formatGraphQLBuild(build)}`);
|
||||
} catch (err) {
|
||||
if (buildId) {
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
|
||||
} else {
|
||||
if (buildId) {
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
|
||||
);
|
||||
}
|
||||
spinner.fail(
|
||||
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,65 +4,57 @@ import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { UpdateChannel } from '../../graphql/generated';
|
||||
import log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import {
|
||||
CreateUpdateChannelOnAppMutation,
|
||||
CreateUpdateChannelOnAppMutationVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getReleaseByNameAsync,
|
||||
getBranchByNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { createUpdateReleaseOnAppAsync } from '../release/create';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { createUpdateBranchOnAppAsync } from '../branch/create';
|
||||
|
||||
async function createUpdateChannelOnAppAsync({
|
||||
appId,
|
||||
channelName,
|
||||
releaseId,
|
||||
branchId,
|
||||
}: {
|
||||
appId: string;
|
||||
channelName: string;
|
||||
releaseId: string;
|
||||
}): Promise<UpdateChannel> {
|
||||
// Point the new channel at a release with its same name.
|
||||
const releaseMapping = JSON.stringify({
|
||||
data: [{ releaseId, releaseMappingLogic: 'true' }],
|
||||
branchId: string;
|
||||
}): Promise<CreateUpdateChannelOnAppMutation> {
|
||||
// Point the new channel at a branch with its same name.
|
||||
const branchMapping = JSON.stringify({
|
||||
data: [{ branchId, branchMappingLogic: 'true' }],
|
||||
version: 0,
|
||||
});
|
||||
const data = await withErrorHandlingAsync(
|
||||
return await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ updateChannel: { createUpdateChannelForApp: UpdateChannel } },
|
||||
{ appId: string; channelName: string; releaseMapping: string }
|
||||
>(
|
||||
.mutation<CreateUpdateChannelOnAppMutation, CreateUpdateChannelOnAppMutationVariables>(
|
||||
gql`
|
||||
mutation CreateUpdateChannelForApp(
|
||||
$appId: ID!
|
||||
$channelName: String!
|
||||
$releaseMapping: String!
|
||||
) {
|
||||
mutation CreateUpdateChannelOnApp($appId: ID!, $name: String!, $branchMapping: String!) {
|
||||
updateChannel {
|
||||
createUpdateChannelForApp(
|
||||
appId: $appId
|
||||
channelName: $channelName
|
||||
releaseMapping: $releaseMapping
|
||||
) {
|
||||
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
|
||||
id
|
||||
channelName
|
||||
releaseMapping
|
||||
name
|
||||
branchMapping
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
channelName,
|
||||
releaseMapping,
|
||||
name: channelName,
|
||||
branchMapping,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateChannel.createUpdateChannelForApp;
|
||||
}
|
||||
|
||||
export default class ChannelCreate extends Command {
|
||||
@@ -71,7 +63,7 @@ export default class ChannelCreate extends Command {
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'channelName',
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the channel to create',
|
||||
},
|
||||
@@ -80,14 +72,14 @@ export default class ChannelCreate extends Command {
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description:
|
||||
'print output as a JSON object with the new channel ID, name and release mapping.',
|
||||
'print output as a JSON object with the new channel ID, name and branch mapping.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { channelName },
|
||||
args: { name: channelName },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ChannelCreate);
|
||||
|
||||
@@ -95,61 +87,79 @@ export default class ChannelCreate extends Command {
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const {
|
||||
exp: { slug },
|
||||
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!channelName) {
|
||||
const validationMessage = 'Channel name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ channelName } = await promptAsync({
|
||||
({ name: channelName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'channelName',
|
||||
name: 'name',
|
||||
message: 'Please name the channel:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
let releaseId: string;
|
||||
let releaseMessage: string;
|
||||
let branchId: string;
|
||||
let branchMessage: string;
|
||||
try {
|
||||
const existingRelease = await getReleaseByNameAsync({
|
||||
const existingBranch = await getBranchByNameAsync({
|
||||
appId: projectId,
|
||||
releaseName: channelName,
|
||||
name: channelName,
|
||||
});
|
||||
releaseId = existingRelease.id;
|
||||
releaseMessage = `We found a release with the same name`;
|
||||
branchId = existingBranch.id;
|
||||
branchMessage = `We found a branch with the same name`;
|
||||
} catch (e) {
|
||||
const newRelease = await createUpdateReleaseOnAppAsync({
|
||||
const newBranch = await createUpdateBranchOnAppAsync({
|
||||
appId: projectId,
|
||||
releaseName: channelName,
|
||||
name: channelName,
|
||||
});
|
||||
releaseId = newRelease.id;
|
||||
releaseMessage = `We also went ahead and made a release with the same name`;
|
||||
branchId = newBranch.id;
|
||||
branchMessage = `We also went ahead and made a branch with the same name`;
|
||||
}
|
||||
|
||||
const newChannel = await createUpdateChannelOnAppAsync({
|
||||
const {
|
||||
updateChannel: { createUpdateChannelForApp: newChannel },
|
||||
} = await createUpdateChannelOnAppAsync({
|
||||
appId: projectId,
|
||||
channelName,
|
||||
releaseId,
|
||||
branchId,
|
||||
});
|
||||
|
||||
if (!newChannel) {
|
||||
throw new Error(
|
||||
`Could not create channel with name ${channelName} on app with id ${projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
log(newChannel);
|
||||
Log.log(JSON.stringify(newChannel));
|
||||
return;
|
||||
}
|
||||
|
||||
log.withTick(
|
||||
`️Created a new channel ${chalk.bold(newChannel.channelName)} on project ${chalk.bold(
|
||||
`@${accountName}/${slug}`
|
||||
)}. ${releaseMessage} and have pointed the channel at it. You can now update your app by publishing!`
|
||||
Log.addNewLineIfNone();
|
||||
Log.withTick(
|
||||
`Created a new channel on project ${chalk.bold(await getProjectFullNameAsync(exp))}`
|
||||
);
|
||||
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 },
|
||||
])
|
||||
);
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('You can now update your app by publishing!'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
GetChannelByNameToEditQuery,
|
||||
GetChannelByNameToEditQueryVariables,
|
||||
UpdateBranch,
|
||||
UpdateChannel,
|
||||
UpdateChannelBranchMappingMutation,
|
||||
UpdateChannelBranchMappingMutationVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getBranchByNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
async function getChannelByNameForAppAsync({
|
||||
appId,
|
||||
channelName,
|
||||
}: GetChannelByNameToEditQueryVariables): Promise<
|
||||
Pick<UpdateChannel, 'id' | 'name'> & {
|
||||
updateBranches: Pick<UpdateBranch, 'id' | 'name'>[];
|
||||
}
|
||||
> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetChannelByNameToEditQuery, GetChannelByNameToEditQueryVariables>(
|
||||
gql`
|
||||
query GetChannelByNameToEdit($appId: String!, $channelName: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateChannelByName(name: $channelName) {
|
||||
id
|
||||
name
|
||||
updateBranches(offset: 0, limit: 1) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, channelName }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const updateChannelByNameResult = data.app?.byId.updateChannelByName;
|
||||
if (!updateChannelByNameResult) {
|
||||
throw new Error(`Could not find a channel named ${channelName} on app with id ${appId}`);
|
||||
}
|
||||
return updateChannelByNameResult;
|
||||
}
|
||||
|
||||
export async function updateChannelBranchMappingAsync({
|
||||
channelId,
|
||||
branchMapping,
|
||||
}: UpdateChannelBranchMappingMutationVariables): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
}> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
|
||||
gql`
|
||||
mutation UpdateChannelBranchMapping($channelId: ID!, $branchMapping: String!) {
|
||||
updateChannel {
|
||||
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
|
||||
id
|
||||
name
|
||||
branchMapping
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ channelId, branchMapping }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const channel = data.updateChannel.editUpdateChannel;
|
||||
if (!channel) {
|
||||
throw new Error(`Could not fine channel with id ${channelId}`);
|
||||
}
|
||||
return data.updateChannel.editUpdateChannel!;
|
||||
}
|
||||
|
||||
export default class ChannelEdit extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Point a channel at a new branch.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the channel to edit',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
branch: flags.string({
|
||||
description: 'Name of the branch to point to',
|
||||
}),
|
||||
json: flags.boolean({
|
||||
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { name: channelName },
|
||||
flags: { branch: branchName, json: jsonFlag },
|
||||
} = this.parse(ChannelEdit);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!channelName) {
|
||||
const validationMessage = 'A channel name is required to edit a specific channel.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name: channelName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Please enter the name of the channel to edit:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const existingChannel = await getChannelByNameForAppAsync({ appId: projectId, channelName });
|
||||
if (existingChannel.updateBranches.length > 1) {
|
||||
throw new Error(
|
||||
'There is a rollout in progress. Please manage it with "channel:rollout" instead.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!branchName) {
|
||||
const validationMessage = 'branch name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name: branchName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: chalk`What branch should it change to?`,
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
|
||||
const channel = await updateChannelBranchMappingAsync({
|
||||
channelId: existingChannel.id,
|
||||
// todo: move branch mapping logic to utility
|
||||
branchMapping: JSON.stringify({
|
||||
data: [{ branchId: branch.id, branchMappingLogic: 'true' }],
|
||||
version: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(channel));
|
||||
return;
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
chalk`Channel {bold ${channel.name}} is now set to branch {bold ${branch.name}}.\n`
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
chalk`Users with builds on channel {bold ${channel.name}} will now receive the active update on {bold ${branch.name}}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
GetAllChannelsForAppQuery,
|
||||
GetAllChannelsForAppQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { logChannelDetails } from './view';
|
||||
|
||||
const CHANNEL_LIMIT = 10_000;
|
||||
|
||||
async function getAllUpdateChannelForAppAsync({
|
||||
appId,
|
||||
}: {
|
||||
appId: string;
|
||||
}): Promise<GetAllChannelsForAppQuery> {
|
||||
return await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetAllChannelsForAppQuery, GetAllChannelsForAppQueryVariables>(
|
||||
gql`
|
||||
query GetAllChannelsForApp($appId: String!, $offset: Int!, $limit: Int!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateChannels(offset: $offset, limit: $limit) {
|
||||
id
|
||||
name
|
||||
branchMapping
|
||||
updateBranches(offset: 0, limit: $limit) {
|
||||
id
|
||||
name
|
||||
updates(offset: 0, limit: 10) {
|
||||
id
|
||||
group
|
||||
message
|
||||
runtimeVersion
|
||||
createdAt
|
||||
platform
|
||||
actor {
|
||||
id
|
||||
... on User {
|
||||
username
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, offset: 0, limit: CHANNEL_LIMIT }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
}
|
||||
|
||||
export default class ChannelList extends Command {
|
||||
static hidden = true;
|
||||
static description = 'List all channels on the current project.';
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const {
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ChannelList);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
const getAllUpdateChannelForAppResult = await getAllUpdateChannelForAppAsync({
|
||||
appId: projectId,
|
||||
});
|
||||
const channels = getAllUpdateChannelForAppResult.app?.byId.updateChannels;
|
||||
if (!channels) {
|
||||
throw new Error(`Could not find channels on project with id ${projectId}`);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(channels));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const channel of channels) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: channel.name },
|
||||
{ label: 'ID', value: channel.id },
|
||||
])
|
||||
);
|
||||
logChannelDetails(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { GetChannelByNameForAppQuery, UpdateBranch } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getBranchByNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync, selectAsync } from '../../prompts';
|
||||
import { updateChannelBranchMappingAsync } from './edit';
|
||||
import { BranchMapping, getBranchMapping, getUpdateChannelByNameForAppAsync } from './view';
|
||||
|
||||
async function promptForRolloutPercentAsync({
|
||||
promptMessage,
|
||||
}: {
|
||||
promptMessage: string;
|
||||
}): Promise<number> {
|
||||
const { name: rolloutPercent } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
format: value => {
|
||||
return parseInt(value, 10);
|
||||
},
|
||||
message: promptMessage,
|
||||
initial: 0,
|
||||
validate: (rolloutPercent: string): true | string => {
|
||||
const floatValue = parseFloat(rolloutPercent);
|
||||
return Number.isInteger(floatValue) && floatValue >= 0 && floatValue <= 100
|
||||
? true
|
||||
: 'The rollout percentage must be an integer between 0 and 100 inclusive.';
|
||||
},
|
||||
});
|
||||
return rolloutPercent;
|
||||
}
|
||||
|
||||
function getRolloutInfo(
|
||||
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery
|
||||
): {
|
||||
newBranch: Pick<UpdateBranch, 'name' | 'id'>;
|
||||
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
|
||||
currentPercent: number;
|
||||
} {
|
||||
const { branchMapping } = getBranchMapping(
|
||||
getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.branchMapping
|
||||
);
|
||||
const [newBranchId, oldBranchId] = branchMapping.data.map(d => d.branchId);
|
||||
const newBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.updateBranches.filter(
|
||||
branch => branch.id === newBranchId
|
||||
)[0];
|
||||
const oldBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.updateBranches.filter(
|
||||
branch => branch.id === oldBranchId
|
||||
)[0];
|
||||
|
||||
if (!newBranch || !oldBranch) {
|
||||
throw new Error(
|
||||
`Branch mapping rollout is missing a branch for channel "${getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.name}".`
|
||||
);
|
||||
}
|
||||
|
||||
const currentPercent = 100 * branchMapping.data[0].branchMappingLogic.operand;
|
||||
return { newBranch, oldBranch, currentPercent };
|
||||
}
|
||||
|
||||
async function startRolloutAsync({
|
||||
channelName,
|
||||
branchName,
|
||||
percent,
|
||||
jsonFlag,
|
||||
projectId,
|
||||
fullName,
|
||||
currentBranchMapping,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
}: {
|
||||
channelName?: string;
|
||||
branchName?: string;
|
||||
percent?: number;
|
||||
jsonFlag: boolean;
|
||||
projectId: string;
|
||||
fullName: string;
|
||||
currentBranchMapping: BranchMapping;
|
||||
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery;
|
||||
}): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
if (!branchName) {
|
||||
const validationMessage = 'A branch must be specified.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name: branchName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: `Select a branch to rollout onto ${channelName}`,
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
|
||||
const oldBranchId = currentBranchMapping.data[0].branchId;
|
||||
if (branch.id === oldBranchId) {
|
||||
throw new Error(
|
||||
`channel "${channelName}" is already pointing at branch "${branchName}". Rollouts must be done with distinct branches.`
|
||||
);
|
||||
}
|
||||
|
||||
if (percent == null) {
|
||||
if (jsonFlag) {
|
||||
throw new Error(
|
||||
'You must specify a percent with the --percent flag when initiating a rollout with the --json flag.'
|
||||
);
|
||||
}
|
||||
const promptMessage = `What percent of users should be directed to the branch "${branchName}"?`;
|
||||
percent = await promptForRolloutPercentAsync({ promptMessage });
|
||||
}
|
||||
|
||||
const newBranchMapping = {
|
||||
version: 0,
|
||||
data: [
|
||||
{
|
||||
branchId: branch.id,
|
||||
branchMappingLogic: {
|
||||
operand: percent / 100,
|
||||
clientKey: 'rolloutToken',
|
||||
branchMappingOperator: 'hash_lt',
|
||||
},
|
||||
},
|
||||
currentBranchMapping.data[0],
|
||||
],
|
||||
};
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync({
|
||||
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.id!,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
|
||||
const oldBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.updateBranches.filter(
|
||||
branch => branch.id === oldBranchId
|
||||
)[0];
|
||||
if (!oldBranch) {
|
||||
throw new Error(
|
||||
`Branch mapping is missing its only branch for channel "${channelName}" on app "${fullName}"`
|
||||
);
|
||||
}
|
||||
|
||||
const logMessage = `️Started a rollout of branch ${chalk.bold(
|
||||
branchName
|
||||
)} onto channel ${chalk.bold(channelName!)}! ${chalk.bold(
|
||||
percent
|
||||
)}% of users will be directed to branch ${chalk.bold(branchName)}, ${chalk.bold(
|
||||
100 - percent
|
||||
)}% to branch ${chalk.bold(oldBranch.name)}.`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
|
||||
async function editRolloutAsync({
|
||||
channelName,
|
||||
percent,
|
||||
jsonFlag,
|
||||
currentBranchMapping,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
}: {
|
||||
channelName?: string;
|
||||
percent?: number;
|
||||
jsonFlag: boolean;
|
||||
currentBranchMapping: BranchMapping;
|
||||
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery;
|
||||
}): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(
|
||||
getUpdateChannelByNameForAppResult
|
||||
);
|
||||
|
||||
if (percent == null) {
|
||||
if (jsonFlag) {
|
||||
throw new Error(
|
||||
'A rollout is already in progress. If you wish to modify it you must use specify the new rollout percentage with the --percent flag.'
|
||||
);
|
||||
}
|
||||
const promptMessage = `Currently ${currentPercent}% of all users are routed to branch ${
|
||||
newBranch.name
|
||||
} and ${100 - currentPercent}% of all users are routed to branch ${
|
||||
oldBranch.name
|
||||
}. What percent of users should be directed to the branch ${newBranch.name}?`;
|
||||
percent = await promptForRolloutPercentAsync({ promptMessage });
|
||||
}
|
||||
|
||||
const newBranchMapping = { ...currentBranchMapping };
|
||||
newBranchMapping.data[0].branchMappingLogic.operand = percent / 100;
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync({
|
||||
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.id!,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
|
||||
const logMessage = `️Rollout of branch ${chalk.bold(newBranch.name)} onto channel ${chalk.bold(
|
||||
channelName!
|
||||
)} updated from ${chalk.bold(currentPercent)}% to ${chalk.bold(percent)}%. ${chalk.bold(
|
||||
percent
|
||||
)}% of users will be directed to branch ${chalk.bold(newBranch.name)}, ${chalk.bold(
|
||||
100 - percent
|
||||
)}% to branch ${chalk.bold(oldBranch.name)}.`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
|
||||
async function endRolloutAsync({
|
||||
channelName,
|
||||
branchName,
|
||||
jsonFlag,
|
||||
projectId,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
}: {
|
||||
channelName?: string;
|
||||
branchName?: string;
|
||||
jsonFlag: boolean;
|
||||
projectId: string;
|
||||
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery;
|
||||
}): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
// end rollout
|
||||
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(
|
||||
getUpdateChannelByNameForAppResult
|
||||
);
|
||||
|
||||
let endOnNewBranch;
|
||||
if (branchName) {
|
||||
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
|
||||
switch (branch.id) {
|
||||
case newBranch.id:
|
||||
endOnNewBranch = true;
|
||||
break;
|
||||
case oldBranch.id:
|
||||
endOnNewBranch = false;
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`The branch "${branchName}" specified by --branch must be one of the branches involved in the rollout: "${newBranch.name}" or "${oldBranch.name}".`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (jsonFlag) {
|
||||
throw new Error(
|
||||
'Branch name must be specified with the --branch flag when both the --end and --json flag are true.'
|
||||
);
|
||||
}
|
||||
endOnNewBranch = await selectAsync<boolean>(
|
||||
'Ending the rollout will send all traffic to a single branch. Which one should that be?',
|
||||
[
|
||||
{
|
||||
title: `${newBranch.name} ${chalk.grey(`- current percent: ${100 - currentPercent}%`)}`,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
title: `${oldBranch.name} ${chalk.grey(`- current percent: ${currentPercent}%`)}`,
|
||||
value: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
if (endOnNewBranch == null) {
|
||||
throw new Error('Branch to end on is undefined.');
|
||||
}
|
||||
|
||||
const newBranchMapping = {
|
||||
version: 0,
|
||||
data: [
|
||||
{
|
||||
branchId: endOnNewBranch ? newBranch.id : oldBranch.id,
|
||||
branchMappingLogic: 'true',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync({
|
||||
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.id!,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
const logMessage = `️Rollout on channel ${chalk.bold(
|
||||
channelName
|
||||
)} ended. All traffic is now sent to branch ${chalk.bold(
|
||||
endOnNewBranch ? newBranch.name : oldBranch.name
|
||||
)}`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
|
||||
export default class ChannelRollout extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Rollout a new branch out to a channel incrementally.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'channel',
|
||||
required: true,
|
||||
description: 'rollout that the channel is on',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
branch: flags.string({
|
||||
description: 'branch to rollout',
|
||||
required: false,
|
||||
}),
|
||||
percent: flags.integer({
|
||||
description: 'percent of traffic to redirect to the new branch',
|
||||
required: false,
|
||||
}),
|
||||
end: flags.boolean({
|
||||
description: 'end the rollout',
|
||||
default: false,
|
||||
}),
|
||||
json: flags.boolean({
|
||||
description: 'print output as a JSON object with the new channel ID, name and branch mapping',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const {
|
||||
args: { channel: channelName },
|
||||
flags: { json: jsonFlag, end: endFlag },
|
||||
} = this.parse(ChannelRollout);
|
||||
const {
|
||||
flags: { branch: branchName, percent },
|
||||
} = this.parse(ChannelRollout);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const fullName = await getProjectFullNameAsync(exp);
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
const getUpdateChannelByNameForAppResult = await getUpdateChannelByNameForAppAsync({
|
||||
appId: projectId,
|
||||
channelName: channelName!,
|
||||
});
|
||||
const { branchMapping: currentBranchMapping, isRollout } = getBranchMapping(
|
||||
getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName?.branchMapping
|
||||
);
|
||||
|
||||
if (currentBranchMapping.data.length === 0) {
|
||||
throw new Error('The channel is not pointing at any branches.');
|
||||
}
|
||||
if (currentBranchMapping.data.length > 2) {
|
||||
throw new Error('"channel:rollout" cannot handle branch mappings with more than 2 branches.');
|
||||
}
|
||||
// This combination doesn't make sense. Throw an error explaining the options.
|
||||
if (isRollout && branchName && !endFlag) {
|
||||
throw new Error(
|
||||
`There is a rollout in progress. You can only either edit the rollout percent or 'end' it.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This if/else block has three branches:
|
||||
* 1. The branch mapping is not a rollout, i.e. it is pointing to a single branch.
|
||||
* 2. The branch mapping is a rollout.
|
||||
* a. increase/decrease the rollout percentage.
|
||||
* b. end the rollout.
|
||||
*/
|
||||
let rolloutMutationResult: {
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
};
|
||||
if (!isRollout) {
|
||||
rolloutMutationResult = await startRolloutAsync({
|
||||
channelName,
|
||||
branchName,
|
||||
percent,
|
||||
jsonFlag,
|
||||
projectId,
|
||||
fullName,
|
||||
currentBranchMapping,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
});
|
||||
} else if (endFlag) {
|
||||
rolloutMutationResult = await endRolloutAsync({
|
||||
channelName,
|
||||
branchName,
|
||||
jsonFlag,
|
||||
projectId,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
});
|
||||
} else {
|
||||
rolloutMutationResult = await editRolloutAsync({
|
||||
channelName,
|
||||
percent,
|
||||
jsonFlag,
|
||||
currentBranchMapping,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
});
|
||||
}
|
||||
if (!rolloutMutationResult) {
|
||||
throw new Error('rollout result is empty');
|
||||
}
|
||||
const { newChannelInfo, logMessage } = rolloutMutationResult;
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(newChannelInfo));
|
||||
return;
|
||||
}
|
||||
Log.withTick(logMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
GetChannelByNameForAppQuery,
|
||||
GetChannelByNameForAppQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import {
|
||||
FormatUpdateParameter,
|
||||
UPDATE_COLUMNS,
|
||||
formatUpdate,
|
||||
getPlatformsForGroup,
|
||||
} from '../../update/utils';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
|
||||
export type BranchMapping = {
|
||||
version: number;
|
||||
data: {
|
||||
branchId: string;
|
||||
branchMappingLogic: {
|
||||
operand: number;
|
||||
clientKey: string;
|
||||
branchMappingOperator: string;
|
||||
} & string;
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the branch mapping and determine whether it is a rollout.
|
||||
* Ensure that the branch mapping is properly formatted.
|
||||
*/
|
||||
export function getBranchMapping(
|
||||
branchMappingString?: string
|
||||
): { branchMapping: BranchMapping; isRollout: boolean; rolloutPercent?: number } {
|
||||
if (!branchMappingString) {
|
||||
throw new Error('Missing branch mapping.');
|
||||
}
|
||||
let branchMapping: BranchMapping;
|
||||
try {
|
||||
branchMapping = JSON.parse(branchMappingString);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
|
||||
}
|
||||
assert(branchMapping, 'Branch Mapping must be defined.');
|
||||
|
||||
if (branchMapping.version !== 0) {
|
||||
throw new Error('Branch mapping must be version 0.');
|
||||
}
|
||||
|
||||
const isRollout = branchMapping.data.length === 2;
|
||||
const rolloutPercent = branchMapping.data[0].branchMappingLogic.operand;
|
||||
|
||||
switch (branchMapping.data.length) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
if (branchMapping.data[0].branchMappingLogic !== 'true') {
|
||||
throw new Error('Branch mapping logic for a single branch must be "true"');
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
|
||||
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
|
||||
}
|
||||
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
|
||||
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
|
||||
}
|
||||
if (rolloutPercent == null) {
|
||||
throw new Error('Branch mapping is missing a "rolloutPercent"');
|
||||
}
|
||||
if (branchMapping.data[1].branchMappingLogic !== 'true') {
|
||||
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error('Branch mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
return { branchMapping, isRollout, rolloutPercent };
|
||||
}
|
||||
|
||||
export async function getUpdateChannelByNameForAppAsync({
|
||||
appId,
|
||||
channelName,
|
||||
}: GetChannelByNameForAppQueryVariables): Promise<GetChannelByNameForAppQuery> {
|
||||
return await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetChannelByNameForAppQuery, GetChannelByNameForAppQueryVariables>(
|
||||
gql`
|
||||
query GetChannelByNameForApp($appId: String!, $channelName: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateChannelByName(name: $channelName) {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
branchMapping
|
||||
updateBranches(offset: 0, limit: 1000) {
|
||||
id
|
||||
name
|
||||
updates(offset: 0, limit: 10) {
|
||||
id
|
||||
group
|
||||
message
|
||||
runtimeVersion
|
||||
createdAt
|
||||
platform
|
||||
actor {
|
||||
id
|
||||
... on User {
|
||||
username
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, channelName }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
}
|
||||
|
||||
export function logChannelDetails(channel: {
|
||||
branchMapping: string;
|
||||
updateBranches: {
|
||||
updates: (FormatUpdateParameter & {
|
||||
runtimeVersion: string;
|
||||
group: string;
|
||||
platform: string;
|
||||
})[];
|
||||
name: string;
|
||||
id: string;
|
||||
}[];
|
||||
}): void {
|
||||
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
|
||||
|
||||
const table = new Table({
|
||||
head: ['branch', ...(isRollout ? ['rollout percent'] : []), ...UPDATE_COLUMNS],
|
||||
wordWrap: true,
|
||||
});
|
||||
|
||||
for (const index in branchMapping.data) {
|
||||
if (parseInt(index, 10) > 1) {
|
||||
throw new Error('Branch Mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
const { branchId } = branchMapping.data[index];
|
||||
const branch = channel.updateBranches.filter(branch => branch.id === branchId)[0];
|
||||
if (!branch) {
|
||||
throw new Error('Branch mapping is pointing at a missing branch.');
|
||||
}
|
||||
const update = branch.updates[0];
|
||||
table.push([
|
||||
branch.name,
|
||||
...(isRollout
|
||||
? [
|
||||
parseInt(index, 10) === 0
|
||||
? `${rolloutPercent! * 100}%`
|
||||
: `${(1 - rolloutPercent!) * 100}%`,
|
||||
]
|
||||
: []),
|
||||
formatUpdate(update),
|
||||
update?.runtimeVersion ?? 'N/A',
|
||||
update?.group ?? 'N/A,',
|
||||
getPlatformsForGroup({
|
||||
updates: branch.updates,
|
||||
group: branch.updates[0]?.group,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
Log.log(table.toString());
|
||||
}
|
||||
|
||||
export default class ChannelView extends Command {
|
||||
static hidden = true;
|
||||
static description = 'View a channel on the current project.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the channel to view',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { name: channelName },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ChannelView);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!channelName) {
|
||||
const validationMessage = 'A channel name is required to view a specific channel.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name: channelName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Please name the channel:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const getUpdateChannelByNameForAppresult = await getUpdateChannelByNameForAppAsync({
|
||||
appId: projectId,
|
||||
channelName,
|
||||
});
|
||||
const channel = getUpdateChannelByNameForAppresult.app?.byId.updateChannelByName;
|
||||
if (!channel) {
|
||||
throw new Error(`Could not fine channel with name ${channelName}`);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(channel));
|
||||
return;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Channel:'));
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: channel.name },
|
||||
{ label: 'ID', value: channel.id },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
chalk`{bold Branches, pointed at by this channel, and their most recent update group:}`
|
||||
);
|
||||
logChannelDetails(channel);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import { runStandaloneCredentialsManagerAsync } from '../credentials/CredentialsManager';
|
||||
import { createCredentialsContextAsync } from '../credentials/context';
|
||||
import { SelectPlatform } from '../credentials/manager/SelectPlatform';
|
||||
|
||||
@@ -9,6 +8,6 @@ export default class Credentials extends Command {
|
||||
|
||||
async run() {
|
||||
const ctx = await createCredentialsContextAsync(process.cwd(), {});
|
||||
await runStandaloneCredentialsManagerAsync(ctx, new SelectPlatform());
|
||||
await new SelectPlatform().runAsync(ctx);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
@@ -6,7 +7,7 @@ import ora from 'ora';
|
||||
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
|
||||
import { AppleTeamQuery } from '../../credentials/ios/api/graphql/queries/AppleTeamQuery';
|
||||
import formatDevice from '../../devices/utils/formatDevice';
|
||||
import log from '../../log';
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
@@ -21,7 +22,8 @@ export default class BuildList extends Command {
|
||||
let appleTeamIdentifier = this.parse(BuildList).flags['apple-team-id'];
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
let spinner: ora.Ora;
|
||||
|
||||
@@ -42,7 +44,9 @@ export default class BuildList extends Command {
|
||||
name: 'appleTeamIdentifier',
|
||||
message: 'What Apple Team would you like to list devices for?',
|
||||
choices: teams.map(team => ({
|
||||
title: `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`,
|
||||
title: team.appleTeamName
|
||||
? `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`
|
||||
: team.appleTeamIdentifier,
|
||||
value: team.appleTeamIdentifier,
|
||||
})),
|
||||
});
|
||||
@@ -71,7 +75,9 @@ export default class BuildList extends Command {
|
||||
if (result?.appleDevices.length) {
|
||||
const { appleTeamName, appleDevices } = result;
|
||||
|
||||
spinner.succeed(`Found ${appleDevices.length} devices for team ${appleTeamName}`);
|
||||
spinner.succeed(
|
||||
`Found ${appleDevices.length} devices for team ${appleTeamName ?? appleTeamIdentifier}`
|
||||
);
|
||||
|
||||
const list = appleDevices
|
||||
.map(device =>
|
||||
@@ -79,7 +85,7 @@ export default class BuildList extends Command {
|
||||
)
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`);
|
||||
|
||||
log(`\n${list}`);
|
||||
Log.log(`\n${list}`);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find any devices for the team ${appleTeamIdentifier}`);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import ora from 'ora';
|
||||
|
||||
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
|
||||
import formatDevice from '../../devices/utils/formatDevice';
|
||||
import log from '../../log';
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
|
||||
export default class DeviceView extends Command {
|
||||
@@ -15,7 +16,7 @@ export default class DeviceView extends Command {
|
||||
const { UDID } = this.parse(DeviceView).args;
|
||||
|
||||
if (!UDID) {
|
||||
log(
|
||||
Log.log(
|
||||
`The device UDID is required to view a specific device. For example:
|
||||
|
||||
eas device:view 00005787-000872430189501D
|
||||
@@ -29,7 +30,8 @@ If you are not sure what is the UDID of the device you are looking for, run:
|
||||
}
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
const spinner = ora().start(`Fetching device details for ${UDID}…`);
|
||||
|
||||
@@ -38,12 +40,11 @@ If you are not sure what is the UDID of the device you are looking for, run:
|
||||
|
||||
if (device) {
|
||||
spinner.succeed('Fetched device details');
|
||||
log(`\n${formatDevice(device, device.appleTeam)}`);
|
||||
Log.log(`\n${formatDevice(device, device.appleTeam)}`);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find a device with the UDID ${UDID}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the device with UDID ${UDID}`);
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { AppInfoQuery, AppInfoQueryVariables } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
|
||||
async function projectInfoByIdAsync(appId: string): Promise<AppInfoQuery> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppInfoQuery, AppInfoQueryVariables>(
|
||||
gql`
|
||||
query AppInfo($appId: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export default class ProjectInfo extends Command {
|
||||
static hidden = true;
|
||||
static description = 'information about the current project';
|
||||
|
||||
async run() {
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const { app } = await projectInfoByIdAsync(projectId);
|
||||
if (!app) {
|
||||
throw new Error(`Could not find project with ID: ${projectId}`);
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'ID', value: projectId },
|
||||
{ label: 'fullName', value: app.byId.fullName },
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, setProjectIdAsync } from '../../project/projectUtils';
|
||||
|
||||
export default class ProjectInit extends Command {
|
||||
static hidden = true;
|
||||
static description = 'create or link an EAS project';
|
||||
static aliases = ['init'];
|
||||
|
||||
async run() {
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
|
||||
if (exp.extra?.eas?.projectId) {
|
||||
Log.error(
|
||||
`app.json is already linked to project with ID: ${chalk.bold(exp.extra?.eas?.projectId)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setProjectIdAsync(projectDir);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { UpdateRelease } from '../../graphql/generated';
|
||||
import log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { getBranchNameAsync } from '../../utils/git';
|
||||
|
||||
export async function createUpdateReleaseOnAppAsync({
|
||||
appId,
|
||||
releaseName,
|
||||
}: {
|
||||
appId: string;
|
||||
releaseName: string;
|
||||
}): Promise<Pick<UpdateRelease, 'id' | 'releaseName'>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ updateRelease: { createUpdateReleaseForApp: Pick<UpdateRelease, 'id' | 'releaseName'> } },
|
||||
{ appId: string; releaseName: string }
|
||||
>(
|
||||
gql`
|
||||
mutation CreateUpdateReleaseForApp($appId: ID!, $releaseName: String!) {
|
||||
updateRelease {
|
||||
createUpdateReleaseForApp(appId: $appId, releaseName: $releaseName) {
|
||||
id
|
||||
releaseName
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
releaseName,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateRelease.createUpdateReleaseForApp;
|
||||
}
|
||||
|
||||
export default class ReleaseCreate extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Create a release on the current project.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'releaseName',
|
||||
required: false,
|
||||
description: 'Name of the release to create',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: 'return a json with the new release ID and name.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { releaseName },
|
||||
flags,
|
||||
} = this.parse(ReleaseCreate);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: exp.slug,
|
||||
});
|
||||
|
||||
if (!releaseName) {
|
||||
const validationMessage = 'Release name may not be empty.';
|
||||
if (flags.json) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ releaseName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'releaseName',
|
||||
message: 'Please name the release:',
|
||||
initial:
|
||||
(await getBranchNameAsync()) || `release-${Math.random().toString(36).substr(2, 4)}`,
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const newRelease = await createUpdateReleaseOnAppAsync({ appId: projectId, releaseName });
|
||||
|
||||
if (flags.json) {
|
||||
log(newRelease);
|
||||
return;
|
||||
}
|
||||
|
||||
log.withTick(
|
||||
`️Created a new release: ${chalk.bold(newRelease.releaseName)} on project ${chalk.bold(
|
||||
`@${accountName}/${exp.slug}`
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { EditUpdateReleaseInput, UpdateRelease } from '../../graphql/generated';
|
||||
import log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
async function renameUpdateReleaseOnAppAsync({
|
||||
appId,
|
||||
currentName,
|
||||
newName,
|
||||
}: {
|
||||
appId: string;
|
||||
currentName: string;
|
||||
newName: string;
|
||||
}): Promise<UpdateRelease> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ updateRelease: { editUpdateRelease: UpdateRelease } },
|
||||
{
|
||||
input: EditUpdateReleaseInput;
|
||||
}
|
||||
>(
|
||||
gql`
|
||||
mutation EditUpdateRelease($input: EditUpdateReleaseInput!) {
|
||||
updateRelease {
|
||||
editUpdateRelease(input: $input) {
|
||||
id
|
||||
releaseName
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
input: {
|
||||
appId,
|
||||
releaseName: currentName,
|
||||
newReleaseName: newName,
|
||||
},
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateRelease.editUpdateRelease;
|
||||
}
|
||||
|
||||
export default class ReleaseRename extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Rename a release.';
|
||||
|
||||
static flags = {
|
||||
from: flags.string({
|
||||
description: 'current name of the release.',
|
||||
required: false,
|
||||
}),
|
||||
to: flags.string({
|
||||
description: 'new name of the release.',
|
||||
required: false,
|
||||
}),
|
||||
json: flags.boolean({
|
||||
description: `return a json with the edited release's ID and name.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
flags: { json: jsonFlag, from: currentName, to: newName },
|
||||
} = this.parse(ReleaseRename);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = await getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: exp.slug,
|
||||
});
|
||||
|
||||
if (!currentName) {
|
||||
const validationMessage = 'current name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ currentName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'currentName',
|
||||
message: 'Please enter the current name of the release to rename:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!newName) {
|
||||
const validationMessage = 'new name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ newName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'newName',
|
||||
message: `Please rename ${currentName}`,
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const editedRelease = await renameUpdateReleaseOnAppAsync({
|
||||
appId: projectId,
|
||||
currentName: currentName!,
|
||||
newName: newName!,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
log(editedRelease);
|
||||
return;
|
||||
}
|
||||
|
||||
log.withTick(
|
||||
`️Renamed release from ${currentName} to ${chalk.bold(
|
||||
editedRelease.releaseName
|
||||
)} on project ${chalk.bold(`@${accountName}/${exp.slug}`)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import gql from 'graphql-tag';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { Update } from '../../graphql/generated';
|
||||
import log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
const PAGE_LIMIT = 10_000;
|
||||
|
||||
type TruncatedUpdate = Pick<Update, 'updateGroup' | 'updateMessage' | 'createdAt' | 'actor'>;
|
||||
|
||||
async function viewUpdateReleaseAsync({
|
||||
appId,
|
||||
releaseName,
|
||||
}: {
|
||||
appId: string;
|
||||
releaseName: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
releaseName: string;
|
||||
updates: TruncatedUpdate[];
|
||||
}> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{
|
||||
app: {
|
||||
byId: {
|
||||
updateReleaseByReleaseName: {
|
||||
id: string;
|
||||
releaseName: string;
|
||||
updates: TruncatedUpdate[];
|
||||
};
|
||||
};
|
||||
};
|
||||
},
|
||||
{
|
||||
appId: string;
|
||||
releaseName: string;
|
||||
}
|
||||
>(
|
||||
gql`
|
||||
query ViewRelease($appId: String!, $releaseName: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
updateReleaseByReleaseName(releaseName: $releaseName) {
|
||||
id
|
||||
releaseName
|
||||
updates(offset: 0, limit: ${PAGE_LIMIT}) {
|
||||
updateGroup
|
||||
updateMessage
|
||||
createdAt
|
||||
actor {
|
||||
... on User {
|
||||
firstName
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
releaseName,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.app.byId.updateReleaseByReleaseName;
|
||||
}
|
||||
|
||||
export default class ReleaseView extends Command {
|
||||
static hidden = true;
|
||||
static description = 'View a release.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'releaseName',
|
||||
required: false,
|
||||
description: 'Name of the release to view',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: `return a json with the release's ID name and recent update groups.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { releaseName },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ReleaseView);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const {
|
||||
exp: { slug },
|
||||
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
if (!releaseName) {
|
||||
const validationMessage = 'Release name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ releaseName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'releaseName',
|
||||
message: 'Please enter the name of the release to view:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const UpdateRelease = await viewUpdateReleaseAsync({
|
||||
appId: projectId,
|
||||
releaseName,
|
||||
});
|
||||
|
||||
const updates = Object.values(groupBy(UpdateRelease.updates, u => u.updateGroup)).map(
|
||||
updateGroup => updateGroup[0]
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
log({ ...UpdateRelease, updates });
|
||||
return;
|
||||
}
|
||||
|
||||
const updateGroupTable = new Table({
|
||||
head: ['createdAt', 'updateMessage', 'updateGroup', 'actor'],
|
||||
wordWrap: true,
|
||||
});
|
||||
|
||||
for (const update of updates) {
|
||||
updateGroupTable.push([
|
||||
new Date(update.createdAt).toLocaleString(),
|
||||
update.updateMessage,
|
||||
update.updateGroup,
|
||||
update.actor?.firstName,
|
||||
]);
|
||||
}
|
||||
|
||||
log.withTick(
|
||||
`️Release: ${chalk.bold(UpdateRelease.releaseName)} on project ${chalk.bold(
|
||||
`@${accountName}/${slug}`
|
||||
)}. Release ID: ${chalk.bold(UpdateRelease.id)}`
|
||||
);
|
||||
log(chalk.bold('Recent update groups published on this release:'));
|
||||
log(updateGroupTable.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
isEasEnabledForProjectAsync,
|
||||
warnEasUnavailable,
|
||||
} from '../../project/isEasEnabledForProject';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { findAccountByName } from '../../user/Account';
|
||||
import { getActorDisplayName } from '../../user/User';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
export enum EnvironmentSecretScope {
|
||||
ACCOUNT = 'account',
|
||||
PROJECT = 'project',
|
||||
}
|
||||
export default class EnvironmentSecretCreate extends Command {
|
||||
static description = 'Create an environment secret on the current project or owner account.';
|
||||
|
||||
static flags = {
|
||||
scope: flags.enum({
|
||||
description: 'Scope for the secret',
|
||||
options: [EnvironmentSecretScope.ACCOUNT, EnvironmentSecretScope.PROJECT],
|
||||
default: EnvironmentSecretScope.PROJECT,
|
||||
}),
|
||||
name: flags.string({
|
||||
description: 'Name of the secret',
|
||||
}),
|
||||
value: flags.string({
|
||||
description: 'Value of the secret',
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const actor = await ensureLoggedInAsync();
|
||||
let {
|
||||
flags: { name, value: secretValue, scope },
|
||||
} = this.parse(EnvironmentSecretCreate);
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
const { slug } = exp;
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scope) {
|
||||
const validationMessage = 'Secret scope may not be empty.';
|
||||
|
||||
({ scope } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'scope',
|
||||
message: 'Where should this secret be used:',
|
||||
choices: [
|
||||
{ title: 'Account-wide', value: EnvironmentSecretScope.ACCOUNT },
|
||||
{ title: 'Project-specific', value: EnvironmentSecretScope.PROJECT },
|
||||
],
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
({ name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: `Secret name:`,
|
||||
validate: value => {
|
||||
if (!value) {
|
||||
return 'Secret name may not be empty.';
|
||||
}
|
||||
|
||||
// this validation regex here is just to shorten the feedback loop
|
||||
// the source of truth is in www's EnvironmentSecretValidator class
|
||||
if (!value.match(/^\w+$/)) {
|
||||
return 'Names may contain only letters, numbers, and underscores.';
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
if (!name) throw new Error('Secret name may not be empty.');
|
||||
}
|
||||
|
||||
if (!secretValue) {
|
||||
const validationMessage = 'Secret value may not be empty.';
|
||||
|
||||
({ secretValue } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'secretValue',
|
||||
message: 'Secret value:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
|
||||
if (!secretValue) throw new Error(validationMessage);
|
||||
}
|
||||
|
||||
if (scope === EnvironmentSecretScope.PROJECT) {
|
||||
const secret = await EnvironmentSecretMutation.createForApp(
|
||||
{ name, value: secretValue },
|
||||
projectId
|
||||
);
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`Could not create secret with name ${name} on project with id ${projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Created a new secret ${chalk.bold(name)} on project ${chalk.bold(
|
||||
`@${accountName}/${slug}`
|
||||
)}.`
|
||||
);
|
||||
} else if (scope === EnvironmentSecretScope.ACCOUNT) {
|
||||
const ownerAccount = findAccountByName(actor.accounts, accountName);
|
||||
if (!ownerAccount) {
|
||||
Log.warn(
|
||||
`Your account (${getActorDisplayName(actor)}) doesn't have access to the ${chalk.bold(
|
||||
accountName
|
||||
)} account`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = await EnvironmentSecretMutation.createForAccount(
|
||||
{ name, value: secretValue },
|
||||
ownerAccount.id
|
||||
);
|
||||
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`Could not create secret with name ${name} on account with id ${ownerAccount.id}`
|
||||
);
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Created a new secret ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
|
||||
import {
|
||||
EnvironmentSecretWithScope,
|
||||
EnvironmentSecretsQuery,
|
||||
} from '../../graphql/queries/EnvironmentSecretsQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
isEasEnabledForProjectAsync,
|
||||
warnEasUnavailable,
|
||||
} from '../../project/isEasEnabledForProject';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../prompts';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { EnvironmentSecretScope } from './create';
|
||||
|
||||
export default class EnvironmentSecretDelete extends Command {
|
||||
static description = `Delete an environment secret by ID.
|
||||
Unsure where to find the secret's ID? Run ${chalk.bold('eas secrets:list')}`;
|
||||
|
||||
static flags = {
|
||||
id: flags.string({
|
||||
description: 'ID of the secret to delete',
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
await ensureLoggedInAsync();
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectFullName = await getProjectFullNameAsync(exp);
|
||||
const projectAccountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let {
|
||||
flags: { id },
|
||||
} = this.parse(EnvironmentSecretDelete);
|
||||
let secret: EnvironmentSecretWithScope | undefined;
|
||||
|
||||
if (!id) {
|
||||
const validationMessage = 'You must select which secret to delete.';
|
||||
|
||||
const secrets = await EnvironmentSecretsQuery.allAsync(projectAccountName, projectFullName);
|
||||
|
||||
({ secret } = await promptAsync({
|
||||
type: 'autocomplete',
|
||||
name: 'secret',
|
||||
message: 'Pick the secret to be deleted:',
|
||||
choices: secrets.map(secret => ({
|
||||
title: `${secret.name} (${secret.scope})`,
|
||||
value: secret,
|
||||
})),
|
||||
}));
|
||||
|
||||
id = secret?.id;
|
||||
|
||||
if (!id) throw new Error(validationMessage);
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`You are about to permamently delete secret${
|
||||
secret?.name ? ` "${secret?.name}"` : ''
|
||||
} with id: "${id}".\nThis action is irreversible.`
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({
|
||||
message: `Are you sure you wish to proceed?${
|
||||
secret?.scope === EnvironmentSecretScope.ACCOUNT
|
||||
? ' This secret is applied across your whole account and may affect multiple apps.'
|
||||
: ''
|
||||
}`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.error(
|
||||
`Canceled deletion of secret${secret?.name ? ` "${secret?.name}"` : ''} with id: "${id}".`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await EnvironmentSecretMutation.delete(id);
|
||||
|
||||
Log.withTick(`️Deleted secret${secret?.name ? ` "${secret?.name}"` : ''} with id "${id}".`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
import { EnvironmentSecretsQuery } from '../../graphql/queries/EnvironmentSecretsQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
isEasEnabledForProjectAsync,
|
||||
warnEasUnavailable,
|
||||
} from '../../project/isEasEnabledForProject';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
export default class EnvironmentSecretList extends Command {
|
||||
static description = 'Lists environment secrets available for your current app';
|
||||
|
||||
async run(): Promise<void> {
|
||||
await ensureLoggedInAsync();
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectFullName = await getProjectFullNameAsync(exp);
|
||||
const projectAccountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectDir) {
|
||||
throw new Error("Please run this command inside your project's directory");
|
||||
}
|
||||
|
||||
const secrets = await EnvironmentSecretsQuery.allAsync(projectAccountName, projectFullName);
|
||||
|
||||
const table = new Table({
|
||||
head: ['Name', 'Scope', 'ID', 'Updated at'],
|
||||
wordWrap: true,
|
||||
});
|
||||
|
||||
for (const secret of secrets) {
|
||||
const { name, createdAt: updatedAt, scope, id } = secret;
|
||||
table.push([name, scope, id, dateFormat(updatedAt, 'mmm dd HH:MM:ss')]);
|
||||
}
|
||||
|
||||
Log.log(chalk`{bold Secrets for this account and project:}`);
|
||||
Log.log(table.toString());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 396 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user