mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f002fb47 | ||
|
|
04049a8d1f | ||
|
|
5bdd46a2a9 | ||
|
|
a7e88bd7cb | ||
|
|
78b75c6224 | ||
|
|
52f54fc7ae | ||
|
|
7ddd630cfd | ||
|
|
8751a2835f | ||
|
|
da9829a853 | ||
|
|
cc822b18f3 | ||
|
|
bcab3bcb09 | ||
|
|
a5dc0bd27c | ||
|
|
54127c4e37 | ||
|
|
9097ab2537 | ||
|
|
4e2029a5c5 | ||
|
|
ad02a056d7 | ||
|
|
ea9f734b11 | ||
|
|
7db9d79c55 | ||
|
|
2e31b42b76 | ||
|
|
66d02d9724 | ||
|
|
7c96cc10cf | ||
|
|
523a781ba5 | ||
|
|
6cf8bd5c5a | ||
|
|
67e9ea706b | ||
|
|
c22f410889 | ||
|
|
9ff47af4fa | ||
|
|
324dce8fc0 | ||
|
|
b30c86f0c0 | ||
|
|
008279e1dd | ||
|
|
d00814f84b | ||
|
|
81aa127e28 | ||
|
|
d321a0bccb | ||
|
|
a1d86549ed | ||
|
|
f7e9af913c | ||
|
|
137f9d4ac7 | ||
|
|
2ac863a9e3 | ||
|
|
11be9eba49 | ||
|
|
b36db15601 | ||
|
|
93c5dc1a32 | ||
|
|
c86550c0c8 | ||
|
|
36b4296d3c | ||
|
|
8c08541ca3 | ||
|
|
5a1406d39c | ||
|
|
78ea88cc5f | ||
|
|
6cb02d0ccd | ||
|
|
d6c7623718 | ||
|
|
32142b4fe8 | ||
|
|
6bdee0f395 | ||
|
|
baf94a21c3 | ||
|
|
162d64bce6 | ||
|
|
d16c1fc757 | ||
|
|
e42ea7e2ec | ||
|
|
5fea1ad16e | ||
|
|
2a57a2eb3d | ||
|
|
5628b78523 | ||
|
|
19d1e6bffd | ||
|
|
88b068511c | ||
|
|
842efcf3ab | ||
|
|
f5f08aa506 | ||
|
|
9f7f412cfb | ||
|
|
e369ec1e25 | ||
|
|
b353441f31 | ||
|
|
4db35e73c6 | ||
|
|
10bc872dc9 | ||
|
|
fedc39ea48 | ||
|
|
6046723769 | ||
|
|
afaee9df38 | ||
|
|
de39d6f00d | ||
|
|
401751fffa | ||
|
|
7f7f1c6a9a | ||
|
|
279d6716d2 | ||
|
|
92977d9a53 | ||
|
|
31871a9eda | ||
|
|
f122f42149 | ||
|
|
a9a49d6b97 | ||
|
|
1d94414334 | ||
|
|
0b73dc32f6 | ||
|
|
ef8ad24639 | ||
|
|
24dbe6e7cc | ||
|
|
aad026febb | ||
|
|
6ae8125c0e | ||
|
|
87d143b310 | ||
|
|
346dee9ea5 | ||
|
|
4308d21927 | ||
|
|
207f623338 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Expo Developers Discord
|
||||
url: https://chat.expo.dev/
|
||||
about: Join other developers in discussions regarding Expo and EAS.
|
||||
+145
@@ -10,6 +10,151 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [5.3.0](https://github.com/expo/eas-cli/releases/tag/v5.3.0) - 2023-09-25
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Update: expose expo cli `--dev` flag as an argument. ([#2050](https://github.com/expo/eas-cli/pull/2050) by [@nderscore](https://github.com/nderscore))
|
||||
- Support `bun` option in eas.json. ([#2055](https://github.com/expo/eas-cli/pull/2055) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Support node aliases in .nvmrc. ([#2052](https://github.com/expo/eas-cli/pull/2052) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Rollouts: more robust printing function. ([#2047](https://github.com/expo/eas-cli/pull/2047) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [5.2.0](https://github.com/expo/eas-cli/releases/tag/v5.2.0) - 2023-09-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Rollouts: json output for ci. ([#2037](https://github.com/expo/eas-cli/pull/2037) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Update: only print channel-branch pairing if we created a channel. ([#2036](https://github.com/expo/eas-cli/pull/2036) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Added rollout tests. ([#2042](https://github.com/expo/eas-cli/pull/2042) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Remove unreachable codesigning option. ([#2041](https://github.com/expo/eas-cli/pull/2041) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Fix generated graphql tsc errors. ([#2039](https://github.com/expo/eas-cli/pull/2039) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Rollouts: view action for CI. ([#2040](https://github.com/expo/eas-cli/pull/2040) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [5.1.0](https://github.com/expo/eas-cli/releases/tag/v5.1.0) - 2023-09-01
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support `pnpm` option in eas.json. ([#1988](https://github.com/expo/eas-cli/pull/1988) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Make app config error not repeat indefinitely. ([#2020](https://github.com/expo/eas-cli/pull/2020) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
## [5.0.2](https://github.com/expo/eas-cli/releases/tag/v5.0.2) - 2023-08-29
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Add proper expo cli `--platform` flag handling when exporting updates. ([#1939](https://github.com/expo/eas-cli/pull/1939) by [@byCedric](https://github.com/byCedric))
|
||||
|
||||
## [5.0.1](https://github.com/expo/eas-cli/releases/tag/v5.0.1) - 2023-08-28
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Pass `platform` argument to expo-cli correctly when using the `eas update` command. ([#2028](https://github.com/expo/eas-cli/pull/2028) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [5.0.0](https://github.com/expo/eas-cli/releases/tag/v5.0.0) - 2023-08-28
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Only export at most ios and android dist for EAS updates. ([#2002](https://github.com/expo/eas-cli/pull/2002) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
|
||||
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
|
||||
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Support republishing roll back to embedded updates. ([#2006](https://github.com/expo/eas-cli/pull/2006) by [@wschurman](https://github.com/wschurman))
|
||||
- Configure updates as well when somebody tries to run a build with channel set. ([#2016](https://github.com/expo/eas-cli/pull/2016) by [@wschurman](https://github.com/wschurman))
|
||||
- Fix printing bug: branch with no update. ([#2023](https://github.com/expo/eas-cli/pull/2023) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- More branch map utility functions. ([#2001](https://github.com/expo/eas-cli/pull/2001) by [@quinlanj](https://github.com/quinlanj))
|
||||
- More branch mapping utils. ([#2003](https://github.com/expo/eas-cli/pull/2003) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Create a linked channel on build if not exists. ([#2017](https://github.com/expo/eas-cli/pull/2017) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add `developmentClient` to metadata. ([#2015](https://github.com/expo/eas-cli/pull/2015) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [4.1.2](https://github.com/expo/eas-cli/releases/tag/v4.1.2) - 2023-08-10
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Make branch mapping utility files have no dependencies. ([#1993](https://github.com/expo/eas-cli/pull/1993) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [4.1.1](https://github.com/expo/eas-cli/releases/tag/v4.1.1) - 2023-08-08
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Logger to say website support is coming soon for rollouts. ([#1997](https://github.com/expo/eas-cli/pull/1997) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [4.1.0](https://github.com/expo/eas-cli/releases/tag/v4.1.0) - 2023-08-08
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Pass credentials to custom iOS builds. ([#1989](https://github.com/expo/eas-cli/pull/1989) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Add the `withoutCredentials` option as a common build profile field in `eas.json`. ([#1994](https://github.com/expo/eas-cli/pull/1994) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [4.0.1](https://github.com/expo/eas-cli/releases/tag/v4.0.1) - 2023-08-08
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `eas channel:*` to work with empty branch mappings. ([#1992](https://github.com/expo/eas-cli/pull/1992) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [4.0.0](https://github.com/expo/eas-cli/releases/tag/v4.0.0) - 2023-08-07
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Release redesigned `eas channel:rollout` into developer preview. The set of flag arguments are different, in addition to the workflow. ([#1986](https://github.com/expo/eas-cli/pull/1986) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Option to add current Apple Silicon device without the need to manually provide the provisioning UDID. ([#1943](https://github.com/expo/eas-cli/pull/1943) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix rollout-preview ending with republish with code signing. ([#1978](https://github.com/expo/eas-cli/pull/1978) by [@wschurman](https://github.com/wschurman))
|
||||
- Rollouts: fix fp precision. ([#1985](https://github.com/expo/eas-cli/pull/1985) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Fixing more grammar errors. ([#1980](https://github.com/expo/eas-cli/pull/1980) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Handle rollout edit edge case. ([#1981](https://github.com/expo/eas-cli/pull/1981) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Linked expo.fyi rollout article. ([#1991](https://github.com/expo/eas-cli/pull/1991) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Rollout ux improvements. ([#1984](https://github.com/expo/eas-cli/pull/1984) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [3.18.3](https://github.com/expo/eas-cli/releases/tag/v3.18.3) - 2023-08-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix republishing with code signing. ([#1973](https://github.com/expo/eas-cli/pull/1973) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add relationships flag to rollouts-preview. ([#1972](https://github.com/expo/eas-cli/pull/1972) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Get channel:{view,list,edit} to play nice with rollouts. ([#1974](https://github.com/expo/eas-cli/pull/1974) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Use 'roll out' instead of rollout for verbs. ([#1979](https://github.com/expo/eas-cli/pull/1979) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [3.18.2](https://github.com/expo/eas-cli/releases/tag/v3.18.2) - 2023-08-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Revert adding `.nvmrc` support for setting node version. ([#1976](https://github.com/expo/eas-cli/pull/1976) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Instead of opening a GitHub issue for security vulnerabilities, send an email to our secure inbox: secure@expo.io.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "3.18.2",
|
||||
"version": "5.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+79
-67
@@ -133,7 +133,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -150,7 +150,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -167,7 +167,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -181,7 +181,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -231,7 +231,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -252,7 +252,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -272,7 +272,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -292,7 +292,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -315,7 +315,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -345,7 +345,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -362,7 +362,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -379,7 +379,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -414,7 +414,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -452,7 +452,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -478,7 +478,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -502,7 +502,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -551,7 +551,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -570,7 +570,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -589,7 +589,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -606,7 +606,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -627,7 +627,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -649,7 +649,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -669,7 +669,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -677,23 +677,31 @@ Roll a new branch out on a channel incrementally.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas channel:rollout [CHANNEL] [--branch <value>] [--percent <value>] [--end] [--json --non-interactive]
|
||||
$ eas channel:rollout [CHANNEL] [--action create|edit|end|view] [--percent <value>] [--outcome
|
||||
republish-and-revert|revert] [--branch <value>] [--runtime-version <value>] [--private-key-path <value>] [--json
|
||||
--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
CHANNEL channel on which the rollout should be done
|
||||
|
||||
FLAGS
|
||||
--branch=<value> branch to rollout
|
||||
--end end the rollout
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--percent=<value> percent of users to send to the new branch
|
||||
--action=(create|edit|end|view) Rollout action to perform
|
||||
--branch=<value> Branch to roll out. Use with --action=create
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--outcome=(republish-and-revert|revert) End outcome of rollout. Use with --action=end
|
||||
--percent=<value> Percent of users to send to the new branch. Use with --action=edit or
|
||||
--action=create
|
||||
--private-key-path=<value> File containing the PEM-encoded private key corresponding to the certificate
|
||||
in expo-updates' configuration. Defaults to a file named "private-key.pem" in
|
||||
the certificate's directory.
|
||||
--runtime-version=<value> Runtime version to target. Use with --action=create
|
||||
|
||||
DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -716,7 +724,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -737,7 +745,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -754,7 +762,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/credentials.ts)_
|
||||
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/credentials.ts)_
|
||||
|
||||
## `eas device:create`
|
||||
|
||||
@@ -768,7 +776,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -788,7 +796,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -809,7 +817,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -830,7 +838,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -844,7 +852,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -858,7 +866,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -946,7 +954,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -963,7 +971,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -980,7 +988,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas open`
|
||||
|
||||
@@ -994,7 +1002,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1008,7 +1016,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1030,7 +1038,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas secret:create`
|
||||
|
||||
@@ -1053,7 +1061,7 @@ DESCRIPTION
|
||||
create an environment secret on the current project or owner account
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/create.ts)_
|
||||
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/secret/create.ts)_
|
||||
|
||||
## `eas secret:delete`
|
||||
|
||||
@@ -1071,7 +1079,7 @@ DESCRIPTION
|
||||
delete an environment secret by ID
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/delete.ts)_
|
||||
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/secret/delete.ts)_
|
||||
|
||||
## `eas secret:list`
|
||||
|
||||
@@ -1085,7 +1093,7 @@ DESCRIPTION
|
||||
list environment secrets available for your current app
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/list.ts)_
|
||||
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/secret/list.ts)_
|
||||
|
||||
## `eas secret:push`
|
||||
|
||||
@@ -1105,7 +1113,7 @@ DESCRIPTION
|
||||
read environment secrets from env file and store on the server
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/push.ts)_
|
||||
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/secret/push.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1135,7 +1143,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1144,8 +1152,8 @@ publish an update group
|
||||
```
|
||||
USAGE
|
||||
$ eas update [--branch <value>] [--channel <value>] [-m <value>] [--republish | --input-dir <value> |
|
||||
--skip-bundler] [--group <value> | | ] [--clear-cache] [-p android|ios|all] [--auto] [--private-key-path <value>]
|
||||
[--json --non-interactive]
|
||||
--skip-bundler] [--group <value> | | ] [--clear-cache] [-p android|ios|all] [--dev] [--auto] [--private-key-path
|
||||
<value>] [--json --non-interactive]
|
||||
|
||||
FLAGS
|
||||
-m, --message=<value> A short message describing the update
|
||||
@@ -1154,6 +1162,7 @@ FLAGS
|
||||
--branch=<value> Branch to publish the update group on
|
||||
--channel=<value> Channel that the published update should affect
|
||||
--clear-cache Clear the bundler cache before publishing
|
||||
--dev Publish a development bundle
|
||||
--group=<value> Update group to republish (deprecated, see republish command)
|
||||
--input-dir=<value> [default: dist] Location of the bundle
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
@@ -1168,7 +1177,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1186,7 +1195,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1207,7 +1216,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1229,7 +1238,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -1238,22 +1247,25 @@ roll back to an existing update
|
||||
```
|
||||
USAGE
|
||||
$ eas update:republish [--channel <value> | --branch <value> | --group <value>] [-m <value>] [-p android|ios|all]
|
||||
[--json --non-interactive]
|
||||
[--private-key-path <value>] [--json --non-interactive]
|
||||
|
||||
FLAGS
|
||||
-m, --message=<value> Short message describing the republished update
|
||||
-m, --message=<value> Short message describing the republished update group
|
||||
-p, --platform=(android|ios|all) [default: all]
|
||||
--branch=<value> Branch name to select an update to republish from
|
||||
--channel=<value> Channel name to select an update to republish from
|
||||
--branch=<value> Branch name to select an update group to republish from
|
||||
--channel=<value> Channel name to select an update group to republish from
|
||||
--group=<value> Update group ID to republish
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--private-key-path=<value> File containing the PEM-encoded private key corresponding to the certificate in
|
||||
expo-updates' configuration. Defaults to a file named "private-key.pem" in the
|
||||
certificate's directory.
|
||||
|
||||
DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -1273,7 +1285,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -1294,7 +1306,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -1314,7 +1326,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -1332,7 +1344,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -1354,7 +1366,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -1371,7 +1383,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
|
||||
Generated
+1714
-129
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "3.18.2",
|
||||
"version": "5.3.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
@@ -13,8 +13,8 @@
|
||||
"@expo/config": "8.1.2",
|
||||
"@expo/config-plugins": "7.2.4",
|
||||
"@expo/config-types": "49.0.0",
|
||||
"@expo/eas-build-job": "1.0.30",
|
||||
"@expo/eas-json": "3.18.0",
|
||||
"@expo/eas-build-job": "1.0.43",
|
||||
"@expo/eas-json": "5.3.0",
|
||||
"@expo/json-file": "8.2.37",
|
||||
"@expo/multipart-body-parser": "1.1.0",
|
||||
"@expo/osascript": "2.0.33",
|
||||
@@ -27,7 +27,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.0",
|
||||
"@expo/steps": "1.0.31",
|
||||
"@expo/steps": "1.0.34",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "1.23.2",
|
||||
"@oclif/plugin-autocomplete": "1.3.10",
|
||||
@@ -73,7 +73,7 @@
|
||||
"prompts": "2.4.2",
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"resolve-from": "5.0.0",
|
||||
"semver": "7.3.8",
|
||||
"semver": "7.5.2",
|
||||
"slash": "3.0.0",
|
||||
"tar": "6.1.13",
|
||||
"terminal-link": "2.1.1",
|
||||
|
||||
@@ -25,7 +25,7 @@ describe(SelectBranch, () => {
|
||||
const branch = await selectBranch.runAsync(ctx);
|
||||
expect(branch).toBeNull();
|
||||
});
|
||||
it('returns the branch without prompting if project only one branch', async () => {
|
||||
it('still prompts if project has only one branch', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [{ node: testBasicBranchInfo, cursor: 'cursor' }],
|
||||
@@ -34,9 +34,13 @@ describe(SelectBranch, () => {
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: testBasicBranchInfo,
|
||||
}));
|
||||
const selectBranch = new SelectBranch();
|
||||
const branch = await selectBranch.runAsync(ctx);
|
||||
expect(branch).toBe(testBasicBranchInfo);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('returns the selected branch', async () => {
|
||||
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
|
||||
@@ -66,6 +66,8 @@ export async function prepareJobAsync(
|
||||
builderEnvironment: {
|
||||
image: buildProfile.image,
|
||||
node: buildProfile.node,
|
||||
pnpm: buildProfile.pnpm,
|
||||
bun: buildProfile.bun,
|
||||
yarn: buildProfile.yarn,
|
||||
ndk: buildProfile.ndk,
|
||||
expoCli: buildProfile.expoCli,
|
||||
|
||||
@@ -55,4 +55,5 @@ export interface BuildContext<T extends Platform> {
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
android: T extends Platform.ANDROID ? AndroidBuildContext : undefined;
|
||||
ios: T extends Platform.IOS ? IosBuildContext : undefined;
|
||||
developmentClient: boolean;
|
||||
}
|
||||
@@ -60,6 +60,12 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
const workflow = await resolveWorkflowAsync(projectDir, platform);
|
||||
const accountId = account.id;
|
||||
const runFromCI = getenv.boolish('CI', false);
|
||||
const developmentClient =
|
||||
buildProfile.developmentClient ??
|
||||
(platform === Platform.ANDROID
|
||||
? (buildProfile as BuildProfile<Platform.ANDROID>)?.gradleCommand === ':app:assembleDebug'
|
||||
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug') ??
|
||||
false;
|
||||
|
||||
const credentialsCtx = new CredentialsContext({
|
||||
projectInfo: { exp, projectId },
|
||||
@@ -120,6 +126,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
message,
|
||||
runFromCI,
|
||||
customBuildConfigMetadata,
|
||||
developmentClient,
|
||||
};
|
||||
if (platform === Platform.ANDROID) {
|
||||
const common = commonContext as CommonContext<Platform.ANDROID>;
|
||||
|
||||
@@ -55,6 +55,5 @@ export async function ensureIosCredentialsForBuildResignAsync(
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
|
||||
const isCustomBuild = buildCtx.buildProfile.config !== undefined;
|
||||
return !buildCtx.buildProfile.simulator && !isCustomBuild;
|
||||
return !buildCtx.buildProfile.simulator && !buildCtx.buildProfile.withoutCredentials;
|
||||
}
|
||||
@@ -58,6 +58,8 @@ export async function prepareJobAsync(
|
||||
builderEnvironment: {
|
||||
image: buildProfile.image,
|
||||
node: buildProfile.node,
|
||||
pnpm: buildProfile.pnpm,
|
||||
bun: buildProfile.bun,
|
||||
yarn: buildProfile.yarn,
|
||||
bundler: buildProfile.bundler,
|
||||
cocoapods: buildProfile.cocoapods,
|
||||
|
||||
@@ -6,7 +6,7 @@ import semver from 'semver';
|
||||
import { ora } from '../ora';
|
||||
|
||||
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
|
||||
const PLUGIN_PACKAGE_VERSION = '1.0.33';
|
||||
const PLUGIN_PACKAGE_VERSION = '1.0.44';
|
||||
|
||||
export enum LocalBuildMode {
|
||||
/**
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
runFromCI: ctx.runFromCI,
|
||||
buildMode: ctx.buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
|
||||
customWorkflowName: ctx.customBuildConfigMetadata?.workflowName,
|
||||
developmentClient: ctx.developmentClient,
|
||||
};
|
||||
return sanitizeMetadata(metadata);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExpoConfig } from '@expo/config-types';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import {
|
||||
AppVersionSource,
|
||||
@@ -13,6 +14,7 @@ import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
import { createAndLinkChannelAsync, doesChannelExistAsync } from '../channel/queries';
|
||||
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
@@ -38,9 +40,9 @@ import {
|
||||
import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk';
|
||||
import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig';
|
||||
import {
|
||||
installExpoUpdatesAsync,
|
||||
isExpoUpdatesInstalledAsDevDependency,
|
||||
isExpoUpdatesInstalledOrAvailable,
|
||||
isUsingEASUpdate,
|
||||
validateAppVersionRuntimePolicySupportAsync,
|
||||
} from '../project/projectUtils';
|
||||
import {
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
waitToCompleteAsync as waitForSubmissionsToCompleteAsync,
|
||||
} from '../submit/submit';
|
||||
import { printSubmissionDetailsUrls } from '../submit/utils/urls';
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../update/configure';
|
||||
import { validateBuildProfileConfigMatchesProjectConfigAsync } from '../update/utils';
|
||||
import { Actor } from '../user/User';
|
||||
import { downloadAndMaybeExtractAppAsync } from '../utils/download';
|
||||
@@ -117,6 +120,7 @@ export async function runBuildAndSubmitAsync(
|
||||
easJsonAccessor,
|
||||
platforms,
|
||||
profileName: flags.profile ?? undefined,
|
||||
projectDir,
|
||||
});
|
||||
Log.log(
|
||||
`Loaded "env" configuration for the "${buildProfiles[0].profileName}" profile: ${
|
||||
@@ -183,6 +187,7 @@ export async function runBuildAndSubmitAsync(
|
||||
platforms,
|
||||
profileName: flags.submitProfile,
|
||||
type: 'submit',
|
||||
projectDir,
|
||||
});
|
||||
for (const startedBuild of startedBuilds) {
|
||||
const submitProfile = nullthrows(
|
||||
@@ -320,11 +325,26 @@ async function prepareAndStartBuildAsync({
|
||||
);
|
||||
if (buildProfile.profile.channel) {
|
||||
await validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
graphqlClient,
|
||||
exp: buildCtx.exp,
|
||||
projectId: buildCtx.projectId,
|
||||
projectDir,
|
||||
sdkVersion: buildCtx.exp.sdkVersion,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
buildProfile,
|
||||
});
|
||||
if (isUsingEASUpdate(buildCtx.exp, buildCtx.projectId)) {
|
||||
const doesChannelExist = await doesChannelExistAsync(graphqlClient, {
|
||||
appId: buildCtx.projectId,
|
||||
channelName: buildProfile.profile.channel,
|
||||
});
|
||||
if (!doesChannelExist) {
|
||||
await createAndLinkChannelAsync(graphqlClient, {
|
||||
appId: buildCtx.projectId,
|
||||
channelName: buildProfile.profile.channel,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await validateAppVersionRuntimePolicySupportAsync(buildCtx.projectDir, buildCtx.exp);
|
||||
@@ -447,11 +467,17 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
|
||||
}
|
||||
|
||||
async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
exp,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
projectDir,
|
||||
buildProfile,
|
||||
nonInteractive,
|
||||
sdkVersion,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
projectDir: string;
|
||||
buildProfile: ProfileData<'build'>;
|
||||
nonInteractive: boolean;
|
||||
@@ -467,18 +493,24 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
);
|
||||
} else if (nonInteractive) {
|
||||
Log.warn(
|
||||
`The build profile "${buildProfile.profileName}" has specified the channel "${buildProfile.profile.channel}", but the "expo-updates" package hasn't been installed. To use channels for your builds, install the "expo-updates" package by running "npx expo install expo-updates".`
|
||||
`The build profile "${buildProfile.profileName}" has specified the channel "${buildProfile.profile.channel}", but the "expo-updates" package hasn't been installed. To use channels for your builds, install the "expo-updates" package by running "npx expo install expo-updates" followed by "eas update:configure".`
|
||||
);
|
||||
} else {
|
||||
Log.warn(
|
||||
`The build profile "${buildProfile.profileName}" specifies the channel "${buildProfile.profile.channel}", but the "expo-updates" package is missing. To use channels in your builds, install the "expo-updates" package.`
|
||||
`The build profile "${buildProfile.profileName}" specifies the channel "${buildProfile.profile.channel}", but the "expo-updates" package is missing. To use channels in your builds, install the "expo-updates" package and run "eas update:configure".`
|
||||
);
|
||||
const installExpoUpdates = await confirmAsync({
|
||||
message: `Would you like to install the "expo-updates" package?`,
|
||||
message: `Would you like to install the "expo-updates" package and configure EAS Update now?`,
|
||||
});
|
||||
if (installExpoUpdates) {
|
||||
await installExpoUpdatesAsync(projectDir, { silent: false });
|
||||
Log.withTick('Installed expo-updates');
|
||||
await ensureEASUpdateIsConfiguredAsync(graphqlClient, {
|
||||
exp,
|
||||
projectId,
|
||||
projectDir,
|
||||
platform: RequestedPlatform.All,
|
||||
});
|
||||
Log.withTick('Installed expo-updates and configured EAS Update.');
|
||||
throw new Error('Command must be re-run to pick up new updates configuration.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BranchBasicInfo, ChannelBasicInfo, UpdateChannelInfoWithBranches } from '../utils';
|
||||
|
||||
export const testChannelBasicInfo: ChannelBasicInfo = {
|
||||
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
|
||||
name: 'production',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
|
||||
};
|
||||
|
||||
export const testBasicBranchInfo1: BranchBasicInfo = {
|
||||
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
|
||||
name: 'wrong-channel',
|
||||
};
|
||||
|
||||
export const testBasicBranchInfo2: BranchBasicInfo = {
|
||||
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
|
||||
name: 'production',
|
||||
};
|
||||
export const channelInfoWithBranches: UpdateChannelInfoWithBranches<BranchBasicInfo> = {
|
||||
...testChannelBasicInfo,
|
||||
updateBranches: [testBasicBranchInfo1, testBasicBranchInfo2],
|
||||
};
|
||||
@@ -1,16 +1,49 @@
|
||||
import { rolloutBranchMapping, standardBranchMapping } from '../../rollout/__tests__/fixtures';
|
||||
import {
|
||||
emptyBranchMapping,
|
||||
rolloutBranchMapping,
|
||||
standardBranchMapping,
|
||||
} from '../../rollout/__tests__/fixtures';
|
||||
import {
|
||||
BranchMappingValidationError,
|
||||
assertVersion,
|
||||
getAlwaysTrueBranchMapping,
|
||||
getBranchMapping,
|
||||
getEmptyBranchMapping,
|
||||
getStandardBranchId,
|
||||
isAlwaysTrueBranchMapping,
|
||||
isEmptyBranchMapping,
|
||||
} from '../branch-mapping';
|
||||
import { testChannelObject } from './fixtures';
|
||||
import { testChannelBasicInfo } from './branch-mapping-fixtures';
|
||||
|
||||
describe(assertVersion, () => {
|
||||
it('throws if the branch mapping is not the correct version', () => {
|
||||
expect(() => assertVersion(testChannelBasicInfo, 5)).toThrowError(BranchMappingValidationError);
|
||||
});
|
||||
it('asserts the correct version', () => {
|
||||
assertVersion(testChannelBasicInfo, 0);
|
||||
expect(getBranchMapping(testChannelBasicInfo.branchMapping).version).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getEmptyBranchMapping, () => {
|
||||
it('gets an empty branch mapping', () => {
|
||||
expect(isEmptyBranchMapping(getEmptyBranchMapping())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isEmptyBranchMapping, () => {
|
||||
it('detects empty branch mappings', () => {
|
||||
expect(isEmptyBranchMapping(emptyBranchMapping)).toBe(true);
|
||||
expect(isEmptyBranchMapping(standardBranchMapping)).toBe(false);
|
||||
expect(isEmptyBranchMapping(rolloutBranchMapping)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isAlwaysTrueBranchMapping, () => {
|
||||
it('detects always true branch mappings', () => {
|
||||
expect(isAlwaysTrueBranchMapping(standardBranchMapping)).toBe(true);
|
||||
expect(isAlwaysTrueBranchMapping(rolloutBranchMapping)).toBe(false);
|
||||
expect(isAlwaysTrueBranchMapping(emptyBranchMapping)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,10 +56,12 @@ describe(getAlwaysTrueBranchMapping, () => {
|
||||
|
||||
describe(getStandardBranchId, () => {
|
||||
it('throws if the branch mapping is not a standard mapping', () => {
|
||||
expect(() => getStandardBranchId(testChannelObject)).toThrowError(BranchMappingValidationError);
|
||||
expect(() => getStandardBranchId(testChannelBasicInfo)).toThrowError(
|
||||
BranchMappingValidationError
|
||||
);
|
||||
});
|
||||
it('gets a standard branch id', () => {
|
||||
const channelObjectWithStandardMapping = { ...testChannelObject };
|
||||
const channelObjectWithStandardMapping = { ...testChannelBasicInfo };
|
||||
channelObjectWithStandardMapping.branchMapping = JSON.stringify(standardBranchMapping);
|
||||
expect(getStandardBranchId(channelObjectWithStandardMapping)).toBe(
|
||||
standardBranchMapping.data[0].branchId
|
||||
|
||||
@@ -124,6 +124,7 @@ export const testChannelObject: UpdateChannelObject = {
|
||||
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
|
||||
name: 'production',
|
||||
createdAt: '2023-05-16T21:26:16.946Z',
|
||||
updatedAt: '2023-05-16T21:26:16.946Z',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
|
||||
updateBranches: [testUpdateBranch1, testUpdateBranch2],
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { format } from '@expo/timeago.js';
|
||||
|
||||
import { getAlwaysTrueBranchMapping, getEmptyBranchMapping } from '../branch-mapping';
|
||||
import { getDescriptionByBranchId } from '../print-utils';
|
||||
import { testChannelObject, testUpdateBranch1, testUpdateBranch2 } from './fixtures';
|
||||
|
||||
describe(getDescriptionByBranchId, () => {
|
||||
it('should get descriptions for multiple branches with multiple update groups', () => {
|
||||
const descriptionByBranchId = getDescriptionByBranchId(testChannelObject);
|
||||
expect(Object.values(descriptionByBranchId)).toHaveLength(2);
|
||||
expect(descriptionByBranchId[testUpdateBranch1.id]).toEqual({
|
||||
branch: 'wrong-channel',
|
||||
branchRolloutPercentage: 15,
|
||||
update: {
|
||||
codeSigningKey: undefined,
|
||||
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
|
||||
isRollBackToEmbedded: false,
|
||||
message: `"fix bug" (${format('2023-07-17T22:48:59.278Z', 'en_US')} by quintest113)`,
|
||||
platforms: 'android, ios',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(descriptionByBranchId[testUpdateBranch2.id]).toEqual({
|
||||
branch: 'production',
|
||||
branchRolloutPercentage: 85,
|
||||
update: {
|
||||
codeSigningKey: undefined,
|
||||
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
|
||||
isRollBackToEmbedded: false,
|
||||
message: `"fix bug" (${format('2023-06-23T23:37:10.004Z', 'en_US')} by quintest113)`,
|
||||
platforms: 'android, ios',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should get descriptions for branches with no updates', () => {
|
||||
const noUpdatesBranch = { ...testUpdateBranch1, updateGroups: [] };
|
||||
const oneBranchChannel = {
|
||||
...testChannelObject,
|
||||
updateBranches: [noUpdatesBranch],
|
||||
branchMapping: JSON.stringify(getAlwaysTrueBranchMapping(noUpdatesBranch.id)),
|
||||
};
|
||||
const descriptionByBranchId = getDescriptionByBranchId(oneBranchChannel);
|
||||
expect(Object.values(descriptionByBranchId)).toHaveLength(1);
|
||||
expect(descriptionByBranchId[testUpdateBranch1.id]).toEqual({ branch: 'wrong-channel' });
|
||||
});
|
||||
it('should get descriptions for no branches', () => {
|
||||
const noBranchChannel = {
|
||||
...testChannelObject,
|
||||
updateBranches: [],
|
||||
branchMapping: JSON.stringify(getEmptyBranchMapping()),
|
||||
};
|
||||
const descriptionByBranchId = getDescriptionByBranchId(noBranchChannel);
|
||||
expect(Object.values(descriptionByBranchId)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getUpdateBranch } from '../utils';
|
||||
import { testChannelObject, testUpdateBranch1 } from './fixtures';
|
||||
import { channelInfoWithBranches, testBasicBranchInfo1 } from './branch-mapping-fixtures';
|
||||
|
||||
describe(getUpdateBranch, () => {
|
||||
it('gets the update branch', () => {
|
||||
expect(getUpdateBranch(testChannelObject, '754bf17f-efc0-46ab-8a59-a03f20e53e9b')).toBe(
|
||||
testUpdateBranch1
|
||||
expect(getUpdateBranch(channelInfoWithBranches, '754bf17f-efc0-46ab-8a59-a03f20e53e9b')).toBe(
|
||||
testBasicBranchInfo1
|
||||
);
|
||||
expect(() => getUpdateBranch(testChannelObject, 'not-a-branch-ID')).toThrow();
|
||||
expect(() => getUpdateBranch(channelInfoWithBranches, 'not-a-branch-ID')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ describe(SelectChannel, () => {
|
||||
const channel = await selectChannel.runAsync(ctx);
|
||||
expect(channel).toBeNull();
|
||||
});
|
||||
it('returns the channel without prompting if project only one channel', async () => {
|
||||
it('still prompts if project has only one channel', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
|
||||
@@ -34,9 +34,13 @@ describe(SelectChannel, () => {
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: testBasicChannelInfo,
|
||||
}));
|
||||
const selectChannel = new SelectChannel();
|
||||
const channel = await selectChannel.runAsync(ctx);
|
||||
expect(channel).toBe(testBasicChannelInfo);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('returns the selected channel', async () => {
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UpdateChannelBasicInfoFragment } from '../graphql/generated';
|
||||
import { ChannelBasicInfo } from './utils';
|
||||
|
||||
// TODO(quin): move this into a common package with www
|
||||
export type BranchMappingOperator =
|
||||
@@ -45,6 +45,18 @@ export type AlwaysTrueBranchMapping = {
|
||||
];
|
||||
};
|
||||
|
||||
export type EmptyBranchMapping = {
|
||||
version: number;
|
||||
data: [];
|
||||
};
|
||||
|
||||
export function getEmptyBranchMapping(): EmptyBranchMapping {
|
||||
return {
|
||||
version: 0,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMapping {
|
||||
return {
|
||||
version: 0,
|
||||
@@ -57,17 +69,28 @@ export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMa
|
||||
};
|
||||
}
|
||||
|
||||
export function hasStandardBranchMap(channelInfo: UpdateChannelBasicInfoFragment): boolean {
|
||||
export function hasEmptyBranchMap(channelInfo: ChannelBasicInfo): boolean {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
return isEmptyBranchMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function hasStandardBranchMap(channelInfo: ChannelBasicInfo): boolean {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
return isAlwaysTrueBranchMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function getStandardBranchId(channelInfo: UpdateChannelBasicInfoFragment): string {
|
||||
export function getStandardBranchId(channelInfo: ChannelBasicInfo): string {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
assertAlwaysTrueBranchMapping(branchMapping);
|
||||
return getBranchIdFromStandardMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function isEmptyBranchMapping(
|
||||
branchMapping: BranchMapping
|
||||
): branchMapping is EmptyBranchMapping {
|
||||
return branchMapping.data.length === 0;
|
||||
}
|
||||
|
||||
export function isAlwaysTrueBranchMapping(
|
||||
branchMapping: BranchMapping
|
||||
): branchMapping is AlwaysTrueBranchMapping {
|
||||
@@ -133,6 +156,19 @@ export function hashLtOperator(): BranchMappingOperator {
|
||||
return 'hash_lt';
|
||||
}
|
||||
|
||||
function isVersion(branchMapping: BranchMapping, version: number): boolean {
|
||||
return branchMapping.version === version;
|
||||
}
|
||||
|
||||
export function assertVersion(channelInfo: ChannelBasicInfo, version: number): void {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
if (!isVersion(branchMapping, version)) {
|
||||
throw new BranchMappingValidationError(
|
||||
`Expected branch mapping version ${version}. Received: ${JSON.stringify(branchMapping)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertStatement(node: BranchMappingNode): asserts node is BranchMappingStatement {
|
||||
if (!isStatement(node)) {
|
||||
throw new BranchMappingValidationError(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import Log from '../log';
|
||||
import { getRollout, isRollout } from '../rollout/branch-mapping';
|
||||
import {
|
||||
FormattedBranchDescription,
|
||||
formatBranch,
|
||||
getUpdateGroupDescriptionsWithBranch,
|
||||
} from '../update/utils';
|
||||
import { assertVersion, hasEmptyBranchMap, hasStandardBranchMap } from './branch-mapping';
|
||||
|
||||
/**
|
||||
* Log all the branches associated with the channel and their most recent update group
|
||||
*/
|
||||
export function logChannelDetails(channel: UpdateChannelObject): void {
|
||||
assertVersion(channel, 0);
|
||||
const doesSupportMapping =
|
||||
hasEmptyBranchMap(channel) || hasStandardBranchMap(channel) || isRollout(channel);
|
||||
if (!doesSupportMapping) {
|
||||
Log.log(chalk.dim('Custom branch mapping detected.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const branchDescriptionByBranchId = getDescriptionByBranchId(channel);
|
||||
const entries = Object.entries(branchDescriptionByBranchId);
|
||||
if (entries.length === 0) {
|
||||
Log.log(chalk.dim('No branches are pointed to this channel.'));
|
||||
} else {
|
||||
Log.log(
|
||||
entries
|
||||
.map(([_branchId, description]) => formatBranch(description))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDescriptionByBranchId(
|
||||
channel: UpdateChannelObject
|
||||
): Record<string, FormattedBranchDescription> {
|
||||
return channel.updateBranches.reduce((acc, branch) => {
|
||||
const maybeRollout = isRollout(channel) ? getRollout(channel) : null;
|
||||
let maybePercentOnBranch: number | undefined = undefined;
|
||||
if (maybeRollout) {
|
||||
maybePercentOnBranch =
|
||||
maybeRollout.rolledOutBranchId === branch.id
|
||||
? maybeRollout.percentRolledOut
|
||||
: 100 - maybeRollout.percentRolledOut;
|
||||
}
|
||||
|
||||
if (branch.updateGroups.length === 0) {
|
||||
acc[branch.id] = { branch: branch.name, branchRolloutPercentage: maybePercentOnBranch };
|
||||
return acc;
|
||||
}
|
||||
const updateGroupsWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
|
||||
branch.updateGroups
|
||||
);
|
||||
// display the most recent update group
|
||||
const updateGroupWithBranchDescription = updateGroupsWithBranchDescriptions[0];
|
||||
const { branch: branchName, ...updateGroup } = updateGroupWithBranchDescription;
|
||||
acc[branch.id] = {
|
||||
branch: branchName,
|
||||
branchRolloutPercentage: maybePercentOnBranch,
|
||||
update: updateGroup,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, FormattedBranchDescription>);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import chalk from 'chalk';
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { createUpdateBranchOnAppAsync } from '../branch/queries';
|
||||
import { BranchNotFoundError } from '../branch/utils';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { PaginatedQueryOptions } from '../commandUtils/pagination';
|
||||
import { withErrorHandlingAsync } from '../graphql/client';
|
||||
@@ -15,13 +17,16 @@ import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/Bra
|
||||
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
|
||||
import Log from '../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../project/projectUtils';
|
||||
import formatFields from '../utils/formatFields';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import {
|
||||
paginatedQueryWithConfirmPromptAsync,
|
||||
paginatedQueryWithSelectPromptAsync,
|
||||
} from '../utils/queries';
|
||||
import { logChannelDetails } from './utils';
|
||||
import { ChannelNotFoundError } from './errors';
|
||||
import { logChannelDetails } from './print-utils';
|
||||
import { ChannelBasicInfo } from './utils';
|
||||
|
||||
export const CHANNELS_LIMIT = 25;
|
||||
|
||||
@@ -264,3 +269,101 @@ export async function ensureChannelExistsAsync(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function doesChannelExistAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, channelName }: { appId: string; channelName: string }
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId,
|
||||
channelName,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ChannelNotFoundError) {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Creates a channel and links it to a branch with the same name.
|
||||
*
|
||||
* @param appId the app ID, also known as the project ID
|
||||
* @param channelName the name of the channel to create
|
||||
* @param shouldPrintJson print only the JSON output
|
||||
*/
|
||||
export async function createAndLinkChannelAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
channelName,
|
||||
shouldPrintJson,
|
||||
}: { appId: string; channelName: string; shouldPrintJson?: boolean }
|
||||
): Promise<ChannelBasicInfo> {
|
||||
let branchId: string;
|
||||
let branchMessage: string;
|
||||
|
||||
try {
|
||||
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
appId,
|
||||
name: channelName,
|
||||
});
|
||||
branchId = branch.id;
|
||||
branchMessage = `We found a branch with the same name`;
|
||||
} catch (error) {
|
||||
if (error instanceof BranchNotFoundError) {
|
||||
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
|
||||
appId,
|
||||
name: channelName,
|
||||
});
|
||||
branchId = newBranch.id;
|
||||
branchMessage = `We also went ahead and made a branch with the same name`;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
updateChannel: { createUpdateChannelForApp: newChannel },
|
||||
} = await createChannelOnAppAsync(graphqlClient, {
|
||||
appId,
|
||||
channelName,
|
||||
branchId,
|
||||
});
|
||||
|
||||
if (!newChannel) {
|
||||
throw new Error(
|
||||
`Could not create channel with name ${channelName} on project with id ${appId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldPrintJson) {
|
||||
printJsonOnlyOutput(newChannel);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.withTick(
|
||||
`Created a new channel on project ${chalk.bold(
|
||||
await getDisplayNameForProjectIdAsync(graphqlClient, appId)
|
||||
)}`
|
||||
);
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: newChannel.name },
|
||||
{ label: 'ID', value: newChannel.id },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.withTick(`${branchMessage} and have pointed the channel at it.`);
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: newChannel.name },
|
||||
{ label: 'ID', value: branchId },
|
||||
])
|
||||
);
|
||||
}
|
||||
return newChannel;
|
||||
}
|
||||
@@ -1,128 +1,30 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
export type ChannelBasicInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
export type BranchBasicInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
import { UpdateBranchObject, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import Log from '../log';
|
||||
import {
|
||||
FormattedBranchDescription,
|
||||
formatBranch,
|
||||
getUpdateGroupDescriptionsWithBranch,
|
||||
} from '../update/utils';
|
||||
import { BranchMapping, assertNodeObject, assertNumber, isNodeObject } from './branch-mapping';
|
||||
export type UpdateChannelInfoWithBranches<Branch extends BranchBasicInfo> = ChannelBasicInfo & {
|
||||
updateBranches: Branch[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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 branchMappingNode = branchMapping.data[0]?.branchMappingLogic;
|
||||
let rolloutPercent: number | undefined;
|
||||
if (isNodeObject(branchMappingNode)) {
|
||||
assertNumber(branchMappingNode.operand);
|
||||
rolloutPercent = branchMappingNode.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:
|
||||
assertNodeObject(branchMappingNode);
|
||||
if (branchMappingNode.clientKey !== 'rolloutToken') {
|
||||
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
|
||||
}
|
||||
if (branchMappingNode.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 function logChannelDetails(channel: UpdateChannelObject): void {
|
||||
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
|
||||
if (branchMapping.data.length > 2) {
|
||||
throw new Error('Branch Mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
const rolloutBranchIds = branchMapping.data.map(data => data.branchId);
|
||||
const branchDescription = channel.updateBranches.flatMap(branch => {
|
||||
const updateGroupWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
|
||||
branch.updateGroups
|
||||
);
|
||||
|
||||
const isRolloutBranch = isRollout && rolloutBranchIds.includes(branch.id);
|
||||
const isBaseBranch = rolloutBranchIds.length > 0 && rolloutBranchIds[0] === branch.id;
|
||||
let rolloutPercentNumber: number | undefined = undefined;
|
||||
if (isRolloutBranch) {
|
||||
rolloutPercentNumber = isBaseBranch ? rolloutPercent! * 100 : (1 - rolloutPercent!) * 100;
|
||||
}
|
||||
|
||||
return updateGroupWithBranchDescriptions.map(
|
||||
({ branch, ...updateGroup }): FormattedBranchDescription => ({
|
||||
branch,
|
||||
branchRolloutPercentage: rolloutPercentNumber,
|
||||
update: updateGroup,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (branchDescription.length === 0) {
|
||||
Log.log(chalk.dim('No branches are pointed to this channel.'));
|
||||
} else {
|
||||
Log.log(
|
||||
branchDescription
|
||||
.map(description => formatBranch(description))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getUpdateBranchNullable(
|
||||
channel: UpdateChannelObject,
|
||||
function getUpdateBranchNullable<Branch extends BranchBasicInfo>(
|
||||
channel: UpdateChannelInfoWithBranches<Branch>,
|
||||
branchId: string
|
||||
): UpdateBranchObject | null {
|
||||
): Branch | null {
|
||||
const updateBranches = channel.updateBranches;
|
||||
const updateBranch = updateBranches.find(branch => branch.id === branchId);
|
||||
return updateBranch ?? null;
|
||||
}
|
||||
|
||||
export function getUpdateBranch(
|
||||
channel: UpdateChannelObject,
|
||||
export function getUpdateBranch<Branch extends BranchBasicInfo>(
|
||||
channel: UpdateChannelInfoWithBranches<Branch>,
|
||||
branchId: string
|
||||
): UpdateBranchObject {
|
||||
): Branch {
|
||||
const updateBranch = getUpdateBranchNullable(channel, branchId);
|
||||
if (!updateBranch) {
|
||||
throw new Error(
|
||||
|
||||
@@ -68,6 +68,7 @@ export default class BuildVersionGetView extends EasCommand {
|
||||
easJsonAccessor,
|
||||
platforms,
|
||||
profileName: flags.profile ?? undefined,
|
||||
projectDir,
|
||||
});
|
||||
const results: { [key in Platform]?: string } = {};
|
||||
for (const { profile, platform } of buildProfiles) {
|
||||
|
||||
@@ -80,6 +80,7 @@ export default class BuildVersionSyncView extends EasCommand {
|
||||
easJsonAccessor,
|
||||
platforms,
|
||||
profileName: flags.profile ?? undefined,
|
||||
projectDir,
|
||||
});
|
||||
for (const profileInfo of buildProfiles) {
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { createUpdateBranchOnAppAsync } from '../../branch/queries';
|
||||
import { BranchNotFoundError } from '../../branch/utils';
|
||||
import { createChannelOnAppAsync } from '../../channel/queries';
|
||||
import { createAndLinkChannelAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import Log from '../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
export default class ChannelCreate extends EasCommand {
|
||||
static override description = 'create a channel';
|
||||
@@ -60,69 +55,13 @@ export default class ChannelCreate extends EasCommand {
|
||||
}));
|
||||
}
|
||||
|
||||
let branchId: string;
|
||||
let branchMessage: string;
|
||||
|
||||
try {
|
||||
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name: channelName,
|
||||
});
|
||||
branchId = branch.id;
|
||||
branchMessage = `We found a branch with the same name`;
|
||||
} catch (error) {
|
||||
if (error instanceof BranchNotFoundError) {
|
||||
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name: channelName,
|
||||
});
|
||||
branchId = newBranch.id;
|
||||
branchMessage = `We also went ahead and made a branch with the same name`;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
updateChannel: { createUpdateChannelForApp: newChannel },
|
||||
} = await createChannelOnAppAsync(graphqlClient, {
|
||||
await createAndLinkChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName,
|
||||
branchId,
|
||||
shouldPrintJson: jsonFlag,
|
||||
});
|
||||
|
||||
if (!newChannel) {
|
||||
throw new Error(
|
||||
`Could not create channel with name ${channelName} on project with id ${projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(newChannel);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.withTick(
|
||||
`Created a new channel on project ${chalk.bold(
|
||||
await getDisplayNameForProjectIdAsync(graphqlClient, projectId)
|
||||
)}`
|
||||
);
|
||||
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!'));
|
||||
}
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('You can now update your app by publishing!'));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import { hasEmptyBranchMap, hasStandardBranchMap } from '../../channel/branch-mapping';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -18,6 +19,7 @@ import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import { UpdateChannelBasicInfoFragmentNode } from '../../graphql/types/UpdateChannelBasicInfo';
|
||||
import Log from '../../log';
|
||||
import { isRollout } from '../../rollout/branch-mapping';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export async function updateChannelBranchMappingAsync(
|
||||
@@ -98,8 +100,10 @@ export default class ChannelEdit extends EasCommand {
|
||||
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
|
||||
});
|
||||
|
||||
if (existingChannel.updateBranches.length > 1) {
|
||||
if (isRollout(existingChannel)) {
|
||||
throw new Error('There is a rollout in progress. Manage it with "channel:rollout" instead.');
|
||||
} else if (!hasStandardBranchMap(existingChannel) && !hasEmptyBranchMap(existingChannel)) {
|
||||
throw new Error('Only standard branch mappings can be edited with this command.');
|
||||
}
|
||||
|
||||
const branch = branchFlag
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
|
||||
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
|
||||
import {
|
||||
EndOutcome,
|
||||
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
|
||||
} from '../../rollout/actions/EndRollout';
|
||||
import { ManageRolloutActions } from '../../rollout/actions/ManageRollout';
|
||||
import { NonInteractiveRollout } from '../../rollout/actions/NonInteractiveRollout';
|
||||
import {
|
||||
MainMenuActions,
|
||||
RolloutActions,
|
||||
RolloutMainMenu,
|
||||
} from '../../rollout/actions/RolloutMainMenu';
|
||||
|
||||
enum ActionRawFlagValue {
|
||||
CREATE = 'create',
|
||||
EDIT = 'edit',
|
||||
END = 'end',
|
||||
}
|
||||
type ChannelRolloutRawArgsAndFlags = {
|
||||
channel?: string;
|
||||
action?: ActionRawFlagValue;
|
||||
percent?: number;
|
||||
outcome?: EndOutcome;
|
||||
'non-interactive': boolean;
|
||||
branch?: string;
|
||||
'runtime-version'?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type ChannelRolloutArgsAndFlags = {
|
||||
channelName?: string;
|
||||
action?: RolloutActions;
|
||||
nonInteractive: boolean;
|
||||
json?: boolean;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
Partial<CreateRolloutNonInteractiveOptions>;
|
||||
|
||||
export default class ChannelRolloutPreview extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'Roll a new branch out on a channel incrementally.';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'channel on which the rollout should be done',
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
action: Flags.enum({
|
||||
description: 'Rollout action to perform',
|
||||
options: Object.values(ActionRawFlagValue),
|
||||
required: false,
|
||||
}),
|
||||
percent: Flags.integer({
|
||||
description:
|
||||
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
|
||||
required: false,
|
||||
}),
|
||||
outcome: Flags.enum({
|
||||
description: 'End outcome of rollout. Use with --action=end',
|
||||
options: Object.values(EndOutcome),
|
||||
required: false,
|
||||
}),
|
||||
branch: Flags.string({
|
||||
description: 'Branch to rollout. Use with --action=create',
|
||||
required: false,
|
||||
}),
|
||||
'runtime-version': Flags.string({
|
||||
description: 'Runtime version to target. Use with --action=create',
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ChannelRolloutPreview);
|
||||
const argsAndFlags = this.sanitizeArgsAndFlags({ ...flags, ...args });
|
||||
const {
|
||||
privateProjectConfig: { exp, projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelRolloutPreview, {
|
||||
nonInteractive: argsAndFlags.nonInteractive,
|
||||
});
|
||||
if (argsAndFlags.json) {
|
||||
// TODO(quin): implement json output
|
||||
throw new Error('Developer Preview doesnt support JSON output yet');
|
||||
}
|
||||
|
||||
const app = { projectId, exp };
|
||||
const ctx = {
|
||||
projectId,
|
||||
nonInteractive: argsAndFlags.nonInteractive,
|
||||
graphqlClient,
|
||||
app,
|
||||
};
|
||||
if (argsAndFlags.nonInteractive) {
|
||||
await new NonInteractiveRollout(argsAndFlags).runAsync(ctx);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`✨ This command is in Developer Preview and has not been released to production yet`
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
await new RolloutMainMenu(argsAndFlags).runAsync(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private getAction(action: ActionRawFlagValue): RolloutActions {
|
||||
switch (action) {
|
||||
case ActionRawFlagValue.CREATE:
|
||||
return MainMenuActions.CREATE_NEW;
|
||||
case ActionRawFlagValue.EDIT:
|
||||
return ManageRolloutActions.EDIT;
|
||||
case ActionRawFlagValue.END:
|
||||
return ManageRolloutActions.END;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeArgsAndFlags(
|
||||
rawFlags: ChannelRolloutRawArgsAndFlags
|
||||
): ChannelRolloutArgsAndFlags {
|
||||
const action = rawFlags.action;
|
||||
return {
|
||||
channelName: rawFlags.channel,
|
||||
percent: rawFlags.percent,
|
||||
outcome: rawFlags.outcome,
|
||||
branchNameToRollout: rawFlags.branch,
|
||||
runtimeVersion: rawFlags['runtime-version'],
|
||||
action: action ? this.getAction(action) : undefined,
|
||||
nonInteractive: rawFlags['non-interactive'],
|
||||
json: rawFlags.json,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,306 +1,51 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import {
|
||||
BranchMapping,
|
||||
BranchMappingObject,
|
||||
assertNodeObject,
|
||||
assertNumber,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import { getBranchMapping } from '../../channel/utils';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { UpdateBranch } from '../../graphql/generated';
|
||||
import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync, selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { updateChannelBranchMappingAsync } from './edit';
|
||||
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
|
||||
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
|
||||
import {
|
||||
EndOutcome,
|
||||
GeneralOptions as EndRolloutGeneralOptions,
|
||||
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
|
||||
} from '../../rollout/actions/EndRollout';
|
||||
import { ManageRolloutActions } from '../../rollout/actions/ManageRollout';
|
||||
import { NonInteractiveRollout } from '../../rollout/actions/NonInteractiveRollout';
|
||||
import {
|
||||
MainMenuActions,
|
||||
RolloutActions,
|
||||
RolloutMainMenu,
|
||||
} from '../../rollout/actions/RolloutMainMenu';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
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;
|
||||
enum ActionRawFlagValue {
|
||||
CREATE = 'create',
|
||||
EDIT = 'edit',
|
||||
END = 'end',
|
||||
VIEW = 'view',
|
||||
}
|
||||
type ChannelRolloutRawArgsAndFlags = {
|
||||
channel?: string;
|
||||
action?: ActionRawFlagValue;
|
||||
percent?: number;
|
||||
outcome?: EndOutcome;
|
||||
'non-interactive': boolean;
|
||||
branch?: string;
|
||||
'runtime-version'?: string;
|
||||
'private-key-path'?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
function getRolloutInfo(channel: UpdateChannelObject): {
|
||||
newBranch: Pick<UpdateBranch, 'name' | 'id'>;
|
||||
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
|
||||
currentPercent: number;
|
||||
} {
|
||||
const { branchMapping } = getBranchMapping(channel.branchMapping);
|
||||
const [newBranchId, oldBranchId] = branchMapping.data.map(d => d.branchId);
|
||||
const newBranch = channel.updateBranches.filter(branch => branch.id === newBranchId)[0];
|
||||
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
|
||||
|
||||
if (!newBranch || !oldBranch) {
|
||||
throw new Error(`Branch mapping rollout is missing a branch for channel "${channel.name}".`);
|
||||
}
|
||||
|
||||
const branchMappingNode = branchMapping.data[0]?.branchMappingLogic;
|
||||
assertNodeObject(branchMappingNode);
|
||||
assertNumber(branchMappingNode.operand);
|
||||
const currentPercent = 100 * branchMappingNode.operand;
|
||||
return { newBranch, oldBranch, currentPercent };
|
||||
}
|
||||
|
||||
async function startRolloutAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
channelName,
|
||||
branchName,
|
||||
percent,
|
||||
projectId,
|
||||
displayName,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
nonInteractive,
|
||||
}: {
|
||||
channelName?: string;
|
||||
branchName: string;
|
||||
percent?: number;
|
||||
projectId: string;
|
||||
displayName: string;
|
||||
currentBranchMapping: BranchMapping;
|
||||
channel: UpdateChannelObject;
|
||||
nonInteractive: boolean;
|
||||
}
|
||||
): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
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 (nonInteractive) {
|
||||
throw new Error(
|
||||
'You must specify a percent with the --percent flag when initiating a rollout with the --non-interactive 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(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
|
||||
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
|
||||
if (!oldBranch) {
|
||||
throw new Error(
|
||||
`Branch mapping is missing its only branch for channel "${channelName}" on app "${displayName}"`
|
||||
);
|
||||
}
|
||||
|
||||
const logMessage = `Started a rollout of branch ${chalk.bold(branchName)} on 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(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
channelName,
|
||||
percent,
|
||||
nonInteractive,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
}: {
|
||||
channelName?: string;
|
||||
percent?: number;
|
||||
nonInteractive: boolean;
|
||||
currentBranchMapping: BranchMapping;
|
||||
channel: UpdateChannelObject;
|
||||
}
|
||||
): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
|
||||
|
||||
if (percent == null) {
|
||||
if (nonInteractive) {
|
||||
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 as BranchMappingObject).operand = percent / 100;
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
|
||||
const logMessage = `Rollout of branch ${chalk.bold(newBranch.name)} on 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(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
channelName,
|
||||
branchName,
|
||||
nonInteractive,
|
||||
projectId,
|
||||
channel,
|
||||
}: {
|
||||
channelName?: string;
|
||||
branchName?: string;
|
||||
nonInteractive: boolean;
|
||||
projectId: string;
|
||||
channel: UpdateChannelObject;
|
||||
}
|
||||
): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
// end rollout
|
||||
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
|
||||
|
||||
let endOnNewBranch;
|
||||
if (branchName) {
|
||||
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
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 (nonInteractive) {
|
||||
throw new Error(
|
||||
'Branch name must be specified with the --branch flag when both the --end and --non-interactive 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: ${currentPercent}%`)}`,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
title: `${oldBranch.name} ${chalk.grey(`- current percent: ${100 - 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(graphqlClient, {
|
||||
channelId: channel.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 };
|
||||
}
|
||||
type ChannelRolloutArgsAndFlags = {
|
||||
channelName?: string;
|
||||
action?: RolloutActions;
|
||||
nonInteractive: boolean;
|
||||
json?: boolean;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
EndRolloutGeneralOptions &
|
||||
Partial<CreateRolloutNonInteractiveOptions>;
|
||||
|
||||
export default class ChannelRollout extends EasCommand {
|
||||
static override description = 'Roll a new branch out on a channel incrementally.';
|
||||
@@ -313,17 +58,68 @@ export default class ChannelRollout extends EasCommand {
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
branch: Flags.string({
|
||||
description: 'branch to rollout',
|
||||
action: Flags.enum({
|
||||
description: 'Rollout action to perform',
|
||||
options: Object.values(ActionRawFlagValue),
|
||||
required: false,
|
||||
relationships: [
|
||||
{
|
||||
type: 'all',
|
||||
flags: [
|
||||
{
|
||||
name: 'percent',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags => {
|
||||
return (
|
||||
!!flags['non-interactive'] &&
|
||||
(flags['action'] === ActionRawFlagValue.CREATE ||
|
||||
flags['action'] === ActionRawFlagValue.EDIT)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'outcome',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags =>
|
||||
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.END,
|
||||
},
|
||||
{
|
||||
name: 'branch',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags =>
|
||||
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.CREATE,
|
||||
},
|
||||
{
|
||||
name: 'runtime-version',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags =>
|
||||
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.CREATE,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
percent: Flags.integer({
|
||||
description: 'percent of users to send to the new branch',
|
||||
description:
|
||||
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
|
||||
required: false,
|
||||
}),
|
||||
end: Flags.boolean({
|
||||
description: 'end the rollout',
|
||||
default: false,
|
||||
outcome: Flags.enum({
|
||||
description: 'End outcome of rollout. Use with --action=end',
|
||||
options: Object.values(EndOutcome),
|
||||
required: false,
|
||||
}),
|
||||
branch: Flags.string({
|
||||
description: 'Branch to roll out. Use with --action=create',
|
||||
required: false,
|
||||
}),
|
||||
'runtime-version': Flags.string({
|
||||
description: 'Runtime version to target. Use with --action=create',
|
||||
required: false,
|
||||
}),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory.`,
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -334,164 +130,64 @@ export default class ChannelRollout extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ChannelRollout);
|
||||
const argsAndFlags = this.sanitizeArgsAndFlags({ ...flags, ...args });
|
||||
const {
|
||||
args: { channel: channelNameArg },
|
||||
flags: {
|
||||
json: jsonFlag,
|
||||
end: endFlag,
|
||||
branch: branchName,
|
||||
percent,
|
||||
'non-interactive': nonInteractive,
|
||||
},
|
||||
} = await this.parse(ChannelRollout);
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
privateProjectConfig: { exp, projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelRollout, {
|
||||
nonInteractive,
|
||||
nonInteractive: argsAndFlags.nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
if (argsAndFlags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
let channelName: string = channelNameArg;
|
||||
if (!channelName) {
|
||||
const { name } = await selectChannelOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
selectionPromptTitle: 'Select a channel on which to perform a rollout',
|
||||
paginatedQueryOptions: {
|
||||
json: jsonFlag,
|
||||
nonInteractive,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
channelName = name;
|
||||
}
|
||||
|
||||
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName,
|
||||
});
|
||||
if (!channel) {
|
||||
throw new Error(
|
||||
`Could not find a channel named "${channelName}". Check which channels exist on this project with ${chalk.bold(
|
||||
'eas channel:list'
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
const { branchMapping: currentBranchMapping, isRollout } = getBranchMapping(
|
||||
channel.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;
|
||||
const app = { projectId, exp };
|
||||
const ctx = {
|
||||
projectId,
|
||||
nonInteractive: argsAndFlags.nonInteractive,
|
||||
graphqlClient,
|
||||
app,
|
||||
};
|
||||
if (!isRollout) {
|
||||
rolloutMutationResult = await startRolloutAsync(graphqlClient, {
|
||||
channelName,
|
||||
branchName:
|
||||
branchName ??
|
||||
(await promptForBranchNameAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
channelName,
|
||||
nonInteractive,
|
||||
json: jsonFlag,
|
||||
})),
|
||||
percent,
|
||||
nonInteractive,
|
||||
projectId,
|
||||
displayName: projectDisplayName,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
});
|
||||
} else if (endFlag) {
|
||||
rolloutMutationResult = await endRolloutAsync(graphqlClient, {
|
||||
channelName,
|
||||
branchName,
|
||||
nonInteractive,
|
||||
projectId,
|
||||
channel,
|
||||
});
|
||||
if (argsAndFlags.nonInteractive) {
|
||||
await new NonInteractiveRollout(argsAndFlags).runAsync(ctx);
|
||||
} else {
|
||||
rolloutMutationResult = await editRolloutAsync(graphqlClient, {
|
||||
channelName,
|
||||
percent,
|
||||
nonInteractive,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
if (!rolloutMutationResult) {
|
||||
throw new Error('rollout result is empty');
|
||||
}
|
||||
const { newChannelInfo, logMessage } = rolloutMutationResult;
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(newChannelInfo);
|
||||
} else {
|
||||
Log.withTick(logMessage);
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`✨ This command is in Developer Preview and has not been released to production yet. Website support is coming soon.`
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
await new RolloutMainMenu(argsAndFlags).runAsync(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promptForBranchNameAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
channelName,
|
||||
nonInteractive,
|
||||
json,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
channelName: string;
|
||||
nonInteractive: boolean;
|
||||
json: boolean;
|
||||
}): Promise<string> {
|
||||
if (nonInteractive) {
|
||||
throw new Error('Must supply branch flag in non-interactive mode');
|
||||
private getAction(action: ActionRawFlagValue): RolloutActions {
|
||||
switch (action) {
|
||||
case ActionRawFlagValue.CREATE:
|
||||
return MainMenuActions.CREATE_NEW;
|
||||
case ActionRawFlagValue.EDIT:
|
||||
return ManageRolloutActions.EDIT;
|
||||
case ActionRawFlagValue.END:
|
||||
return ManageRolloutActions.END;
|
||||
case ActionRawFlagValue.VIEW:
|
||||
return ManageRolloutActions.VIEW;
|
||||
}
|
||||
}
|
||||
|
||||
const { name: branchName } = await selectBranchOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
promptTitle: `Which branch would you like roll out on ${channelName}?`,
|
||||
displayTextForListItem: updateBranch => ({
|
||||
title: updateBranch.name,
|
||||
}),
|
||||
// discard limit and offset because this query is not their intended target
|
||||
paginatedQueryOptions: {
|
||||
json,
|
||||
nonInteractive,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return branchName;
|
||||
private sanitizeArgsAndFlags(
|
||||
rawFlags: ChannelRolloutRawArgsAndFlags
|
||||
): ChannelRolloutArgsAndFlags {
|
||||
const action = rawFlags.action;
|
||||
return {
|
||||
channelName: rawFlags.channel,
|
||||
percent: rawFlags.percent,
|
||||
outcome: rawFlags.outcome,
|
||||
branchNameToRollout: rawFlags.branch,
|
||||
runtimeVersion: rawFlags['runtime-version'],
|
||||
privateKeyPath: rawFlags['private-key-path'] ?? null,
|
||||
action: action ? this.getAction(action) : undefined,
|
||||
nonInteractive: rawFlags['non-interactive'],
|
||||
json: rawFlags.json,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export default class MetadataLint extends EasCommand {
|
||||
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
|
||||
platforms: [Platform.IOS],
|
||||
profileName: flags.profile,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
if (submitProfiles.length !== 1) {
|
||||
|
||||
@@ -49,6 +49,7 @@ export default class MetadataPull extends EasCommand {
|
||||
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
|
||||
platforms: [Platform.IOS],
|
||||
profileName: flags.profile,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
if (submitProfiles.length !== 1) {
|
||||
|
||||
@@ -47,6 +47,7 @@ export default class MetadataPush extends EasCommand {
|
||||
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
|
||||
platforms: [Platform.IOS],
|
||||
profileName: flags.profile,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
if (submitProfiles.length !== 1) {
|
||||
|
||||
@@ -118,6 +118,7 @@ export default class Submit extends EasCommand {
|
||||
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
|
||||
platforms,
|
||||
profileName: flagsWithPlatform.profile,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
const submissions: SubmissionFragment[] = [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import { vol } from 'memfs';
|
||||
import { AppJSONConfig, ExpoConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import { DirectoryJSON, vol } from 'memfs';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
|
||||
@@ -8,12 +8,17 @@ import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/cr
|
||||
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
|
||||
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
|
||||
import { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { CodeSigningInfo, UpdateFragment } from '../../../graphql/generated';
|
||||
import { UpdateFragment } from '../../../graphql/generated';
|
||||
import { PublishMutation } from '../../../graphql/mutations/PublishMutation';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
|
||||
import { getBranchNameFromChannelNameAsync } from '../../../update/getBranchNameFromChannelNameAsync';
|
||||
import { selectUpdateGroupOnBranchAsync } from '../../../update/queries';
|
||||
import {
|
||||
getCodeSigningInfoAsync,
|
||||
getManifestBodyAsync,
|
||||
signBody,
|
||||
} from '../../../utils/code-signing';
|
||||
import UpdateRepublish from '../republish';
|
||||
|
||||
const projectRoot = '/test-project';
|
||||
@@ -33,12 +38,6 @@ const updateStub: UpdateFragment = {
|
||||
createdAt: '2022-01-01T12:00:00Z',
|
||||
};
|
||||
|
||||
const codeSigningStub: CodeSigningInfo = {
|
||||
keyid: 'keyid',
|
||||
alg: 'alg',
|
||||
sig: 'sig',
|
||||
};
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
|
||||
@@ -52,16 +51,12 @@ jest.mock('../../../ora', () => ({
|
||||
start: () => ({ succeed: () => {}, fail: () => {} }),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../utils/code-signing');
|
||||
jest.mock('../../../fetch');
|
||||
|
||||
describe(UpdateRepublish.name, () => {
|
||||
afterEach(() => vol.reset());
|
||||
|
||||
it('errors without --channel, --branch, or --group', async () => {
|
||||
await expect(new UpdateRepublish([], commandOptions).run()).rejects.toThrow(
|
||||
'--channel, --branch, or --group must be specified'
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when providing both --group and --branch', async () => {
|
||||
const flags = ['--group=1234', '--branch=main'];
|
||||
|
||||
@@ -156,18 +151,45 @@ describe(UpdateRepublish.name, () => {
|
||||
});
|
||||
|
||||
it('re-creates update using codesigning with --group and --message', async () => {
|
||||
const flags = ['--group=1234', '--message=test-republish'];
|
||||
const flags = [
|
||||
'--group=1234',
|
||||
'--message=test-republish',
|
||||
`--private-key-path=./keys/test-private-key.pem`,
|
||||
];
|
||||
const codeSigning = {
|
||||
alg: 'alg',
|
||||
alg: 'rsa-v1_5-sha256',
|
||||
keyid: 'keyid',
|
||||
sig: 'sig',
|
||||
};
|
||||
|
||||
mockTestProject();
|
||||
mockTestProject({
|
||||
extraManifest: {
|
||||
updates: {
|
||||
codeSigningCertificate: './keys/test-certificate.pem',
|
||||
codeSigningMetadata: {
|
||||
alg: 'rsa-v1_5-sha256',
|
||||
keyid: 'keyid',
|
||||
},
|
||||
},
|
||||
},
|
||||
extraVol: {
|
||||
'./keys/test-private-key.pem': 'testpemprivate',
|
||||
'./keys/test-certificate.pem': 'testpemcertificate',
|
||||
},
|
||||
});
|
||||
|
||||
jest.mocked(getCodeSigningInfoAsync).mockResolvedValue({
|
||||
privateKey: '' as any,
|
||||
certificate: '' as any,
|
||||
codeSigningMetadata: { alg: 'rsa-v1_5-sha256', keyid: 'keyid' },
|
||||
});
|
||||
jest.mocked(getManifestBodyAsync).mockResolvedValue('test');
|
||||
jest.mocked(signBody).mockReturnValue('sig');
|
||||
|
||||
// Mock queries to retrieve the update and code signing info
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, codeSigningInfo: codeSigningStub }]);
|
||||
.mockResolvedValue([{ ...updateStub, codeSigningInfo: codeSigning }]);
|
||||
// Mock mutations to store the new update
|
||||
jest.mocked(PublishMutation.publishUpdateGroupAsync).mockResolvedValue([
|
||||
{
|
||||
@@ -287,8 +309,12 @@ describe(UpdateRepublish.name, () => {
|
||||
/** Create a new in-memory project, based on src/commands/project/__tests__/init.test.ts */
|
||||
function mockTestProject({
|
||||
configuredProjectId = '1234',
|
||||
extraManifest,
|
||||
extraVol,
|
||||
}: {
|
||||
configuredProjectId?: string;
|
||||
extraManifest?: Partial<ExpoConfig>;
|
||||
extraVol?: DirectoryJSON;
|
||||
} = {}): void {
|
||||
const packageJSON: PackageJSONConfig = {
|
||||
name: 'testing123',
|
||||
@@ -309,6 +335,7 @@ function mockTestProject({
|
||||
projectId: configuredProjectId,
|
||||
},
|
||||
},
|
||||
...extraManifest,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -316,6 +343,7 @@ function mockTestProject({
|
||||
{
|
||||
'package.json': JSON.stringify(packageJSON),
|
||||
'app.json': JSON.stringify(appJSON),
|
||||
...extraVol,
|
||||
},
|
||||
projectRoot
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
getRuntimeVersionObjectAsync,
|
||||
getUpdateMessageForCommandAsync,
|
||||
isUploadedAssetCountAboveWarningThreshold,
|
||||
platformDisplayNames,
|
||||
resolveInputDirectoryAsync,
|
||||
uploadAssetsAsync,
|
||||
} from '../../project/publish';
|
||||
@@ -55,6 +56,7 @@ import { getVcsClient } from '../../vcs';
|
||||
|
||||
type RawUpdateFlags = {
|
||||
auto: boolean;
|
||||
dev: boolean;
|
||||
branch?: string;
|
||||
channel?: string;
|
||||
message?: string;
|
||||
@@ -73,6 +75,7 @@ type RawUpdateFlags = {
|
||||
|
||||
type UpdateFlags = {
|
||||
auto: boolean;
|
||||
dev: boolean;
|
||||
platform: ExpoCLIExportPlatformFlag;
|
||||
branchName?: string;
|
||||
channelName?: string;
|
||||
@@ -133,6 +136,10 @@ export default class UpdatePublish extends EasCommand {
|
||||
default: 'all',
|
||||
required: false,
|
||||
}),
|
||||
dev: Flags.boolean({
|
||||
description: 'Publish a development bundle',
|
||||
default: false,
|
||||
}),
|
||||
auto: Flags.boolean({
|
||||
description:
|
||||
'Use the current git branch and commit message for the EAS branch and update message',
|
||||
@@ -157,6 +164,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
auto: autoFlag,
|
||||
platform: platformFlag,
|
||||
channelName: channelNameArg,
|
||||
dev,
|
||||
updateMessage: updateMessageArg,
|
||||
inputDir,
|
||||
skipBundler,
|
||||
@@ -219,7 +227,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
if (!skipBundler) {
|
||||
const bundleSpinner = ora().start('Exporting...');
|
||||
try {
|
||||
await buildBundlesAsync({ projectDir, inputDir, exp, platformFlag, clearCache });
|
||||
await buildBundlesAsync({ projectDir, inputDir, dev, exp, platformFlag, clearCache });
|
||||
bundleSpinner.succeed('Exported bundle(s)');
|
||||
} catch (e) {
|
||||
bundleSpinner.fail('Export failed');
|
||||
@@ -325,6 +333,21 @@ export default class UpdatePublish extends EasCommand {
|
||||
for (const uploadedAssetPath of uploadResults.uniqueUploadedAssetPaths) {
|
||||
Log.debug(chalk.dim(`- ${uploadedAssetPath}`));
|
||||
}
|
||||
|
||||
const platformString = (Object.keys(assets) as PublishPlatform[])
|
||||
.map(platform => {
|
||||
const collectedAssetForPlatform = nullthrows(assets[platform]);
|
||||
const totalAssetsForPlatform = collectedAssetForPlatform.assets.length + 1; // launch asset
|
||||
const assetString = totalAssetsForPlatform === 1 ? 'asset' : 'assets';
|
||||
return `${totalAssetsForPlatform} ${platformDisplayNames[platform]} ${assetString}`;
|
||||
})
|
||||
.join(', ');
|
||||
Log.withInfo(
|
||||
`${platformString} (maximum: ${assetLimitPerUpdateGroup} total per update). ${learnMore(
|
||||
'https://expo.fyi/eas-update-asset-limits',
|
||||
{ learnMoreMessage: 'Learn more about asset limits.' }
|
||||
)}`
|
||||
);
|
||||
} catch (e) {
|
||||
assetSpinner.fail('Failed to upload');
|
||||
throw e;
|
||||
@@ -344,10 +367,11 @@ export default class UpdatePublish extends EasCommand {
|
||||
branchId,
|
||||
channelName: branchName,
|
||||
});
|
||||
Log.withTick(
|
||||
`Channel: ${chalk.bold(branchName)} pointed at branch: ${chalk.bold(branchName)}`
|
||||
);
|
||||
}
|
||||
|
||||
Log.withTick(`Channel: ${chalk.bold(branchName)} pointed at branch: ${chalk.bold(branchName)}`);
|
||||
|
||||
const vcsClient = getVcsClient();
|
||||
|
||||
const gitCommitHash = await vcsClient.getCommitHashAsync();
|
||||
@@ -507,7 +531,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
private sanitizeFlags(flags: RawUpdateFlags): UpdateFlags {
|
||||
const nonInteractive = flags['non-interactive'] ?? false;
|
||||
|
||||
const { auto, branch: branchName, channel: channelName, message: updateMessage } = flags;
|
||||
const { auto, branch: branchName, channel: channelName, dev, message: updateMessage } = flags;
|
||||
if (nonInteractive && !auto && !(updateMessage && (branchName || channelName))) {
|
||||
Errors.error(
|
||||
'--branch and --message, or --channel and --message are required when updating in non-interactive mode unless --auto is specified',
|
||||
@@ -536,6 +560,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
auto,
|
||||
branchName,
|
||||
channelName,
|
||||
dev,
|
||||
updateMessage,
|
||||
inputDir: flags['input-dir'],
|
||||
skipBundler: flags['skip-bundler'],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Platform } from '@expo/config';
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
@@ -12,6 +14,7 @@ import { getBranchNameFromChannelNameAsync } from '../../update/getBranchNameFro
|
||||
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
|
||||
import { UpdateToRepublish, republishAsync } from '../../update/republish';
|
||||
import { truncateString as truncateUpdateMessage } from '../../update/utils';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
const defaultRepublishPlatforms: Platform[] = ['android', 'ios'];
|
||||
@@ -22,6 +25,7 @@ type UpdateRepublishRawFlags = {
|
||||
group?: string;
|
||||
message?: string;
|
||||
platform: string;
|
||||
'private-key-path'?: string;
|
||||
'non-interactive': boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
@@ -32,6 +36,7 @@ type UpdateRepublishFlags = {
|
||||
groupId?: string;
|
||||
updateMessage?: string;
|
||||
platform: Platform[];
|
||||
privateKeyPath?: string;
|
||||
nonInteractive: boolean;
|
||||
json: boolean;
|
||||
};
|
||||
@@ -41,11 +46,11 @@ export default class UpdateRepublish extends EasCommand {
|
||||
|
||||
static override flags = {
|
||||
channel: Flags.string({
|
||||
description: 'Channel name to select an update to republish from',
|
||||
description: 'Channel name to select an update group to republish from',
|
||||
exclusive: ['branch', 'group'],
|
||||
}),
|
||||
branch: Flags.string({
|
||||
description: 'Branch name to select an update to republish from',
|
||||
description: 'Branch name to select an update group to republish from',
|
||||
exclusive: ['channel', 'group'],
|
||||
}),
|
||||
group: Flags.string({
|
||||
@@ -54,7 +59,7 @@ export default class UpdateRepublish extends EasCommand {
|
||||
}),
|
||||
message: Flags.string({
|
||||
char: 'm',
|
||||
description: 'Short message describing the republished update',
|
||||
description: 'Short message describing the republished update group',
|
||||
required: false,
|
||||
}),
|
||||
platform: Flags.enum({
|
||||
@@ -63,6 +68,10 @@ export default class UpdateRepublish extends EasCommand {
|
||||
default: 'all',
|
||||
required: false,
|
||||
}),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory.`,
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -86,6 +95,8 @@ export default class UpdateRepublish extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
|
||||
|
||||
const existingUpdates = await getOrAskUpdatesAsync(graphqlClient, projectId, flags);
|
||||
const updatesToPublish = existingUpdates.filter(update =>
|
||||
flags.platform.includes(update.platform as Platform)
|
||||
@@ -105,15 +116,15 @@ export default class UpdateRepublish extends EasCommand {
|
||||
}
|
||||
|
||||
if (rawFlags.platform === 'all') {
|
||||
Log.withTick(`The republished update will appear only on: ${rawFlags.platform}`);
|
||||
Log.withTick(`The republished update group will appear only on: ${rawFlags.platform}`);
|
||||
} else {
|
||||
const platformsFromUpdates = updatesToPublish.map(update => update.platform);
|
||||
if (platformsFromUpdates.length < defaultRepublishPlatforms.length) {
|
||||
Log.warn(`You are republishing an update that wasn't published for all platforms.`);
|
||||
Log.warn(`You are republishing an update group that wasn't published for all platforms.`);
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`The republished update will appear on the same platforms it was originally published on: ${platformsFromUpdates.join(
|
||||
`The republished update group will appear on the same platforms it was originally published on: ${platformsFromUpdates.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
@@ -127,22 +138,21 @@ export default class UpdateRepublish extends EasCommand {
|
||||
updatesToPublish,
|
||||
targetBranch: { branchId: arbitraryUpdate.branchId, branchName: arbitraryUpdate.branchName },
|
||||
updateMessage,
|
||||
codeSigningInfo,
|
||||
json: flags.json,
|
||||
});
|
||||
}
|
||||
|
||||
sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
|
||||
private sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
|
||||
const branchName = rawFlags.branch;
|
||||
const channelName = rawFlags.channel;
|
||||
const groupId = rawFlags.group;
|
||||
const nonInteractive = rawFlags['non-interactive'];
|
||||
const privateKeyPath = rawFlags['private-key-path'];
|
||||
|
||||
if (nonInteractive && !groupId) {
|
||||
throw new Error('Only --group can be used in non-interactive mode');
|
||||
}
|
||||
if (!groupId && !(branchName || channelName)) {
|
||||
throw new Error(`--channel, --branch, or --group must be specified`);
|
||||
}
|
||||
|
||||
const platform =
|
||||
rawFlags.platform === 'all' ? defaultRepublishPlatforms : ([rawFlags.platform] as Platform[]);
|
||||
@@ -153,6 +163,7 @@ export default class UpdateRepublish extends EasCommand {
|
||||
groupId,
|
||||
platform,
|
||||
updateMessage: rawFlags.message,
|
||||
privateKeyPath,
|
||||
json: rawFlags.json ?? false,
|
||||
nonInteractive,
|
||||
};
|
||||
@@ -178,6 +189,10 @@ async function getOrAskUpdatesAsync(
|
||||
}));
|
||||
}
|
||||
|
||||
if (flags.nonInteractive) {
|
||||
throw new Error('Must supply --group when in non-interactive mode');
|
||||
}
|
||||
|
||||
if (flags.branchName) {
|
||||
return await askUpdatesFromBranchNameAsync(graphqlClient, {
|
||||
...flags,
|
||||
@@ -194,7 +209,55 @@ async function getOrAskUpdatesAsync(
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('--channel, --branch, or --group is required');
|
||||
const { choice } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Find update by branch or channel?',
|
||||
name: 'choice',
|
||||
choices: [
|
||||
{ title: 'Branch', value: 'branch' },
|
||||
{ title: 'Channel', value: 'channel' },
|
||||
],
|
||||
});
|
||||
|
||||
if (choice === 'channel') {
|
||||
const { name } = await selectChannelOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
selectionPromptTitle: 'Select a channel to view',
|
||||
paginatedQueryOptions: {
|
||||
json: flags.json,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return await askUpdatesFromChannelNameAsync(graphqlClient, {
|
||||
...flags,
|
||||
channelName: name,
|
||||
projectId,
|
||||
});
|
||||
} else if (choice === 'branch') {
|
||||
const { name } = await selectBranchOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
promptTitle: 'Select branch from which to choose update',
|
||||
displayTextForListItem: updateBranch => ({
|
||||
title: updateBranch.name,
|
||||
}),
|
||||
// discard limit and offset because this query is not their intended target
|
||||
paginatedQueryOptions: {
|
||||
json: flags.json,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return await askUpdatesFromBranchNameAsync(graphqlClient, {
|
||||
...flags,
|
||||
branchName: name,
|
||||
projectId,
|
||||
});
|
||||
} else {
|
||||
throw new Error('Must choose update via channel or branch');
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the user which update needs to be republished by branch name, this requires interactive mode */
|
||||
@@ -207,10 +270,6 @@ async function askUpdatesFromBranchNameAsync(
|
||||
nonInteractive,
|
||||
}: { projectId: string; branchName: string; json: boolean; nonInteractive: boolean }
|
||||
): Promise<UpdateToRepublish[]> {
|
||||
if (nonInteractive) {
|
||||
throw new Error('Must supply --group when in non-interactive mode');
|
||||
}
|
||||
|
||||
const updateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
|
||||
projectId,
|
||||
branchName,
|
||||
@@ -234,10 +293,6 @@ async function askUpdatesFromChannelNameAsync(
|
||||
nonInteractive,
|
||||
}: { projectId: string; channelName: string; json: boolean; nonInteractive: boolean }
|
||||
): Promise<UpdateToRepublish[]> {
|
||||
if (nonInteractive) {
|
||||
throw new Error('Must supply --group when in non-interactive mode');
|
||||
}
|
||||
|
||||
const branchName = await getBranchNameFromChannelNameAsync(graphqlClient, projectId, channelName);
|
||||
|
||||
return await askUpdatesFromBranchNameAsync(graphqlClient, {
|
||||
|
||||
@@ -86,7 +86,11 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
}),
|
||||
platform: Flags.enum({
|
||||
char: 'p',
|
||||
options: [...defaultPublishPlatforms, 'all'],
|
||||
options: [
|
||||
// TODO: Add web when it's fully supported
|
||||
...defaultPublishPlatforms,
|
||||
'all',
|
||||
],
|
||||
default: 'all',
|
||||
required: false,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import UpdateRepublish from './republish';
|
||||
import UpdateRollBackToEmbedded from './roll-back-to-embedded';
|
||||
|
||||
export default class UpdateRollback extends EasCommand {
|
||||
static override description = 'roll back to an embedded update or an existing update';
|
||||
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory.`,
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(UpdateRollback);
|
||||
|
||||
const { choice } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Which type of update would you like to roll back to?',
|
||||
name: 'choice',
|
||||
choices: [
|
||||
{ title: 'Published Update', value: 'published' },
|
||||
{ title: 'Embedded Update', value: 'embedded' },
|
||||
],
|
||||
});
|
||||
|
||||
const privateKeyPathArg = flags['private-key-path']
|
||||
? ['--private-key-path', flags['private-key-path']]
|
||||
: [];
|
||||
if (choice === 'published') {
|
||||
await UpdateRepublish.run(privateKeyPathArg);
|
||||
} else {
|
||||
await UpdateRollBackToEmbedded.run(privateKeyPathArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../../project/projectUtils';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import { AndroidPackageNotDefinedError } from '../../errors';
|
||||
import { AppLookupParams } from '../api/GraphqlClient';
|
||||
|
||||
/**
|
||||
@@ -111,7 +112,7 @@ export async function getAppLookupParamsFromContextAsync(
|
||||
gradleContext
|
||||
);
|
||||
if (!androidApplicationIdentifier) {
|
||||
throw new Error(
|
||||
throw new AndroidPackageNotDefinedError(
|
||||
`android.package needs to be defined in your ${getProjectConfigDescription(
|
||||
ctx.projectDir
|
||||
)} file`
|
||||
|
||||
@@ -15,3 +15,9 @@ export class UnsupportedCredentialsChoiceError extends Error {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class AndroidPackageNotDefinedError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message ?? 'android.package needs to be defined in your app.json/app.config.js file');
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,23 @@ export class SetUpAdhocProvisioningProfile {
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Create (or update) app build credentials
|
||||
// 6. Compare selected devices with the ones actually provisioned
|
||||
const diffList = differenceBy(
|
||||
chosenDevices,
|
||||
appleProvisioningProfile.appleDevices,
|
||||
'identifier'
|
||||
);
|
||||
if (diffList && diffList.length > 0) {
|
||||
Log.warn(`Failed to provision ${diffList.length} of the selected devices:`);
|
||||
for (const missingDevice of diffList) {
|
||||
Log.warn(`- ${formatDeviceLabel(missingDevice)}`);
|
||||
}
|
||||
Log.log(
|
||||
'Most commonly devices fail to to be provisioned while they are still being processed by Apple, which can take up to 24-72 hours. Check your Apple Developer Portal page at https://developer.apple.com/account/resources/devices/list, the devices in "Processing" status cannot be provisioned yet'
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Create (or update) app build credentials
|
||||
assert(appleProvisioningProfile);
|
||||
return await assignBuildCredentialsAsync(
|
||||
ctx,
|
||||
|
||||
+136
-1
@@ -1,4 +1,38 @@
|
||||
import { doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import { EasJson } from '@expo/eas-json';
|
||||
|
||||
import { Analytics } from '../../../../analytics/AnalyticsManager';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
Account,
|
||||
AppleAppIdentifierFragment,
|
||||
AppleDevice,
|
||||
AppleDeviceClass,
|
||||
AppleDeviceFragment,
|
||||
AppleDistributionCertificateFragment,
|
||||
IosAppBuildCredentialsFragment,
|
||||
} from '../../../../graphql/generated';
|
||||
import Log from '../../../../log';
|
||||
import { getApplePlatformFromTarget } from '../../../../project/ios/target';
|
||||
import { Actor } from '../../../../user/User';
|
||||
import { CredentialsContext, CredentialsContextProjectInfo } from '../../../context';
|
||||
import { ProvisioningProfile } from '../../appstore/Credentials.types';
|
||||
import { ApplePlatform } from '../../appstore/constants';
|
||||
import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildCredentialsUtils';
|
||||
import { chooseDevicesAsync } from '../DeviceUtils';
|
||||
import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
|
||||
|
||||
jest.mock('../BuildCredentialsUtils');
|
||||
jest.mock('../../../context');
|
||||
jest.mock('../../../ios/api/GraphqlClient');
|
||||
jest.mock('../DeviceUtils', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
chooseDevicesAsync: jest.fn(),
|
||||
formatDeviceLabel: jest.requireActual('../DeviceUtils').formatDeviceLabel,
|
||||
};
|
||||
});
|
||||
jest.mock('../../../../project/ios/target');
|
||||
|
||||
describe(doUDIDsMatch, () => {
|
||||
it('return false if UDIDs do not match', () => {
|
||||
@@ -12,3 +46,104 @@ describe(doUDIDsMatch, () => {
|
||||
expect(doUDIDsMatch(udidsA, udidsB)).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('runWithDistributionCertificateAsync', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({
|
||||
app: { account: {} as Account, projectName: 'projName', bundleIdentifier: 'bundleId' },
|
||||
target: { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} },
|
||||
});
|
||||
describe('compare chosen and provisioned devices', () => {
|
||||
describe('not all devices provisioned', () => {
|
||||
it('displays warning to the user and lists the missing devices', async () => {
|
||||
const { ctx, distCert } = setUpTest();
|
||||
jest.mocked(getBuildCredentialsAsync).mockResolvedValue({
|
||||
provisioningProfile: {
|
||||
appleTeam: {},
|
||||
appleDevices: [{ identifier: 'id1' }],
|
||||
developerPortalIdentifier: 'provisioningProfileId',
|
||||
},
|
||||
} as IosAppBuildCredentialsFragment);
|
||||
const LogWarnSpy = jest.spyOn(Log, 'warn');
|
||||
const LogLogSpy = jest.spyOn(Log, 'log');
|
||||
const result = await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(
|
||||
ctx,
|
||||
distCert
|
||||
);
|
||||
expect(result).toEqual({} as IosAppBuildCredentialsFragment);
|
||||
expect(LogWarnSpy).toHaveBeenCalledTimes(3);
|
||||
expect(LogWarnSpy).toHaveBeenCalledWith('Failed to provision 2 of the selected devices:');
|
||||
expect(LogWarnSpy).toHaveBeenCalledWith('- id2 (iPhone) (Device 2)');
|
||||
expect(LogWarnSpy).toHaveBeenCalledWith('- id3 (Mac) (Device 3)');
|
||||
expect(LogLogSpy).toHaveBeenCalledTimes(1);
|
||||
expect(LogLogSpy).toHaveBeenCalledWith(
|
||||
'Most commonly devices fail to to be provisioned while they are still being processed by Apple, which can take up to 24-72 hours. Check your Apple Developer Portal page at https://developer.apple.com/account/resources/devices/list, the devices in "Processing" status cannot be provisioned yet'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('all devices provisioned', () => {
|
||||
it('does not display warning', async () => {
|
||||
const { ctx, distCert } = setUpTest();
|
||||
jest.mocked(getBuildCredentialsAsync).mockResolvedValue({
|
||||
provisioningProfile: {
|
||||
appleTeam: {},
|
||||
appleDevices: [{ identifier: 'id1' }, { identifier: 'id2' }, { identifier: 'id3' }],
|
||||
developerPortalIdentifier: 'provisioningProfileId',
|
||||
},
|
||||
} as IosAppBuildCredentialsFragment);
|
||||
const LogWarnSpy = jest.spyOn(Log, 'warn');
|
||||
const LogLogSpy = jest.spyOn(Log, 'log');
|
||||
const result = await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(
|
||||
ctx,
|
||||
distCert
|
||||
);
|
||||
expect(result).toEqual({} as IosAppBuildCredentialsFragment);
|
||||
expect(LogWarnSpy).not.toHaveBeenCalled();
|
||||
expect(LogLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCertificateFragment } {
|
||||
const ctx = jest.mocked(
|
||||
new CredentialsContext(
|
||||
{} as {
|
||||
projectInfo: CredentialsContextProjectInfo | null;
|
||||
easJsonCliConfig?: EasJson['cli'];
|
||||
nonInteractive: boolean;
|
||||
projectDir: string;
|
||||
user: Actor;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
env?: Env;
|
||||
}
|
||||
)
|
||||
);
|
||||
Object.defineProperty(ctx, 'ios', { value: jest.mock('../../../ios/api/GraphqlClient') });
|
||||
ctx.ios.getDevicesForAppleTeamAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ identifier: 'id1' },
|
||||
{ identifier: 'id2' },
|
||||
{ identifier: 'id3' },
|
||||
] as AppleDeviceFragment[]);
|
||||
jest.mocked(chooseDevicesAsync).mockResolvedValue([
|
||||
{ identifier: 'id1', name: 'Device 1', deviceClass: AppleDeviceClass.Ipad },
|
||||
{ identifier: 'id2', name: 'Device 2', deviceClass: AppleDeviceClass.Iphone },
|
||||
{ identifier: 'id3', name: 'Device 3', deviceClass: AppleDeviceClass.Mac },
|
||||
] as AppleDevice[]);
|
||||
// @ts-ignore
|
||||
jest.mocked(getApplePlatformFromTarget).mockResolvedValue(ApplePlatform.IOS);
|
||||
Object.defineProperty(ctx, 'appStore', { value: jest.mock('../../appstore/AppStoreApi') });
|
||||
ctx.appStore.createOrReuseAdhocProvisioningProfileAsync = jest.fn().mockResolvedValue({
|
||||
provisioningProfileId: 'provisioningProfileId',
|
||||
} as ProvisioningProfile);
|
||||
ctx.ios.createOrGetExistingAppleAppIdentifierAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue({} as AppleAppIdentifierFragment);
|
||||
jest.mocked(assignBuildCredentialsAsync).mockResolvedValue({} as IosAppBuildCredentialsFragment);
|
||||
const distCert = {} as AppleDistributionCertificateFragment;
|
||||
return { ctx, distCert };
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
displayEmptyAndroidCredentials,
|
||||
} from '../android/utils/printCredentials';
|
||||
import { CredentialsContext, CredentialsContextProjectInfo } from '../context';
|
||||
import { AndroidPackageNotDefinedError } from '../errors';
|
||||
import { ActionInfo, AndroidActionType, Scope } from './Actions';
|
||||
import {
|
||||
buildCredentialsActions,
|
||||
@@ -131,6 +132,12 @@ export class ManageAndroid {
|
||||
|
||||
await this.runProjectSpecificActionAsync(ctx, chosenAction, gradleContext);
|
||||
} catch (err) {
|
||||
if (err instanceof AndroidPackageNotDefinedError) {
|
||||
err.message += `\n${learnMore(
|
||||
'https://docs.expo.dev/workflow/configuration/'
|
||||
)} about configuration with app.json/app.config.js`;
|
||||
throw err; // breaks out of the loop to avoid failing again after continuation
|
||||
}
|
||||
Log.error(err);
|
||||
if (process.env.DEBUG) {
|
||||
throw err; // breaks out of the loop so we can get stack trace
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile, EasJsonUtils } from '@expo/eas-json';
|
||||
|
||||
import { Analytics } from '../../../analytics/AnalyticsManager';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { learnMore } from '../../../log';
|
||||
import { getProjectConfigDescription } from '../../../project/projectUtils';
|
||||
import { pressAnyKeyToContinueAsync } from '../../../prompts';
|
||||
import { Actor } from '../../../user/User';
|
||||
import { getAppLookupParamsFromContextAsync } from '../../android/actions/BuildCredentialsUtils';
|
||||
import { CredentialsContextProjectInfo } from '../../context';
|
||||
import { AndroidPackageNotDefinedError } from '../../errors';
|
||||
import { Action } from '../HelperActions';
|
||||
import { ManageAndroid } from '../ManageAndroid';
|
||||
|
||||
jest.mock('../../android/actions/BuildCredentialsUtils');
|
||||
jest.mock('../../../prompts', () => {
|
||||
return {
|
||||
...jest.requireActual('../../../prompts'),
|
||||
pressAnyKeyToContinueAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../project/projectUtils');
|
||||
|
||||
describe('runAsync', () => {
|
||||
describe('"android.package" missing in app.json', () => {
|
||||
const manageAndroid = new ManageAndroid(
|
||||
{
|
||||
projectInfo: {} as CredentialsContextProjectInfo,
|
||||
actor: {} as Actor,
|
||||
graphqlClient: {} as ExpoGraphqlClient,
|
||||
analytics: {} as Analytics,
|
||||
getDynamicPrivateProjectConfigAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ exp: {}, projectId: '' }),
|
||||
runAsync: jest.fn(),
|
||||
} as Action,
|
||||
''
|
||||
);
|
||||
it('does not repeat the error indefinitely and prints useful error', async () => {
|
||||
jest.spyOn(EasJsonUtils, 'getBuildProfileNamesAsync').mockResolvedValue(['testProfile']);
|
||||
jest
|
||||
.spyOn(EasJsonUtils, 'getBuildProfileAsync')
|
||||
.mockResolvedValue({} as BuildProfile<Platform.ANDROID>);
|
||||
jest.mocked(getProjectConfigDescription).mockReturnValue('app.json');
|
||||
jest.mocked(getAppLookupParamsFromContextAsync).mockImplementation(() => {
|
||||
throw new AndroidPackageNotDefinedError(
|
||||
'Specify "android.package" in app.json and run this command again.'
|
||||
);
|
||||
});
|
||||
const pressAnyKeyToContinueAsyncMock = jest.mocked(pressAnyKeyToContinueAsync);
|
||||
Array.from(Array(100)).map((_, i) => {
|
||||
// continue 101 times if error is rethrown indefinitely
|
||||
pressAnyKeyToContinueAsyncMock.mockResolvedValueOnce();
|
||||
});
|
||||
pressAnyKeyToContinueAsyncMock.mockImplementationOnce(async () => {
|
||||
// fail after 102nd time
|
||||
fail('test should not reach this place - if it does, the error repeats indefinitely');
|
||||
});
|
||||
const reThrownError = new AndroidPackageNotDefinedError(
|
||||
'Specify "android.package" in app.json and run this command again.\n' +
|
||||
`${learnMore(
|
||||
'https://docs.expo.dev/workflow/configuration/'
|
||||
)} about configuration with app.json/app.config.js`
|
||||
);
|
||||
await expect(manageAndroid.runAsync()).rejects.toThrow(reThrownError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const runCurrentMachineMethodAsync = jest.fn();
|
||||
@@ -5,6 +5,7 @@ import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils
|
||||
import AppStoreApi from '../../../../credentials/ios/appstore/AppStoreApi';
|
||||
import { AccountFragment, Role } from '../../../../graphql/generated';
|
||||
import DeviceCreateAction, { RegistrationMethod } from '../action';
|
||||
import { runCurrentMachineMethodAsync } from '../currentMachineMethod';
|
||||
import { runDeveloperPortalMethodAsync } from '../developerPortalMethod';
|
||||
import { runInputMethodAsync } from '../inputMethod';
|
||||
import { runRegistrationUrlMethodAsync } from '../registrationUrlMethod';
|
||||
@@ -13,6 +14,7 @@ jest.mock('prompts');
|
||||
jest.mock('../developerPortalMethod');
|
||||
jest.mock('../inputMethod');
|
||||
jest.mock('../registrationUrlMethod');
|
||||
jest.mock('../currentMachineMethod');
|
||||
|
||||
beforeEach(() => {
|
||||
const promptsMock = jest.mocked(prompts);
|
||||
@@ -22,6 +24,7 @@ beforeEach(() => {
|
||||
});
|
||||
jest.mocked(runRegistrationUrlMethodAsync).mockClear();
|
||||
jest.mocked(runInputMethodAsync).mockClear();
|
||||
jest.mocked(runCurrentMachineMethodAsync).mockClear();
|
||||
});
|
||||
|
||||
describe(DeviceCreateAction, () => {
|
||||
@@ -118,5 +121,36 @@ describe(DeviceCreateAction, () => {
|
||||
|
||||
expect(runDeveloperPortalMethodAsync).toBeCalled();
|
||||
});
|
||||
|
||||
it('calls runCurrentMachineMethodAsync if user chooses the current machine method', async () => {
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({
|
||||
method: RegistrationMethod.CURRENT_MACHINE,
|
||||
}));
|
||||
const appStoreApiMock = mock<AppStoreApi>();
|
||||
const appStoreApi = instance(appStoreApiMock);
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
const account: AccountFragment = {
|
||||
id: 'account_id',
|
||||
name: 'foobar',
|
||||
users: [
|
||||
{
|
||||
role: Role.Owner,
|
||||
actor: {
|
||||
id: 'user_id',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const appleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123Y',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
const action = new DeviceCreateAction(graphqlClient, appStoreApi, account, appleTeam);
|
||||
await action.runAsync();
|
||||
|
||||
expect(runCurrentMachineMethodAsync).toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import os from 'os';
|
||||
import prompts from 'prompts';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppleDeviceMutation } from '../../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
|
||||
import { AppleTeam } from '../../../../graphql/generated';
|
||||
import { DeviceCreateError } from '../../../utils/errors';
|
||||
import { runCurrentMachineMethodAsync } from '../currentMachineMethod';
|
||||
|
||||
jest.mock('../../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation');
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('os', () => {
|
||||
return {
|
||||
...jest.requireActual('os'),
|
||||
cpus: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('@expo/spawn-async', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
...jest.requireActual('@expo/spawn-async'),
|
||||
default: jest.fn(async () => {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
SPHardwareDataType: [
|
||||
{
|
||||
provisioning_UDID: 'fake_udid',
|
||||
machine_name: 'fake_machine_name',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
let actualPlatform: string;
|
||||
beforeAll(() => {
|
||||
actualPlatform = process.platform;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(prompts).mockReset();
|
||||
jest.mocked(prompts).mockImplementation(() => {
|
||||
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
|
||||
});
|
||||
jest.mocked(AppleDeviceMutation.createAppleDeviceAsync).mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, 'platform', { value: actualPlatform });
|
||||
});
|
||||
|
||||
describe(runCurrentMachineMethodAsync, () => {
|
||||
it('allows registering the default Mac device class', async () => {
|
||||
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
mockDeviceData('my Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const accountId = 'account-id';
|
||||
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
|
||||
const appleTeam: AppleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123XY',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
|
||||
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('proceeds with registration if user approves, only once', async () => {
|
||||
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
mockDeviceData('my Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
mockDeviceData('my other Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
|
||||
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const accountId = 'account-id';
|
||||
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
|
||||
const appleTeam: AppleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123XY',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
|
||||
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exits registration if user cancels', async () => {
|
||||
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
mockDeviceData('my Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
|
||||
mockDeviceData('my other Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const accountId = 'account-id';
|
||||
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
|
||||
const appleTeam: AppleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123XY',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
|
||||
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('exits registration if current Mac machine is not of Apple Silicon type', async () => {
|
||||
jest
|
||||
.mocked(os.cpus)
|
||||
.mockImplementation(() => [{ model: 'Intel(R) Core(TM) i5' } as os.CpuInfo]);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
mockDeviceData('my old Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
mockDeviceData('my other Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
|
||||
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const accountId = 'account-id';
|
||||
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
|
||||
const appleTeam: AppleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123XY',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
|
||||
await expect(
|
||||
runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam)
|
||||
).rejects.toThrowError(
|
||||
new DeviceCreateError(
|
||||
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
|
||||
)
|
||||
);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('exits registration if current machine is not Mac', async () => {
|
||||
jest
|
||||
.mocked(os.cpus)
|
||||
.mockImplementation(() => [{ model: 'Intel(R) Core(TM) i5' } as os.CpuInfo]);
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
mockDeviceData('my Windows PC');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
mockDeviceData('my other Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
|
||||
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const accountId = 'account-id';
|
||||
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
|
||||
const appleTeam: AppleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123XY',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
|
||||
await expect(
|
||||
runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam)
|
||||
).rejects.toThrowError(
|
||||
new DeviceCreateError(
|
||||
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
|
||||
)
|
||||
);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
function mockDeviceData(name: string): void {
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ name }));
|
||||
}
|
||||
@@ -21,6 +21,8 @@ describe(runInputMethodAsync, () => {
|
||||
it('should allow for multiple device registration', async () => {
|
||||
mockDeviceData('00001111-001122334455662E', 'my iPhone', AppleDeviceClass.Iphone);
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
mockDeviceData('00001111-001122334455662F', 'my Mac', AppleDeviceClass.Mac);
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
mockDeviceData('b12cba9856d89c932ab7a4b813c4d932534e1679', 'my iPad', AppleDeviceClass.Ipad);
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
|
||||
|
||||
@@ -35,7 +37,7 @@ describe(runInputMethodAsync, () => {
|
||||
|
||||
await runInputMethodAsync(graphqlClient, accountId, appleTeam);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(2);
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import AppStoreApi from '../../../credentials/ios/appstore/AppStoreApi';
|
||||
import { AccountFragment, AppleTeam } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { runCurrentMachineMethodAsync } from './currentMachineMethod';
|
||||
import { runDeveloperPortalMethodAsync } from './developerPortalMethod';
|
||||
import { runInputMethodAsync } from './inputMethod';
|
||||
import { runRegistrationUrlMethodAsync } from './registrationUrlMethod';
|
||||
@@ -13,6 +14,7 @@ export enum RegistrationMethod {
|
||||
WEBSITE,
|
||||
INPUT,
|
||||
DEVELOPER_PORTAL,
|
||||
CURRENT_MACHINE,
|
||||
EXIT,
|
||||
}
|
||||
|
||||
@@ -37,6 +39,8 @@ export default class DeviceCreateAction {
|
||||
);
|
||||
} else if (method === RegistrationMethod.INPUT) {
|
||||
await runInputMethodAsync(this.graphqlClient, this.account.id, this.appleTeam);
|
||||
} else if (method === RegistrationMethod.CURRENT_MACHINE) {
|
||||
await runCurrentMachineMethodAsync(this.graphqlClient, this.account.id, this.appleTeam);
|
||||
} else if (method === RegistrationMethod.EXIT) {
|
||||
Log.log('Bye!');
|
||||
process.exit(0);
|
||||
@@ -66,6 +70,12 @@ export default class DeviceCreateAction {
|
||||
title: `${chalk.bold('Input')} - allows you to type in UDIDs (advanced option)`,
|
||||
value: RegistrationMethod.INPUT,
|
||||
},
|
||||
{
|
||||
title: `${chalk.bold(
|
||||
'Current Machine'
|
||||
)} - automatically sets the provisioning UDID of the current Apple Silicon machine`,
|
||||
value: RegistrationMethod.CURRENT_MACHINE,
|
||||
},
|
||||
{ title: chalk.bold('Exit'), value: RegistrationMethod.EXIT },
|
||||
],
|
||||
validate: (val: RegistrationMethod) => Object.values(RegistrationMethod).includes(val),
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import chalk from 'chalk';
|
||||
import os from 'os';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppleDeviceMutation } from '../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
|
||||
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { DeviceCreateError } from '../../utils/errors';
|
||||
import { DeviceData, printDeviceData, promptForNameAsync } from './utils';
|
||||
|
||||
export async function runCurrentMachineMethodAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>
|
||||
): Promise<void> {
|
||||
Log.newLine();
|
||||
Log.log(chalk.white('Checking if current machine is an Apple Silicon one.'));
|
||||
if (!isMachineAppleSilicon()) {
|
||||
throw new DeviceCreateError(
|
||||
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
|
||||
);
|
||||
}
|
||||
Log.log(chalk.green('Check successful.'));
|
||||
|
||||
await collectDataAndRegisterDeviceAsync(graphqlClient, { accountId, appleTeam });
|
||||
}
|
||||
|
||||
function isMachineAppleSilicon(): boolean {
|
||||
return os.cpus()[0].model.includes('Apple M') && process.platform === 'darwin';
|
||||
}
|
||||
|
||||
async function collectDataAndRegisterDeviceAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
accountId,
|
||||
appleTeam,
|
||||
}: {
|
||||
accountId: string;
|
||||
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>;
|
||||
}
|
||||
): Promise<void> {
|
||||
const deviceData = await collectDeviceDataAsync(appleTeam);
|
||||
if (!deviceData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { udid, deviceClass, name } = deviceData;
|
||||
const spinner = ora(`Registering Apple device on Expo`).start();
|
||||
try {
|
||||
await AppleDeviceMutation.createAppleDeviceAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
appleTeamId: appleTeam.id,
|
||||
identifier: udid,
|
||||
name,
|
||||
deviceClass: deviceClass ?? undefined,
|
||||
},
|
||||
accountId
|
||||
);
|
||||
} catch (err) {
|
||||
spinner.fail();
|
||||
throw err;
|
||||
}
|
||||
spinner.succeed();
|
||||
}
|
||||
|
||||
async function collectDeviceDataAsync(
|
||||
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName'>,
|
||||
initialValues: Partial<DeviceData> = {}
|
||||
): Promise<DeviceData | null> {
|
||||
Log.log(chalk.white('Fetching the provisioning UDID.'));
|
||||
const [udid, defaultMachineName] = await fetchCurrentMachineUDIDAsync();
|
||||
Log.log(chalk.green(`Fetched the provisioning UDID - ${udid}`));
|
||||
const name = await promptForNameAsync(defaultMachineName ?? initialValues.name);
|
||||
const deviceClass = AppleDeviceClass.Mac;
|
||||
const deviceData: DeviceData = {
|
||||
udid,
|
||||
name,
|
||||
deviceClass,
|
||||
};
|
||||
printDeviceData(deviceData, appleTeam);
|
||||
|
||||
const registrationConfirmed = await confirmAsync({
|
||||
message: 'Is this what you want to register?',
|
||||
});
|
||||
if (!registrationConfirmed) {
|
||||
Log.log('No worries, just try again.');
|
||||
Log.newLine();
|
||||
return null;
|
||||
} else {
|
||||
return deviceData;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrentMachineUDIDAsync(): Promise<[string, string?]> {
|
||||
try {
|
||||
const profilerData = (
|
||||
await spawnAsync('system_profiler', ['-json', 'SPHardwareDataType'])
|
||||
).stdout.trim();
|
||||
if (!profilerData) {
|
||||
const message = 'Failed to fetch the provisioning UDID from system profiler';
|
||||
Log.error(message);
|
||||
throw new DeviceCreateError(message);
|
||||
}
|
||||
|
||||
const profilerDataJSON = JSON.parse(profilerData);
|
||||
const provisioningUDID = profilerDataJSON?.SPHardwareDataType[0]?.provisioning_UDID;
|
||||
if (!provisioningUDID) {
|
||||
const message = 'System profiler data did not contain the provisioning UDID';
|
||||
Log.error(message);
|
||||
throw new DeviceCreateError(message);
|
||||
}
|
||||
|
||||
const defaultMachineName = profilerDataJSON?.SPHardwareDataType[0]?.machine_name;
|
||||
return [provisioningUDID, defaultMachineName];
|
||||
} catch (err: any) {
|
||||
if (!(err instanceof DeviceCreateError)) {
|
||||
const message = `Failed to fetch the provisioning UDID of the current machine - ${err.message}`;
|
||||
Log.error(message);
|
||||
throw new DeviceCreateError(message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const DEVICE_IMPORT_CHUNK_SIZE = 10;
|
||||
const DEVICE_CLASS_TO_GRAPHQL_TYPE: Partial<Record<DeviceClass, AppleDeviceClass>> = {
|
||||
[DeviceClass.IPAD]: AppleDeviceClass.Ipad,
|
||||
[DeviceClass.IPHONE]: AppleDeviceClass.Iphone,
|
||||
[DeviceClass.MAC]: AppleDeviceClass.Mac,
|
||||
};
|
||||
|
||||
export async function runDeveloperPortalMethodAsync(
|
||||
@@ -105,7 +106,7 @@ async function findUnregisteredPortalDevicesAsync(
|
||||
return portalDevices.filter(
|
||||
portalDevice =>
|
||||
!(portalDevice.attributes.udid in expoRegisteredDevicesByUdid) &&
|
||||
[DeviceClass.IPAD, DeviceClass.IPHONE, DeviceClass.APPLE_TV].includes(
|
||||
[DeviceClass.IPAD, DeviceClass.IPHONE, DeviceClass.APPLE_TV, DeviceClass.MAC].includes(
|
||||
portalDevice.attributes.deviceClass
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2,18 +2,17 @@ import chalk from 'chalk';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppleDeviceMutation } from '../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
|
||||
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
|
||||
import { AppleTeam } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { isValidUDID, normalizeUDID } from '../../udids';
|
||||
import { formatNewDevice } from '../../utils/formatDevice';
|
||||
|
||||
interface DeviceData {
|
||||
udid: string;
|
||||
name?: string;
|
||||
deviceClass: AppleDeviceClass | null;
|
||||
}
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import {
|
||||
DeviceData,
|
||||
printDeviceData,
|
||||
promptForDeviceClassAsync,
|
||||
promptForNameAsync,
|
||||
promptForUDIDAsync,
|
||||
} from './utils';
|
||||
|
||||
export async function runInputMethodAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -78,16 +77,7 @@ async function collectDeviceDataAsync(
|
||||
deviceClass,
|
||||
};
|
||||
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`We are going to register the following device in our database.
|
||||
This device will ${chalk.bold(
|
||||
'not'
|
||||
)} be registered on the Apple Developer Portal until it is chosen for an internal distribution build.`
|
||||
);
|
||||
Log.newLine();
|
||||
Log.log(formatNewDevice({ ...deviceData, identifier: deviceData.udid }, appleTeam));
|
||||
Log.newLine();
|
||||
printDeviceData(deviceData, appleTeam);
|
||||
|
||||
const registrationConfirmed = await confirmAsync({
|
||||
message: 'Is this what you want to register?',
|
||||
@@ -100,56 +90,3 @@ This device will ${chalk.bold(
|
||||
return deviceData;
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptForUDIDAsync(initial?: string): Promise<string> {
|
||||
const { udid } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'udid',
|
||||
message: 'UDID:',
|
||||
initial,
|
||||
validate: (rawVal: string) => {
|
||||
const val = normalizeUDID(rawVal);
|
||||
if (!val || val === '') {
|
||||
return 'UDID cannot be empty';
|
||||
} else if (val.length !== 25 && val.length !== 40) {
|
||||
return 'UDID should be a 25 or 40-character string';
|
||||
} else if (!isValidUDID(val)) {
|
||||
return 'UDID is invalid';
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
format: (val: string) => normalizeUDID(val),
|
||||
});
|
||||
return udid;
|
||||
}
|
||||
|
||||
async function promptForNameAsync(initial?: string): Promise<string | undefined> {
|
||||
const { name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Device name (optional):',
|
||||
initial,
|
||||
});
|
||||
return name;
|
||||
}
|
||||
|
||||
async function promptForDeviceClassAsync(
|
||||
initial?: AppleDeviceClass | null
|
||||
): Promise<AppleDeviceClass | null> {
|
||||
const choices = [
|
||||
{ title: 'iPhone', value: AppleDeviceClass.Iphone },
|
||||
{ title: 'iPad', value: AppleDeviceClass.Ipad },
|
||||
{ title: 'Not sure (leave empty)', value: null },
|
||||
];
|
||||
const values = choices.map(({ value }) => value);
|
||||
|
||||
const { deviceClass } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'deviceClass',
|
||||
message: 'Device class (optional):',
|
||||
choices,
|
||||
initial: initial !== undefined && values.indexOf(initial),
|
||||
});
|
||||
return deviceClass;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { isValidUDID, normalizeUDID } from '../../udids';
|
||||
import { formatNewDevice } from '../../utils/formatDevice';
|
||||
|
||||
export interface DeviceData {
|
||||
udid: string;
|
||||
name?: string;
|
||||
deviceClass: AppleDeviceClass | null;
|
||||
}
|
||||
|
||||
export async function promptForDeviceClassAsync(
|
||||
initial?: AppleDeviceClass | null
|
||||
): Promise<AppleDeviceClass | null> {
|
||||
const choices = [
|
||||
{ title: 'iPhone', value: AppleDeviceClass.Iphone },
|
||||
{ title: 'iPad', value: AppleDeviceClass.Ipad },
|
||||
{ title: 'Mac', value: AppleDeviceClass.Mac },
|
||||
{ title: 'Not sure (leave empty)', value: null },
|
||||
];
|
||||
const values = choices.map(({ value }) => value);
|
||||
|
||||
const { deviceClass } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'deviceClass',
|
||||
message: 'Device class (optional):',
|
||||
choices,
|
||||
initial: initial !== undefined && values.indexOf(initial),
|
||||
});
|
||||
return deviceClass;
|
||||
}
|
||||
|
||||
export async function promptForNameAsync(initial?: string): Promise<string | undefined> {
|
||||
const { name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Device name (optional):',
|
||||
initial,
|
||||
});
|
||||
return name;
|
||||
}
|
||||
|
||||
export async function promptForUDIDAsync(initial?: string): Promise<string> {
|
||||
const { udid } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'udid',
|
||||
message: 'UDID:',
|
||||
initial,
|
||||
validate: (rawVal: string) => {
|
||||
const val = normalizeUDID(rawVal);
|
||||
if (!val || val === '') {
|
||||
return 'UDID cannot be empty';
|
||||
} else if (val.length !== 25 && val.length !== 40) {
|
||||
return 'UDID should be a 25 or 40-character string';
|
||||
} else if (!isValidUDID(val)) {
|
||||
return 'UDID is invalid';
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
format: (val: string) => normalizeUDID(val),
|
||||
});
|
||||
return udid;
|
||||
}
|
||||
|
||||
export function printDeviceData(
|
||||
deviceData: DeviceData,
|
||||
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName'>
|
||||
): void {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`We are going to register the following device in our database.
|
||||
This device will ${chalk.bold(
|
||||
'not'
|
||||
)} be registered on the Apple Developer Portal until it is chosen for an internal distribution build.`
|
||||
);
|
||||
Log.newLine();
|
||||
Log.log(formatNewDevice({ ...deviceData, identifier: deviceData.udid }, appleTeam));
|
||||
Log.newLine();
|
||||
}
|
||||
@@ -12,10 +12,10 @@ import { Actor } from '../user/User';
|
||||
import DeviceCreateAction from './actions/create/action';
|
||||
import { DeviceManagerContext } from './context';
|
||||
|
||||
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones and iPads) for internal distribution of your app.
|
||||
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones, iPads and Macs) for internal distribution of your app.
|
||||
Internal distribution means that you won't need to upload your app archive to App Store / Testflight.
|
||||
Your app archive (.ipa) will be installable on your equipment as long as you sign your application with an adhoc provisiong profile.
|
||||
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones and iPads.
|
||||
Your app archive (.ipa) will be installable on your equipment as long as you sign your application with an adhoc provisioning profile.
|
||||
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones, iPads and Macs.
|
||||
|
||||
First of all, choose the Expo account under which you want to register your devices.
|
||||
Later, authenticate with Apple and choose your desired Apple Team (if your Apple ID has access to multiple teams).`;
|
||||
|
||||
@@ -3,3 +3,9 @@ export class DeviceNotFoundError extends Error {
|
||||
super(message ?? 'Device not found.');
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceCreateError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message ?? 'Failed to create a device.');
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export type AppleTeamIdAndName = Pick<AppleTeam, 'appleTeamIdentifier' | 'appleT
|
||||
const DEVICE_CLASS_DISPLAY_NAMES: Record<AppleDeviceClass, string> = {
|
||||
[AppleDeviceClass.Iphone]: 'iPhone',
|
||||
[AppleDeviceClass.Ipad]: 'iPad',
|
||||
[AppleDeviceClass.Mac]: 'Mac',
|
||||
};
|
||||
|
||||
function formatDeviceClass(device: Device | NewDevice): string {
|
||||
|
||||
+276
-22
@@ -479,17 +479,11 @@ export type AccountSsoConfiguration = {
|
||||
};
|
||||
|
||||
export type AccountSsoConfigurationData = {
|
||||
authEndpoint?: InputMaybe<Scalars['String']>;
|
||||
authProtocol: AuthProtocolType;
|
||||
authProviderIdentifier: Scalars['String'];
|
||||
clientIdentifier: Scalars['String'];
|
||||
clientSecret: Scalars['String'];
|
||||
endSessionEndpoint?: InputMaybe<Scalars['String']>;
|
||||
issuer: Scalars['String'];
|
||||
jwksEndpoint?: InputMaybe<Scalars['String']>;
|
||||
revokeEndpoint?: InputMaybe<Scalars['String']>;
|
||||
tokenEndpoint?: InputMaybe<Scalars['String']>;
|
||||
userInfoEndpoint?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type AccountSsoConfigurationMutation = {
|
||||
@@ -602,6 +596,8 @@ export type Actor = {
|
||||
* For example, when displaying a sentence indicating that actor X created a build or published an update.
|
||||
*/
|
||||
displayName: Scalars['String'];
|
||||
/** Experiments associated with this actor */
|
||||
experiments: Array<ActorExperiment>;
|
||||
/**
|
||||
* Server feature gate values for this actor, optionally filtering by desired gates.
|
||||
* Only resolves for the viewer.
|
||||
@@ -618,9 +614,33 @@ export type ActorFeatureGatesArgs = {
|
||||
filter?: InputMaybe<Array<Scalars['String']>>;
|
||||
};
|
||||
|
||||
export type ActorExperiment = {
|
||||
__typename?: 'ActorExperiment';
|
||||
createdAt: Scalars['DateTime'];
|
||||
enabled: Scalars['Boolean'];
|
||||
experiment: Experiment;
|
||||
id: Scalars['ID'];
|
||||
updatedAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
export type ActorExperimentMutation = {
|
||||
__typename?: 'ActorExperimentMutation';
|
||||
/** Create or update the value of a User Experiment */
|
||||
createOrUpdateActorExperiment: ActorExperiment;
|
||||
};
|
||||
|
||||
|
||||
export type ActorExperimentMutationCreateOrUpdateActorExperimentArgs = {
|
||||
enabled: Scalars['Boolean'];
|
||||
experiment: Experiment;
|
||||
};
|
||||
|
||||
export type ActorQuery = {
|
||||
__typename?: 'ActorQuery';
|
||||
/** Query an Actor by ID */
|
||||
/**
|
||||
* Query an Actor by ID
|
||||
* @deprecated Public actor queries are no longer supported
|
||||
*/
|
||||
byId: Actor;
|
||||
};
|
||||
|
||||
@@ -789,11 +809,13 @@ export enum AndroidBuildType {
|
||||
}
|
||||
|
||||
export type AndroidBuilderEnvironmentInput = {
|
||||
bun?: InputMaybe<Scalars['String']>;
|
||||
env?: InputMaybe<Scalars['JSONObject']>;
|
||||
expoCli?: InputMaybe<Scalars['String']>;
|
||||
image?: InputMaybe<Scalars['String']>;
|
||||
ndk?: InputMaybe<Scalars['String']>;
|
||||
node?: InputMaybe<Scalars['String']>;
|
||||
pnpm?: InputMaybe<Scalars['String']>;
|
||||
yarn?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
@@ -1023,6 +1045,7 @@ export type App = Project & {
|
||||
insights: AppInsights;
|
||||
/** iOS app credentials for the project */
|
||||
iosAppCredentials: Array<IosAppCredentials>;
|
||||
isDeleting: Scalars['Boolean'];
|
||||
/** Whether the latest classic update publish is using a deprecated SDK version */
|
||||
isDeprecated: Scalars['Boolean'];
|
||||
/** @deprecated 'likes' have been deprecated. */
|
||||
@@ -1057,6 +1080,8 @@ export type App = Project & {
|
||||
releaseChannels: Array<Scalars['String']>;
|
||||
/** @deprecated Legacy access tokens are deprecated */
|
||||
requiresAccessTokenForPushSecurity: Scalars['Boolean'];
|
||||
/** Runtimes associated with this app */
|
||||
runtimes: RuntimesConnection;
|
||||
scopeKey: Scalars['String'];
|
||||
/** SDK version of the latest classic update publish, 0.0.0 otherwise */
|
||||
sdkVersion: Scalars['String'];
|
||||
@@ -1174,6 +1199,7 @@ export type AppDeploymentArgs = {
|
||||
export type AppDeploymentsArgs = {
|
||||
after?: InputMaybe<Scalars['String']>;
|
||||
before?: InputMaybe<Scalars['String']>;
|
||||
filter?: InputMaybe<DeploymentFilterInput>;
|
||||
first?: InputMaybe<Scalars['Int']>;
|
||||
last?: InputMaybe<Scalars['Int']>;
|
||||
};
|
||||
@@ -1212,6 +1238,15 @@ export type AppLikedByArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppRuntimesArgs = {
|
||||
after?: InputMaybe<Scalars['String']>;
|
||||
before?: InputMaybe<Scalars['String']>;
|
||||
first?: InputMaybe<Scalars['Int']>;
|
||||
last?: InputMaybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppSubmissionsArgs = {
|
||||
filter: SubmissionFilter;
|
||||
@@ -1657,7 +1692,8 @@ export type AppleDevice = {
|
||||
|
||||
export enum AppleDeviceClass {
|
||||
Ipad = 'IPAD',
|
||||
Iphone = 'IPHONE'
|
||||
Iphone = 'IPHONE',
|
||||
Mac = 'MAC'
|
||||
}
|
||||
|
||||
export type AppleDeviceInput = {
|
||||
@@ -2052,6 +2088,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
|
||||
completedAt?: Maybe<Scalars['DateTime']>;
|
||||
createdAt: Scalars['DateTime'];
|
||||
customWorkflowName?: Maybe<Scalars['String']>;
|
||||
developmentClient?: Maybe<Scalars['Boolean']>;
|
||||
distribution?: Maybe<DistributionType>;
|
||||
enqueuedAt?: Maybe<Scalars['DateTime']>;
|
||||
error?: Maybe<BuildError>;
|
||||
@@ -2143,6 +2180,7 @@ export enum BuildCredentialsSource {
|
||||
|
||||
export type BuildError = {
|
||||
__typename?: 'BuildError';
|
||||
buildPhase?: Maybe<BuildPhase>;
|
||||
docsUrl?: Maybe<Scalars['String']>;
|
||||
errorCode: Scalars['String'];
|
||||
message: Scalars['String'];
|
||||
@@ -2268,6 +2306,7 @@ export type BuildMetadataInput = {
|
||||
cliVersion?: InputMaybe<Scalars['String']>;
|
||||
credentialsSource?: InputMaybe<BuildCredentialsSource>;
|
||||
customWorkflowName?: InputMaybe<Scalars['String']>;
|
||||
developmentClient?: InputMaybe<Scalars['Boolean']>;
|
||||
distribution?: InputMaybe<DistributionType>;
|
||||
gitCommitHash?: InputMaybe<Scalars['String']>;
|
||||
gitCommitMessage?: InputMaybe<Scalars['String']>;
|
||||
@@ -2396,6 +2435,50 @@ export type BuildParamsInput = {
|
||||
sdkVersion?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export enum BuildPhase {
|
||||
BuilderInfo = 'BUILDER_INFO',
|
||||
CleanUpCredentials = 'CLEAN_UP_CREDENTIALS',
|
||||
CompleteBuild = 'COMPLETE_BUILD',
|
||||
ConfigureExpoUpdates = 'CONFIGURE_EXPO_UPDATES',
|
||||
ConfigureXcodeProject = 'CONFIGURE_XCODE_PROJECT',
|
||||
Custom = 'CUSTOM',
|
||||
DownloadApplicationArchive = 'DOWNLOAD_APPLICATION_ARCHIVE',
|
||||
EasBuildInternal = 'EAS_BUILD_INTERNAL',
|
||||
FailBuild = 'FAIL_BUILD',
|
||||
FixGradlew = 'FIX_GRADLEW',
|
||||
InstallCustomTools = 'INSTALL_CUSTOM_TOOLS',
|
||||
InstallDependencies = 'INSTALL_DEPENDENCIES',
|
||||
InstallPods = 'INSTALL_PODS',
|
||||
OnBuildCancelHook = 'ON_BUILD_CANCEL_HOOK',
|
||||
OnBuildCompleteHook = 'ON_BUILD_COMPLETE_HOOK',
|
||||
OnBuildErrorHook = 'ON_BUILD_ERROR_HOOK',
|
||||
OnBuildSuccessHook = 'ON_BUILD_SUCCESS_HOOK',
|
||||
ParseCustomWorkflowConfig = 'PARSE_CUSTOM_WORKFLOW_CONFIG',
|
||||
PostInstallHook = 'POST_INSTALL_HOOK',
|
||||
Prebuild = 'PREBUILD',
|
||||
PrepareArtifacts = 'PREPARE_ARTIFACTS',
|
||||
PrepareCredentials = 'PREPARE_CREDENTIALS',
|
||||
PrepareProject = 'PREPARE_PROJECT',
|
||||
PreInstallHook = 'PRE_INSTALL_HOOK',
|
||||
PreUploadArtifactsHook = 'PRE_UPLOAD_ARTIFACTS_HOOK',
|
||||
Queue = 'QUEUE',
|
||||
ReadAppConfig = 'READ_APP_CONFIG',
|
||||
ReadPackageJson = 'READ_PACKAGE_JSON',
|
||||
RestoreCache = 'RESTORE_CACHE',
|
||||
RunExpoDoctor = 'RUN_EXPO_DOCTOR',
|
||||
RunFastlane = 'RUN_FASTLANE',
|
||||
RunGradlew = 'RUN_GRADLEW',
|
||||
SaveCache = 'SAVE_CACHE',
|
||||
SetUpBuildEnvironment = 'SET_UP_BUILD_ENVIRONMENT',
|
||||
SpinUpBuilder = 'SPIN_UP_BUILDER',
|
||||
StartBuild = 'START_BUILD',
|
||||
Unknown = 'UNKNOWN',
|
||||
UploadApplicationArchive = 'UPLOAD_APPLICATION_ARCHIVE',
|
||||
/** @deprecated No longer supported */
|
||||
UploadArtifacts = 'UPLOAD_ARTIFACTS',
|
||||
UploadBuildArtifacts = 'UPLOAD_BUILD_ARTIFACTS'
|
||||
}
|
||||
|
||||
export type BuildPlanCreditThresholdExceededMetadata = {
|
||||
__typename?: 'BuildPlanCreditThresholdExceededMetadata';
|
||||
account: Account;
|
||||
@@ -2770,8 +2853,10 @@ export type Deployment = {
|
||||
builds: DeploymentBuildsConnection;
|
||||
channel: UpdateChannel;
|
||||
id: Scalars['ID'];
|
||||
/** @deprecated Not required for the new Deployment UI */
|
||||
latestUpdate?: Maybe<Update>;
|
||||
/** Deployment query field */
|
||||
insights: DeploymentInsights;
|
||||
/** Ordered the same way as 'updateBranches' in UpdateChannel */
|
||||
latestUpdatesPerBranch: Array<LatestUpdateOnBranch>;
|
||||
runtime: Runtime;
|
||||
};
|
||||
|
||||
@@ -2784,6 +2869,13 @@ export type DeploymentBuildsArgs = {
|
||||
last?: InputMaybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
|
||||
/** Represents a Deployment - a set of Builds with the same Runtime Version and Channel */
|
||||
export type DeploymentLatestUpdatesPerBranchArgs = {
|
||||
limit: Scalars['Int'];
|
||||
offset: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type DeploymentBuildEdge = {
|
||||
__typename?: 'DeploymentBuildEdge';
|
||||
cursor: Scalars['String'];
|
||||
@@ -2803,6 +2895,40 @@ export type DeploymentEdge = {
|
||||
node: Deployment;
|
||||
};
|
||||
|
||||
export type DeploymentFilterInput = {
|
||||
channel?: InputMaybe<Scalars['String']>;
|
||||
runtimeVersion?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type DeploymentInsights = {
|
||||
__typename?: 'DeploymentInsights';
|
||||
embeddedUpdateTotalUniqueUsers: Scalars['Int'];
|
||||
embeddedUpdateUniqueUsersOverTime: UniqueUsersOverTimeData;
|
||||
id: Scalars['ID'];
|
||||
mostPopularUpdates: Array<Update>;
|
||||
uniqueUsersOverTime: UniqueUsersOverTimeData;
|
||||
};
|
||||
|
||||
|
||||
export type DeploymentInsightsEmbeddedUpdateTotalUniqueUsersArgs = {
|
||||
timespan: InsightsTimespan;
|
||||
};
|
||||
|
||||
|
||||
export type DeploymentInsightsEmbeddedUpdateUniqueUsersOverTimeArgs = {
|
||||
timespan: InsightsTimespan;
|
||||
};
|
||||
|
||||
|
||||
export type DeploymentInsightsMostPopularUpdatesArgs = {
|
||||
timespan: InsightsTimespan;
|
||||
};
|
||||
|
||||
|
||||
export type DeploymentInsightsUniqueUsersOverTimeArgs = {
|
||||
timespan: InsightsTimespan;
|
||||
};
|
||||
|
||||
/** Represents the connection over the deployments edge of an App */
|
||||
export type DeploymentsConnection = {
|
||||
__typename?: 'DeploymentsConnection';
|
||||
@@ -2971,6 +3097,10 @@ export type EstimatedUsage = {
|
||||
value: Scalars['Float'];
|
||||
};
|
||||
|
||||
export enum Experiment {
|
||||
Orbit = 'ORBIT'
|
||||
}
|
||||
|
||||
export type ExperimentationQuery = {
|
||||
__typename?: 'ExperimentationQuery';
|
||||
/** Get device experimentation config */
|
||||
@@ -3534,6 +3664,7 @@ export enum IosBuildType {
|
||||
}
|
||||
|
||||
export type IosBuilderEnvironmentInput = {
|
||||
bun?: InputMaybe<Scalars['String']>;
|
||||
bundler?: InputMaybe<Scalars['String']>;
|
||||
cocoapods?: InputMaybe<Scalars['String']>;
|
||||
env?: InputMaybe<Scalars['JSONObject']>;
|
||||
@@ -3541,6 +3672,7 @@ export type IosBuilderEnvironmentInput = {
|
||||
fastlane?: InputMaybe<Scalars['String']>;
|
||||
image?: InputMaybe<Scalars['String']>;
|
||||
node?: InputMaybe<Scalars['String']>;
|
||||
pnpm?: InputMaybe<Scalars['String']>;
|
||||
yarn?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
@@ -3667,6 +3799,12 @@ export type KeystoreGenerationUrlMutation = {
|
||||
createKeystoreGenerationUrl: KeystoreGenerationUrl;
|
||||
};
|
||||
|
||||
export type LatestUpdateOnBranch = {
|
||||
__typename?: 'LatestUpdateOnBranch';
|
||||
branchId: Scalars['String'];
|
||||
update?: Maybe<Update>;
|
||||
};
|
||||
|
||||
export type LeaveAccountResult = {
|
||||
__typename?: 'LeaveAccountResult';
|
||||
success: Scalars['Boolean'];
|
||||
@@ -3844,6 +3982,7 @@ export type MeteredBillingStatus = {
|
||||
|
||||
export type Notification = {
|
||||
__typename?: 'Notification';
|
||||
accountName: Scalars['String'];
|
||||
createdAt: Scalars['DateTime'];
|
||||
event: NotificationEvent;
|
||||
id: Scalars['ID'];
|
||||
@@ -3851,6 +3990,7 @@ export type Notification = {
|
||||
metadata?: Maybe<NotificationMetadata>;
|
||||
type: NotificationType;
|
||||
updatedAt: Scalars['DateTime'];
|
||||
websiteMessage: Scalars['String'];
|
||||
};
|
||||
|
||||
export enum NotificationEvent {
|
||||
@@ -4083,6 +4223,8 @@ export type Robot = Actor & {
|
||||
accounts: Array<Account>;
|
||||
created: Scalars['DateTime'];
|
||||
displayName: Scalars['String'];
|
||||
/** Experiments associated with this actor */
|
||||
experiments: Array<ActorExperiment>;
|
||||
/**
|
||||
* Server feature gate values for this actor, optionally filtering by desired gates.
|
||||
* Only resolves for the viewer.
|
||||
@@ -4154,6 +4296,8 @@ export type RootMutation = {
|
||||
account: AccountMutation;
|
||||
/** Mutations that create, update, and delete an AccountSSOConfiguration */
|
||||
accountSSOConfiguration: AccountSsoConfigurationMutation;
|
||||
/** Mutations for Actor experiments */
|
||||
actorExperiment: ActorExperimentMutation;
|
||||
/** Mutations that modify the build credentials for an Android app */
|
||||
androidAppBuildCredentials: AndroidAppBuildCredentialsMutation;
|
||||
/** Mutations that modify the credentials for an Android app */
|
||||
@@ -4225,10 +4369,14 @@ export type RootMutation = {
|
||||
updateBranch: UpdateBranchMutation;
|
||||
updateChannel: UpdateChannelMutation;
|
||||
uploadSession: UploadSession;
|
||||
/** Mutations that create, update, and delete pinned apps */
|
||||
userAppPins: UserAppPinMutation;
|
||||
/** Mutations that create, delete, and accept UserInvitations */
|
||||
userInvitation: UserInvitationMutation;
|
||||
/** Mutations that create, delete, update Webhooks */
|
||||
webhook: WebhookMutation;
|
||||
/** Mutations that modify a websiteNotification */
|
||||
websiteNotifications: WebsiteNotificationMutation;
|
||||
};
|
||||
|
||||
|
||||
@@ -4262,7 +4410,10 @@ export type RootQuery = {
|
||||
account: AccountQuery;
|
||||
/** Top-level query object for querying AccountSSOConfigurationPublicData */
|
||||
accountSSOConfigurationPublicData: AccountSsoConfigurationPublicDataQuery;
|
||||
/** Top-level query object for querying Actors. */
|
||||
/**
|
||||
* Top-level query object for querying Actors.
|
||||
* @deprecated Public actor queries are no longer supported
|
||||
*/
|
||||
actor: ActorQuery;
|
||||
/**
|
||||
* Public apps in the app directory
|
||||
@@ -4319,7 +4470,10 @@ export type RootQuery = {
|
||||
* @deprecated Public user queries are no longer supported
|
||||
*/
|
||||
user: UserQuery;
|
||||
/** Top-level query object for querying UserActors. */
|
||||
/**
|
||||
* Top-level query object for querying UserActors.
|
||||
* @deprecated Public user queries are no longer supported
|
||||
*/
|
||||
userActor: UserActorQuery;
|
||||
/** Top-level query object for querying UserActorPublicData publicly. */
|
||||
userActorPublicData: UserActorPublicDataQuery;
|
||||
@@ -4385,6 +4539,7 @@ export type RuntimeFilterInput = {
|
||||
branchId?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** Represents the connection over the runtime edge of an App */
|
||||
export type RuntimesConnection = {
|
||||
__typename?: 'RuntimesConnection';
|
||||
edges: Array<RuntimeEdge>;
|
||||
@@ -4409,6 +4564,8 @@ export type SsoUser = Actor & UserActor & {
|
||||
/** Discord account linked to a user */
|
||||
discordUser?: Maybe<DiscordUser>;
|
||||
displayName: Scalars['String'];
|
||||
/** Experiments associated with this actor */
|
||||
experiments: Array<ActorExperiment>;
|
||||
/**
|
||||
* Server feature gate values for this actor, optionally filtering by desired gates.
|
||||
* Only resolves for the viewer.
|
||||
@@ -4428,6 +4585,7 @@ export type SsoUser = Actor & UserActor & {
|
||||
/** @deprecated No longer supported */
|
||||
location?: Maybe<Scalars['String']>;
|
||||
notificationSubscriptions: Array<NotificationSubscription>;
|
||||
pinnedApps: Array<App>;
|
||||
/** Associated accounts */
|
||||
primaryAccount: Account;
|
||||
profilePhoto: Scalars['String'];
|
||||
@@ -4436,8 +4594,7 @@ export type SsoUser = Actor & UserActor & {
|
||||
/** @deprecated No longer supported */
|
||||
twitterUsername?: Maybe<Scalars['String']>;
|
||||
username: Scalars['String'];
|
||||
/** Web notifications linked to a user */
|
||||
webNotifications: Array<Notification>;
|
||||
websiteNotificationsPaginated: WebsiteNotificationsConnection;
|
||||
};
|
||||
|
||||
|
||||
@@ -4475,6 +4632,15 @@ export type SsoUserSnacksArgs = {
|
||||
offset: Scalars['Int'];
|
||||
};
|
||||
|
||||
|
||||
/** Represents a human SSO (not robot) actor. */
|
||||
export type SsoUserWebsiteNotificationsPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']>;
|
||||
before?: InputMaybe<Scalars['String']>;
|
||||
first?: InputMaybe<Scalars['Int']>;
|
||||
last?: InputMaybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
export type SsoUserDataInput = {
|
||||
firstName?: InputMaybe<Scalars['String']>;
|
||||
lastName?: InputMaybe<Scalars['String']>;
|
||||
@@ -4669,7 +4835,9 @@ export type StatuspageService = {
|
||||
export enum StatuspageServiceName {
|
||||
EasBuild = 'EAS_BUILD',
|
||||
EasSubmit = 'EAS_SUBMIT',
|
||||
EasUpdate = 'EAS_UPDATE'
|
||||
EasUpdate = 'EAS_UPDATE',
|
||||
GithubApiRequests = 'GITHUB_API_REQUESTS',
|
||||
GithubWebhooks = 'GITHUB_WEBHOOKS'
|
||||
}
|
||||
|
||||
export type StatuspageServiceQuery = {
|
||||
@@ -4903,6 +5071,8 @@ export type Update = ActivityTimelineProjectActivity & {
|
||||
gitCommitHash?: Maybe<Scalars['String']>;
|
||||
group: Scalars['String'];
|
||||
id: Scalars['ID'];
|
||||
/** Update query field */
|
||||
insights: UpdateInsights;
|
||||
isGitWorkingTreeDirty: Scalars['Boolean'];
|
||||
isRollBackToEmbedded: Scalars['Boolean'];
|
||||
manifestFragment: Scalars['String'];
|
||||
@@ -5063,6 +5233,17 @@ export type UpdateInfoGroup = {
|
||||
web?: InputMaybe<PartialManifest>;
|
||||
};
|
||||
|
||||
export type UpdateInsights = {
|
||||
__typename?: 'UpdateInsights';
|
||||
id: Scalars['ID'];
|
||||
totalUniqueUsers: Scalars['Int'];
|
||||
};
|
||||
|
||||
|
||||
export type UpdateInsightsTotalUniqueUsersArgs = {
|
||||
timespan: InsightsTimespan;
|
||||
};
|
||||
|
||||
export type UpdateMutation = {
|
||||
__typename?: 'UpdateMutation';
|
||||
/** Delete an EAS update group */
|
||||
@@ -5162,6 +5343,8 @@ export type User = Actor & UserActor & {
|
||||
displayName: Scalars['String'];
|
||||
email: Scalars['String'];
|
||||
emailVerified: Scalars['Boolean'];
|
||||
/** Experiments associated with this actor */
|
||||
experiments: Array<ActorExperiment>;
|
||||
/**
|
||||
* Server feature gate values for this actor, optionally filtering by desired gates.
|
||||
* Only resolves for the viewer.
|
||||
@@ -5188,6 +5371,7 @@ export type User = Actor & UserActor & {
|
||||
notificationSubscriptions: Array<NotificationSubscription>;
|
||||
/** Pending UserInvitations for this user. Only resolves for the viewer. */
|
||||
pendingUserInvitations: Array<UserInvitation>;
|
||||
pinnedApps: Array<App>;
|
||||
/** Associated accounts */
|
||||
primaryAccount: Account;
|
||||
profilePhoto: Scalars['String'];
|
||||
@@ -5198,7 +5382,7 @@ export type User = Actor & UserActor & {
|
||||
/** @deprecated No longer supported */
|
||||
twitterUsername?: Maybe<Scalars['String']>;
|
||||
username: Scalars['String'];
|
||||
webNotifications: Array<Notification>;
|
||||
websiteNotificationsPaginated: WebsiteNotificationsConnection;
|
||||
};
|
||||
|
||||
|
||||
@@ -5236,6 +5420,15 @@ export type UserSnacksArgs = {
|
||||
offset: Scalars['Int'];
|
||||
};
|
||||
|
||||
|
||||
/** Represents a human (not robot) actor. */
|
||||
export type UserWebsiteNotificationsPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']>;
|
||||
before?: InputMaybe<Scalars['String']>;
|
||||
first?: InputMaybe<Scalars['Int']>;
|
||||
last?: InputMaybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
|
||||
export type UserActor = {
|
||||
/** Access Tokens belonging to this user actor */
|
||||
@@ -5260,6 +5453,8 @@ export type UserActor = {
|
||||
* For example, when displaying a sentence indicating that actor X created a build or published an update.
|
||||
*/
|
||||
displayName: Scalars['String'];
|
||||
/** Experiments associated with this actor */
|
||||
experiments: Array<ActorExperiment>;
|
||||
/**
|
||||
* Server feature gate values for this user actor, optionally filtering by desired gates.
|
||||
* Only resolves for the viewer.
|
||||
@@ -5279,6 +5474,7 @@ export type UserActor = {
|
||||
/** @deprecated No longer supported */
|
||||
location?: Maybe<Scalars['String']>;
|
||||
notificationSubscriptions: Array<NotificationSubscription>;
|
||||
pinnedApps: Array<App>;
|
||||
/** Associated accounts */
|
||||
primaryAccount: Account;
|
||||
profilePhoto: Scalars['String'];
|
||||
@@ -5287,8 +5483,7 @@ export type UserActor = {
|
||||
/** @deprecated No longer supported */
|
||||
twitterUsername?: Maybe<Scalars['String']>;
|
||||
username: Scalars['String'];
|
||||
/** Web notifications linked to a user */
|
||||
webNotifications: Array<Notification>;
|
||||
websiteNotificationsPaginated: WebsiteNotificationsConnection;
|
||||
};
|
||||
|
||||
|
||||
@@ -5326,6 +5521,15 @@ export type UserActorSnacksArgs = {
|
||||
offset: Scalars['Int'];
|
||||
};
|
||||
|
||||
|
||||
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
|
||||
export type UserActorWebsiteNotificationsPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']>;
|
||||
before?: InputMaybe<Scalars['String']>;
|
||||
first?: InputMaybe<Scalars['Int']>;
|
||||
last?: InputMaybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
|
||||
export type UserActorPublicData = {
|
||||
__typename?: 'UserActorPublicData';
|
||||
@@ -5368,9 +5572,15 @@ export type UserActorPublicDataQueryByUsernameArgs = {
|
||||
|
||||
export type UserActorQuery = {
|
||||
__typename?: 'UserActorQuery';
|
||||
/** Query a UserActor by ID */
|
||||
/**
|
||||
* Query a UserActor by ID
|
||||
* @deprecated Public user actor queries are no longer supported
|
||||
*/
|
||||
byId: UserActor;
|
||||
/** Query a UserActor by username */
|
||||
/**
|
||||
* Query a UserActor by username
|
||||
* @deprecated Public user actor queries are no longer supported
|
||||
*/
|
||||
byUsername: UserActor;
|
||||
};
|
||||
|
||||
@@ -5384,6 +5594,22 @@ export type UserActorQueryByUsernameArgs = {
|
||||
username: Scalars['String'];
|
||||
};
|
||||
|
||||
export type UserAppPinMutation = {
|
||||
__typename?: 'UserAppPinMutation';
|
||||
pinApp: Scalars['ID'];
|
||||
unpinApp?: Maybe<Scalars['ID']>;
|
||||
};
|
||||
|
||||
|
||||
export type UserAppPinMutationPinAppArgs = {
|
||||
appId: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type UserAppPinMutationUnpinAppArgs = {
|
||||
appId: Scalars['ID'];
|
||||
};
|
||||
|
||||
export type UserDataInput = {
|
||||
email?: InputMaybe<Scalars['String']>;
|
||||
firstName?: InputMaybe<Scalars['String']>;
|
||||
@@ -5543,6 +5769,11 @@ export type UserSecondFactorDevice = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export type WebNotificationUpdateReadStateInput = {
|
||||
id: Scalars['ID'];
|
||||
isRead: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type Webhook = {
|
||||
__typename?: 'Webhook';
|
||||
appId: Scalars['ID'];
|
||||
@@ -5605,6 +5836,29 @@ export enum WebhookType {
|
||||
Submit = 'SUBMIT'
|
||||
}
|
||||
|
||||
export type WebsiteNotificationEdge = {
|
||||
__typename?: 'WebsiteNotificationEdge';
|
||||
cursor: Scalars['String'];
|
||||
node: Notification;
|
||||
};
|
||||
|
||||
export type WebsiteNotificationMutation = {
|
||||
__typename?: 'WebsiteNotificationMutation';
|
||||
updateAllWebsiteNotificationReadStateAsRead: Scalars['Boolean'];
|
||||
updateNotificationReadState: Notification;
|
||||
};
|
||||
|
||||
|
||||
export type WebsiteNotificationMutationUpdateNotificationReadStateArgs = {
|
||||
input: WebNotificationUpdateReadStateInput;
|
||||
};
|
||||
|
||||
export type WebsiteNotificationsConnection = {
|
||||
__typename?: 'WebsiteNotificationsConnection';
|
||||
edges: Array<WebsiteNotificationEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type DeleteAndroidAppBuildCredentialsResult = {
|
||||
__typename?: 'deleteAndroidAppBuildCredentialsResult';
|
||||
id: Scalars['ID'];
|
||||
@@ -6333,7 +6587,7 @@ export type ViewUpdateChannelOnAppQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type ViewUpdateChannelOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannelByName?: { __typename?: 'UpdateChannel', id: string, name: string, createdAt: any, branchMapping: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updateGroups: Array<Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }>> }> } | null } } };
|
||||
export type ViewUpdateChannelOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannelByName?: { __typename?: 'UpdateChannel', id: string, name: string, updatedAt: any, createdAt: any, branchMapping: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updateGroups: Array<Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }>> }> } | null } } };
|
||||
|
||||
export type ViewUpdateChannelsOnAppQueryVariables = Exact<{
|
||||
appId: Scalars['String'];
|
||||
@@ -6342,7 +6596,7 @@ export type ViewUpdateChannelsOnAppQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type ViewUpdateChannelsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannels: Array<{ __typename?: 'UpdateChannel', id: string, name: string, createdAt: any, branchMapping: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updateGroups: Array<Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }>> }> }> } } };
|
||||
export type ViewUpdateChannelsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannels: Array<{ __typename?: 'UpdateChannel', id: string, name: string, updatedAt: any, createdAt: any, branchMapping: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updateGroups: Array<Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }>> }> }> } } };
|
||||
|
||||
export type ViewUpdateChannelsPaginatedOnAppQueryVariables = Exact<{
|
||||
appId: Scalars['String'];
|
||||
|
||||
@@ -60,6 +60,7 @@ export const ChannelQuery = {
|
||||
updateChannelByName(name: $channelName) {
|
||||
id
|
||||
name
|
||||
updatedAt
|
||||
createdAt
|
||||
branchMapping
|
||||
updateBranches(offset: 0, limit: 5) {
|
||||
@@ -104,6 +105,7 @@ export const ChannelQuery = {
|
||||
updateChannels(offset: $offset, limit: $limit) {
|
||||
id
|
||||
name
|
||||
updatedAt
|
||||
createdAt
|
||||
branchMapping
|
||||
updateBranches(offset: 0, limit: 5) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AppleDeviceClass } from '../../generated';
|
||||
export const APPLE_DEVICE_CLASS_LABELS: Record<AppleDeviceClass, string> = {
|
||||
[AppleDeviceClass.Ipad]: 'iPad',
|
||||
[AppleDeviceClass.Iphone]: 'iPhone',
|
||||
[AppleDeviceClass.Mac]: 'Mac',
|
||||
};
|
||||
|
||||
export const AppleDeviceFragmentNode = gql`
|
||||
|
||||
@@ -57,6 +57,10 @@ export default class Log {
|
||||
Log.consoleLog(chalk.green(figures.tick), ...args);
|
||||
}
|
||||
|
||||
public static withInfo(...args: any[]): void {
|
||||
Log.consoleLog(chalk.green(figures.info), ...args);
|
||||
}
|
||||
|
||||
private static consoleLog(...args: any[]): void {
|
||||
Log.updateIsLastLineNewLine(args);
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@@ -201,8 +201,14 @@ async function getSuggestedBundleIdentifierAsync(
|
||||
} else {
|
||||
// the only callsite is heavily interactive
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
let possibleId: string;
|
||||
// It's common to use dashes in your node project name, strip them from the suggested package name.
|
||||
const possibleId = `com.${account.name}.${exp.slug}`.split('-').join('');
|
||||
if (account.name) {
|
||||
possibleId = `com.${account.name}.${exp.slug}`.split('-').join('');
|
||||
} else {
|
||||
possibleId = `com.${exp.slug}`.split('-').join('');
|
||||
}
|
||||
|
||||
if (isBundleIdentifierValid(possibleId)) {
|
||||
return possibleId;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ import { promptAsync } from '../prompts';
|
||||
import { getBranchNameFromChannelNameAsync } from '../update/getBranchNameFromChannelNameAsync';
|
||||
import { formatUpdateMessage, truncateString as truncateUpdateMessage } from '../update/utils';
|
||||
import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../uploads';
|
||||
import { expoCommandAsync, shouldUseVersionedExpoCLI } from '../utils/expoCli';
|
||||
import {
|
||||
expoCommandAsync,
|
||||
shouldUseVersionedExpoCLI,
|
||||
shouldUseVersionedExpoCLIWithExplicitPlatforms,
|
||||
} from '../utils/expoCli';
|
||||
import chunk from '../utils/expodash/chunk';
|
||||
import { truthy } from '../utils/expodash/filter';
|
||||
import uniqBy from '../utils/expodash/uniqBy';
|
||||
@@ -181,32 +185,23 @@ export async function buildBundlesAsync({
|
||||
exp,
|
||||
platformFlag,
|
||||
clearCache,
|
||||
dev = false,
|
||||
}: {
|
||||
projectDir: string;
|
||||
inputDir: string;
|
||||
exp: Pick<ExpoConfig, 'sdkVersion'>;
|
||||
exp: Pick<ExpoConfig, 'sdkVersion' | 'web'>;
|
||||
platformFlag: ExpoCLIExportPlatformFlag;
|
||||
clearCache?: boolean;
|
||||
dev?: boolean;
|
||||
}): Promise<void> {
|
||||
const packageJSON = JsonFile.read(path.resolve(projectDir, 'package.json'));
|
||||
if (!packageJSON) {
|
||||
throw new Error('Could not locate package.json');
|
||||
}
|
||||
|
||||
if (shouldUseVersionedExpoCLI(projectDir, exp)) {
|
||||
await expoCommandAsync(projectDir, [
|
||||
'export',
|
||||
'--output-dir',
|
||||
inputDir,
|
||||
'--dump-sourcemap',
|
||||
'--dump-assetmap',
|
||||
'--platform',
|
||||
platformFlag,
|
||||
...(clearCache ? ['--clear'] : []),
|
||||
]);
|
||||
} else {
|
||||
// Legacy global Expo CLI
|
||||
await expoCommandAsync(projectDir, [
|
||||
// Legacy global Expo CLI
|
||||
if (!shouldUseVersionedExpoCLI(projectDir, exp)) {
|
||||
return await expoCommandAsync(projectDir, [
|
||||
'export',
|
||||
'--output-dir',
|
||||
inputDir,
|
||||
@@ -214,11 +209,52 @@ export async function buildBundlesAsync({
|
||||
'--non-interactive',
|
||||
'--dump-sourcemap',
|
||||
'--dump-assetmap',
|
||||
'--platform',
|
||||
platformFlag,
|
||||
`--platform=${platformFlag}`,
|
||||
...(dev ? ['--dev'] : []),
|
||||
...(clearCache ? ['--clear'] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
// Versioned Expo CLI, with multiple platform flag support
|
||||
if (shouldUseVersionedExpoCLIWithExplicitPlatforms(projectDir)) {
|
||||
// When creating EAS updates, we don't want to build a web bundle
|
||||
const platformArgs =
|
||||
platformFlag === 'all'
|
||||
? ['--platform', 'ios', '--platform', 'android']
|
||||
: ['--platform', platformFlag];
|
||||
|
||||
return await expoCommandAsync(projectDir, [
|
||||
'export',
|
||||
'--output-dir',
|
||||
inputDir,
|
||||
'--dump-sourcemap',
|
||||
'--dump-assetmap',
|
||||
...platformArgs,
|
||||
...(dev ? ['--dev'] : []),
|
||||
...(clearCache ? ['--clear'] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
// Versioned Expo CLI, without multiple platform flag support
|
||||
// Warn users about potential export issues when using Metro web
|
||||
// See: https://github.com/expo/expo/pull/23621
|
||||
if (exp.web?.bundler === 'metro') {
|
||||
Log.warn('Exporting bundle for all platforms, including Metro web.');
|
||||
Log.warn(
|
||||
'If your app is incompatible with web, remove the "expo.web.bundler" property from your app manifest, or upgrade to the latest Expo SDK.'
|
||||
);
|
||||
}
|
||||
|
||||
return await expoCommandAsync(projectDir, [
|
||||
'export',
|
||||
'--output-dir',
|
||||
inputDir,
|
||||
'--dump-sourcemap',
|
||||
'--dump-assetmap',
|
||||
`--platform=${platformFlag}`,
|
||||
...(dev ? ['--dev'] : []),
|
||||
...(clearCache ? ['--clear'] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function resolveInputDirectoryAsync(
|
||||
@@ -682,3 +718,9 @@ export function getRuntimeToPlatformMappingFromRuntimeVersions(
|
||||
}
|
||||
return runtimeToPlatformMapping;
|
||||
}
|
||||
|
||||
export const platformDisplayNames: Record<Platform, string> = {
|
||||
android: 'Android',
|
||||
ios: 'iOS',
|
||||
web: 'Web',
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
testChannelObject,
|
||||
testUpdateBranch1,
|
||||
testUpdateBranch2,
|
||||
} from '../../channel/__tests__/fixtures';
|
||||
channelInfoWithBranches,
|
||||
testBasicBranchInfo1,
|
||||
testBasicBranchInfo2,
|
||||
testChannelBasicInfo,
|
||||
} from '../../channel/__tests__/branch-mapping-fixtures';
|
||||
import {
|
||||
BranchMappingOperator,
|
||||
BranchMappingValidationError,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
assertRolloutBranchMapping,
|
||||
composeRollout,
|
||||
createRolloutBranchMapping,
|
||||
doesTargetRollout,
|
||||
editRolloutBranchMapping,
|
||||
getRollout,
|
||||
getRolloutInfo,
|
||||
@@ -32,18 +34,29 @@ import {
|
||||
standardBranchMapping,
|
||||
} from './fixtures';
|
||||
|
||||
describe(doesTargetRollout, () => {
|
||||
it('detects whether a runtime targets a constrained rollout', () => {
|
||||
expect(doesTargetRollout(rolloutBranchMapping, '1.0.0')).toBe(true);
|
||||
expect(doesTargetRollout(rolloutBranchMapping, '2.0.0')).toBe(false);
|
||||
});
|
||||
it('should always return true if the rollout is unconstrained', () => {
|
||||
expect(doesTargetRollout(rolloutBranchMappingLegacy, '1.0.0')).toBe(true);
|
||||
expect(doesTargetRollout(rolloutBranchMappingLegacy, '2.0.0')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(composeRollout, () => {
|
||||
it('composes a rollout', () => {
|
||||
const rollout = getRollout(testChannelObject);
|
||||
const rolloutInfo = getRolloutInfo(testChannelObject);
|
||||
const composedRollout = composeRollout(rolloutInfo, testUpdateBranch2, testUpdateBranch1);
|
||||
const rollout = getRollout(channelInfoWithBranches);
|
||||
const rolloutInfo = getRolloutInfo(testChannelBasicInfo);
|
||||
const composedRollout = composeRollout(rolloutInfo, testBasicBranchInfo2, testBasicBranchInfo1);
|
||||
expect(composedRollout).toEqual(rollout);
|
||||
});
|
||||
it('throws if the branches to not match the rollout info', () => {
|
||||
const rolloutInfo = getRolloutInfo(testChannelObject);
|
||||
expect(() => composeRollout(rolloutInfo, testUpdateBranch1, testUpdateBranch2)).toThrowError(
|
||||
BranchMappingValidationError
|
||||
);
|
||||
const rolloutInfo = getRolloutInfo(testChannelBasicInfo);
|
||||
expect(() =>
|
||||
composeRollout(rolloutInfo, testBasicBranchInfo1, testBasicBranchInfo2)
|
||||
).toThrowError(BranchMappingValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,33 +132,33 @@ describe(isConstrainedRollout, () => {
|
||||
|
||||
describe(getRollout, () => {
|
||||
it('doesnt get the mapping if it isnt a rollout', () => {
|
||||
const notRollout = { ...testChannelObject };
|
||||
const notRollout = { ...channelInfoWithBranches };
|
||||
notRollout.branchMapping = JSON.stringify(standardBranchMapping);
|
||||
expect(() => getRollout(notRollout)).toThrowError();
|
||||
});
|
||||
it('gets a constrained rollout', () => {
|
||||
const constrainedRollout = { ...testChannelObject };
|
||||
const constrainedRollout = { ...channelInfoWithBranches };
|
||||
const rolloutBranchMappingWithCorrectBranch = { ...rolloutBranchMapping };
|
||||
rolloutBranchMappingWithCorrectBranch.data[0].branchId = testUpdateBranch1.id;
|
||||
rolloutBranchMappingWithCorrectBranch.data[1].branchId = testUpdateBranch2.id;
|
||||
rolloutBranchMappingWithCorrectBranch.data[0].branchId = testBasicBranchInfo1.id;
|
||||
rolloutBranchMappingWithCorrectBranch.data[1].branchId = testBasicBranchInfo2.id;
|
||||
constrainedRollout.branchMapping = JSON.stringify(rolloutBranchMapping);
|
||||
|
||||
const rollout = getRollout(constrainedRollout);
|
||||
expect(rollout.percentRolledOut).toEqual(10);
|
||||
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
|
||||
expect(rollout.defaultBranch).toEqual(testUpdateBranch2);
|
||||
expect(rollout.rolledOutBranch).toEqual(testUpdateBranch1);
|
||||
expect(rollout.defaultBranchId).toEqual(testUpdateBranch2.id);
|
||||
expect(rollout.rolledOutBranchId).toEqual(testUpdateBranch1.id);
|
||||
expect(rollout.defaultBranch).toEqual(testBasicBranchInfo2);
|
||||
expect(rollout.rolledOutBranch).toEqual(testBasicBranchInfo1);
|
||||
expect(rollout.defaultBranchId).toEqual(testBasicBranchInfo2.id);
|
||||
expect(rollout.rolledOutBranchId).toEqual(testBasicBranchInfo1.id);
|
||||
});
|
||||
it('gets a legacy rollout', () => {
|
||||
const rollout = getRollout(testChannelObject);
|
||||
const rollout = getRollout(channelInfoWithBranches);
|
||||
expect(rollout.percentRolledOut).toEqual(15);
|
||||
expect(isLegacyRolloutInfo(rollout)).toBe(true);
|
||||
expect(rollout.defaultBranch).toEqual(testUpdateBranch2);
|
||||
expect(rollout.rolledOutBranch).toEqual(testUpdateBranch1);
|
||||
expect(rollout.defaultBranchId).toEqual(testUpdateBranch2.id);
|
||||
expect(rollout.rolledOutBranchId).toEqual(testUpdateBranch1.id);
|
||||
expect(rollout.defaultBranch).toEqual(testBasicBranchInfo2);
|
||||
expect(rollout.rolledOutBranch).toEqual(testBasicBranchInfo1);
|
||||
expect(rollout.defaultBranchId).toEqual(testBasicBranchInfo2.id);
|
||||
expect(rollout.rolledOutBranchId).toEqual(testBasicBranchInfo1.id);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,3 +68,8 @@ export const standardBranchMapping: BranchMapping = {
|
||||
version: 0,
|
||||
data: [{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() }],
|
||||
};
|
||||
|
||||
export const emptyBranchMapping: BranchMapping = {
|
||||
version: 0,
|
||||
data: [],
|
||||
};
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Updates } from '@expo/config-plugins';
|
||||
import assert from 'assert';
|
||||
|
||||
import { SelectBranch } from '../../branch/actions/SelectBranch';
|
||||
import { getStandardBranchId, hasStandardBranchMap } from '../../channel/branch-mapping';
|
||||
import {
|
||||
getStandardBranchId,
|
||||
hasEmptyBranchMap,
|
||||
hasStandardBranchMap,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { getUpdateBranch } from '../../channel/utils';
|
||||
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
|
||||
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
|
||||
@@ -18,7 +23,8 @@ import {
|
||||
} from '../../graphql/queries/ChannelQuery';
|
||||
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { truthy } from '../../utils/expodash/filter';
|
||||
import {
|
||||
composeRollout,
|
||||
createRolloutBranchMapping,
|
||||
@@ -69,6 +75,11 @@ export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFrag
|
||||
if (isRollout(this.channelInfo)) {
|
||||
throw new Error(`A rollout is already in progress for channel ${this.channelInfo.name}`);
|
||||
}
|
||||
if (hasEmptyBranchMap(this.channelInfo)) {
|
||||
throw new Error(
|
||||
`Your channel needs to be linked to a branch before a rollout can be created. Do this by running 'eas channel:edit'`
|
||||
);
|
||||
}
|
||||
if (!hasStandardBranchMap(this.channelInfo)) {
|
||||
throw new Error(
|
||||
`You have a custom branch mapping. Map your channel to a single branch before creating a rollout. Received: ${this.channelInfo.branchMapping}`
|
||||
@@ -88,7 +99,7 @@ export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFrag
|
||||
this.options.runtimeVersion ??
|
||||
(await this.selectRuntimeVersionAsync(ctx, branchInfoToRollout, defaultBranchId));
|
||||
Log.newLine();
|
||||
const promptMessage = `What percent of users should be routed to branch ${branchInfoToRollout.name}?`;
|
||||
const promptMessage = `What percent of users should be rolled out to branch ${branchInfoToRollout.name}?`;
|
||||
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
|
||||
const rolloutBranchMapping = createRolloutBranchMapping({
|
||||
defaultBranchId,
|
||||
@@ -196,10 +207,96 @@ export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFrag
|
||||
channelName: this.channelInfo.name,
|
||||
});
|
||||
const defaultBranchRtvAgnostic = getUpdateBranch(channelObjectRtvAgnostic, defaultBranchId);
|
||||
const selectRuntimeAction = new SelectRuntime(branchToRollout, {
|
||||
const selectSharedRuntimeAction = new SelectRuntime(branchToRollout, {
|
||||
anotherBranchToIntersectRuntimesBy: defaultBranchRtvAgnostic,
|
||||
});
|
||||
return await selectRuntimeAction.runAsync(ctx);
|
||||
const sharedRuntime = await selectSharedRuntimeAction.runAsync(ctx);
|
||||
if (sharedRuntime) {
|
||||
return sharedRuntime;
|
||||
}
|
||||
|
||||
return await this.selectRuntimeVersionFromAlternativeSourceAsync(
|
||||
ctx,
|
||||
branchToRollout,
|
||||
defaultBranchRtvAgnostic
|
||||
);
|
||||
}
|
||||
|
||||
async selectRuntimeVersionFromAlternativeSourceAsync(
|
||||
ctx: EASUpdateContext,
|
||||
branchToRollout: UpdateBranchBasicInfoFragment,
|
||||
defaultBranch: UpdateBranchBasicInfoFragment
|
||||
): Promise<string> {
|
||||
const { runtimeSource: selectedRuntimeSource } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'runtimeSource',
|
||||
message: `What would you like to do?`,
|
||||
choices: [
|
||||
{
|
||||
value: 'DEFAULT_BRANCH_RUNTIME',
|
||||
title: `Find a runtime supported on the ${defaultBranch.name} branch`,
|
||||
},
|
||||
{
|
||||
value: 'ROLLED_OUT_BRANCH_RUNTIME',
|
||||
title: `Find a runtime supported on the ${branchToRollout.name} branch`,
|
||||
},
|
||||
{
|
||||
value: 'PROJECT_RUNTIME',
|
||||
title: 'Use the runtime specified in your project config',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selectedRuntimeSource === 'DEFAULT_BRANCH_RUNTIME') {
|
||||
const selectDefaultBranchRuntimeAction = new SelectRuntime(defaultBranch);
|
||||
const defaultBranchRuntime = await selectDefaultBranchRuntimeAction.runAsync(ctx);
|
||||
if (defaultBranchRuntime) {
|
||||
return defaultBranchRuntime;
|
||||
}
|
||||
} else if (selectedRuntimeSource === 'ROLLED_OUT_BRANCH_RUNTIME') {
|
||||
const selectBranchToRolloutRuntimeAction = new SelectRuntime(branchToRollout);
|
||||
const branchToRolloutRuntime = await selectBranchToRolloutRuntimeAction.runAsync(ctx);
|
||||
if (branchToRolloutRuntime) {
|
||||
return branchToRolloutRuntime;
|
||||
}
|
||||
} else if (selectedRuntimeSource === 'PROJECT_RUNTIME') {
|
||||
return await this.selectRuntimeVersionFromProjectConfigAsync(ctx);
|
||||
} else {
|
||||
throw new Error(`Unexpected runtime source: ${selectedRuntimeSource}`);
|
||||
}
|
||||
return await this.selectRuntimeVersionFromAlternativeSourceAsync(
|
||||
ctx,
|
||||
branchToRollout,
|
||||
defaultBranch
|
||||
);
|
||||
}
|
||||
|
||||
async selectRuntimeVersionFromProjectConfigAsync(ctx: EASUpdateContext): Promise<string> {
|
||||
const platforms: ('ios' | 'android')[] = ['ios', 'android'];
|
||||
const runtimes = platforms
|
||||
.map(platform => Updates.getRuntimeVersion(ctx.app.exp, platform))
|
||||
.filter(truthy);
|
||||
const dedupedRuntimes = [...new Set(runtimes)];
|
||||
|
||||
if (dedupedRuntimes.length === 0) {
|
||||
throw new Error(
|
||||
`Your project config doesn't specify a runtime. Ensure your project is configured correctly for EAS Update by running \`eas update:configure\``
|
||||
);
|
||||
} else if (dedupedRuntimes.length === 1) {
|
||||
const runtime = dedupedRuntimes[0];
|
||||
Log.log(`🔧 Your project config currently supports runtime ${runtime}`);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
const { runtime: selectedRuntime } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'runtime',
|
||||
message: `Select a runtime supported by your project config`,
|
||||
choices: runtimes.map((runtime, index) => ({
|
||||
title: `${runtime} ${index === 0 ? '[iOS runtime]' : '[Android runtime]'}`,
|
||||
value: runtime,
|
||||
})),
|
||||
});
|
||||
return selectedRuntime;
|
||||
}
|
||||
|
||||
async selectBranchAsync(
|
||||
@@ -207,14 +304,17 @@ export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFrag
|
||||
defaultBranchId: string
|
||||
): Promise<UpdateBranchBasicInfoFragment> {
|
||||
const selectBranchAction = new SelectBranch({
|
||||
printedType: 'branch to rollout',
|
||||
printedType: 'branch to roll out',
|
||||
// we don't want to show the default branch as an option
|
||||
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) =>
|
||||
branchInfo.id !== defaultBranchId,
|
||||
});
|
||||
const branchInfo = await selectBranchAction.runAsync(ctx);
|
||||
if (!branchInfo) {
|
||||
throw new Error(`You dont have any branches. Create one with 'eas branch:create'`);
|
||||
// We know the user has at least one branch, since we have `defaultBranchId`
|
||||
throw new Error(
|
||||
`You don't have a second branch to roll out. Create it with 'eas branch:create'`
|
||||
);
|
||||
}
|
||||
return branchInfo;
|
||||
}
|
||||
|
||||
@@ -51,17 +51,25 @@ export class EditRollout implements EASUpdateAction<UpdateChannelBasicInfoFragme
|
||||
const channelObject = await this.getChannelObjectAsync(ctx);
|
||||
const rollout = getRollout(channelObject);
|
||||
const { rolledOutBranch, defaultBranch } = rollout;
|
||||
const promptMessage = `What percent of users should be directed to the ${rolledOutBranch.name} branch ?`;
|
||||
const promptMessage = `What percent of users should be rolled out to the ${rolledOutBranch.name} branch ?`;
|
||||
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
|
||||
|
||||
if (percent === 0 || percent === 100) {
|
||||
Log.warn(
|
||||
`Editing the percent to ${percent} will not end the rollout. You'll need to end the rollout from the main menu.`
|
||||
);
|
||||
}
|
||||
|
||||
const oldBranchMapping = getRolloutBranchMapping(channelObject.branchMapping);
|
||||
const newBranchMapping = editRolloutBranchMapping(oldBranchMapping, percent);
|
||||
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`📝 The updated rollout will send ${chalk.bold(percent)}% of users to the ${chalk.bold(
|
||||
`📝 ${chalk.bold(percent)}% of users will be rolled out to the ${chalk.bold(
|
||||
rolledOutBranch.name
|
||||
)} branch and ${chalk.bold(100 - percent)}% to the ${chalk.bold(defaultBranch.name)} branch.`
|
||||
)} branch and ${chalk.bold(100 - percent)}% will remain on the ${chalk.bold(
|
||||
defaultBranch.name
|
||||
)} branch.`
|
||||
);
|
||||
const confirmEdit = await this.confirmEditAsync(ctx);
|
||||
if (!confirmEdit) {
|
||||
|
||||
@@ -5,10 +5,15 @@ import { getAlwaysTrueBranchMapping } from '../../channel/branch-mapping';
|
||||
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
|
||||
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
|
||||
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
|
||||
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
|
||||
import {
|
||||
ChannelQuery,
|
||||
UpdateBranchObject,
|
||||
UpdateChannelObject,
|
||||
} from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { republishAsync } from '../../update/republish';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import {
|
||||
Rollout,
|
||||
@@ -20,10 +25,14 @@ import {
|
||||
import { formatBranchWithUpdateGroup } from '../utils';
|
||||
|
||||
export enum EndOutcome {
|
||||
REPUBLISH_AND_ROUTE_BACK = 'republish-and-route-back',
|
||||
ROUTE_BACK = 'route-back',
|
||||
REPUBLISH_AND_REVERT = 'republish-and-revert',
|
||||
REVERT = 'revert',
|
||||
}
|
||||
|
||||
export type GeneralOptions = {
|
||||
privateKeyPath: string | null;
|
||||
};
|
||||
|
||||
export type NonInteractiveOptions = {
|
||||
outcome: EndOutcome;
|
||||
};
|
||||
@@ -47,7 +56,7 @@ function assertNonInteractiveOptions(
|
||||
export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
|
||||
constructor(
|
||||
private channelInfo: UpdateChannelBasicInfoFragment,
|
||||
private options: Partial<NonInteractiveOptions> = {}
|
||||
private options: Partial<NonInteractiveOptions> & GeneralOptions
|
||||
) {}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
|
||||
@@ -55,30 +64,24 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
if (nonInteractive) {
|
||||
assertNonInteractiveOptions(this.options);
|
||||
}
|
||||
|
||||
const channelObject = await this.getChannelObjectAsync(ctx);
|
||||
const rollout = getRollout(channelObject);
|
||||
const { rolledOutBranch } = rollout;
|
||||
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
|
||||
let outcome: EndOutcome;
|
||||
if (!rolledOutUpdateGroup) {
|
||||
Log.log(`⚠️ There is no Update Group being served on ${rolledOutBranch.name}.`);
|
||||
Log.log(`⚠️ There is no update group being served on the ${rolledOutBranch.name} branch.`);
|
||||
assert(
|
||||
this.options.outcome !== EndOutcome.REPUBLISH_AND_ROUTE_BACK,
|
||||
'The only valid outcome for this rollout is to route back to the default branch. '
|
||||
this.options.outcome !== EndOutcome.REPUBLISH_AND_REVERT,
|
||||
`The only valid outcome for this rollout is to revert users back to the ${rollout.defaultBranch.name} branch. `
|
||||
);
|
||||
outcome = EndOutcome.ROUTE_BACK;
|
||||
outcome = EndOutcome.REVERT;
|
||||
} else {
|
||||
outcome = this.options.outcome ?? (await this.selectOutcomeAsync(rollout));
|
||||
}
|
||||
const didConfirm = await this.confirmOutcomeAsync(ctx, outcome, rollout);
|
||||
if (!didConfirm) {
|
||||
if (!rolledOutUpdateGroup) {
|
||||
Log.log(
|
||||
`If you wish to stop serving updates to your users, you can edit your rollout to 100% on ${chalk.bold(
|
||||
rolledOutBranch.name
|
||||
)} instead`
|
||||
);
|
||||
}
|
||||
throw new Error('Aborting...');
|
||||
}
|
||||
return await this.performOutcomeAsync(ctx, rollout, outcome);
|
||||
@@ -104,17 +107,17 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
});
|
||||
}
|
||||
|
||||
async selectOutcomeAsync(rollout: Rollout): Promise<EndOutcome> {
|
||||
async selectOutcomeAsync(rollout: Rollout<UpdateBranchObject>): Promise<EndOutcome> {
|
||||
const { rolledOutBranch, percentRolledOut, defaultBranch } = rollout;
|
||||
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
|
||||
const defaultUpdateGroup = defaultBranch.updateGroups[0];
|
||||
const outcomes = [
|
||||
{
|
||||
value: EndOutcome.REPUBLISH_AND_ROUTE_BACK,
|
||||
value: EndOutcome.REPUBLISH_AND_REVERT,
|
||||
title: formatBranchWithUpdateGroup(rolledOutUpdateGroup, rolledOutBranch, percentRolledOut),
|
||||
},
|
||||
{
|
||||
value: EndOutcome.ROUTE_BACK,
|
||||
value: EndOutcome.REVERT,
|
||||
title: formatBranchWithUpdateGroup(
|
||||
defaultUpdateGroup,
|
||||
defaultBranch,
|
||||
@@ -125,17 +128,17 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
const { outcome: selectedOutcome } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'outcome',
|
||||
message: `Which Update Group would you like to serve?`,
|
||||
message: `Which update group would you like to serve?`,
|
||||
choices: outcomes,
|
||||
});
|
||||
Log.newLine();
|
||||
if (selectedOutcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
|
||||
if (selectedOutcome === EndOutcome.REPUBLISH_AND_REVERT) {
|
||||
Log.log(
|
||||
`🍽️ The Update Group you chose is served by branch ${chalk.bold(rolledOutBranch.name)}`
|
||||
`➡️ 📱 The update group you chose is served by branch ${chalk.bold(rolledOutBranch.name)}`
|
||||
);
|
||||
} else {
|
||||
Log.log(
|
||||
`🍽️ The Update Group you chose is served by branch ${chalk.bold(defaultBranch.name)}`
|
||||
`➡️ 📱 The update group you chose is served by branch ${chalk.bold(defaultBranch.name)}`
|
||||
);
|
||||
}
|
||||
return selectedOutcome;
|
||||
@@ -143,13 +146,17 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
|
||||
async performOutcomeAsync(
|
||||
ctx: EASUpdateContext,
|
||||
rollout: Rollout,
|
||||
rollout: Rollout<UpdateBranchObject>,
|
||||
outcome: EndOutcome
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
const { graphqlClient, app } = ctx;
|
||||
const { rolledOutBranch, defaultBranch } = rollout;
|
||||
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
|
||||
if (outcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
|
||||
if (outcome === EndOutcome.REPUBLISH_AND_REVERT) {
|
||||
const codeSigningInfo = await getCodeSigningInfoAsync(
|
||||
ctx.app.exp,
|
||||
this.options.privateKeyPath ?? undefined
|
||||
);
|
||||
const arbitraryUpdate = rolledOutUpdateGroup[0];
|
||||
const { message: oldUpdateMessage, group: oldGroupId } = arbitraryUpdate;
|
||||
const newUpdateMessage = `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`;
|
||||
@@ -162,6 +169,7 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
branchId: update.branch.id,
|
||||
branchName: update.branch.name,
|
||||
})),
|
||||
codeSigningInfo,
|
||||
targetBranch: { branchId: defaultBranch.id, branchName: defaultBranch.name },
|
||||
updateMessage: newUpdateMessage,
|
||||
});
|
||||
@@ -172,7 +180,7 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
branchMapping: JSON.stringify(alwaysTrueDefaultBranchMapping),
|
||||
});
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(`🚗 Routed all traffic back to branch ${chalk.bold(defaultBranch.name)}`);
|
||||
Log.log(`⬅️ Reverted all users back to branch ${chalk.bold(defaultBranch.name)}`);
|
||||
Log.log(`✅ Successfully ended rollout`);
|
||||
return newChannelInfo;
|
||||
}
|
||||
@@ -180,7 +188,7 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
async confirmOutcomeAsync(
|
||||
ctx: EASUpdateContext,
|
||||
selectedOutcome: EndOutcome,
|
||||
rollout: Rollout
|
||||
rollout: Rollout<UpdateBranchObject>
|
||||
): Promise<boolean> {
|
||||
const { nonInteractive } = ctx;
|
||||
if (nonInteractive) {
|
||||
@@ -188,21 +196,21 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
}
|
||||
const { rolledOutBranch, defaultBranch } = rollout;
|
||||
Log.newLine();
|
||||
if (selectedOutcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
|
||||
if (selectedOutcome === EndOutcome.REPUBLISH_AND_REVERT) {
|
||||
Log.log(`Ending the rollout will do the following:`);
|
||||
const actions = formatFields([
|
||||
{
|
||||
label: '1.',
|
||||
value: `🔁 Republish the Update Group from ${chalk.bold(
|
||||
value: `🔁 Republish the update group from ${chalk.bold(
|
||||
rolledOutBranch.name
|
||||
)} onto ${chalk.bold(defaultBranch.name)}`,
|
||||
},
|
||||
{ label: '2.', value: `⬅️ Route all users back to ${chalk.bold(defaultBranch.name)}` },
|
||||
{ label: '2.', value: `⬅️ Revert all users back to ${chalk.bold(defaultBranch.name)}` },
|
||||
]);
|
||||
Log.log(actions);
|
||||
} else {
|
||||
Log.log(
|
||||
`⬅️ Ending the rollout will route all users back to ${chalk.bold(defaultBranch.name)}`
|
||||
`⬅️ Ending the rollout will revert all users back to ${chalk.bold(defaultBranch.name)}`
|
||||
);
|
||||
}
|
||||
return await confirmAsync({
|
||||
|
||||
@@ -11,11 +11,16 @@ import {
|
||||
EditRollout,
|
||||
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
|
||||
} from './EditRollout';
|
||||
import { EndRollout, NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
|
||||
import {
|
||||
EndRollout,
|
||||
GeneralOptions as EndRolloutGeneralOptions,
|
||||
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
|
||||
} from './EndRollout';
|
||||
|
||||
export enum ManageRolloutActions {
|
||||
EDIT = 'Edit',
|
||||
END = 'End',
|
||||
VIEW = 'View',
|
||||
GO_BACK = 'Go back',
|
||||
}
|
||||
|
||||
@@ -27,9 +32,10 @@ export class ManageRollout implements EASUpdateAction<EASUpdateAction> {
|
||||
private channelInfo: UpdateChannelBasicInfoFragment,
|
||||
private options: {
|
||||
callingAction?: EASUpdateAction;
|
||||
action?: ManageRolloutActions.EDIT | ManageRolloutActions.END;
|
||||
action?: ManageRolloutActions.EDIT | ManageRolloutActions.END | ManageRolloutActions.VIEW;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> = {}
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
EndRolloutGeneralOptions
|
||||
) {}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<EASUpdateAction> {
|
||||
@@ -46,6 +52,9 @@ export class ManageRollout implements EASUpdateAction<EASUpdateAction> {
|
||||
return new EditRollout(this.channelInfo, this.options);
|
||||
case ManageRolloutActions.END:
|
||||
return new EndRollout(this.channelInfo, this.options);
|
||||
case ManageRolloutActions.VIEW:
|
||||
// Rollout is automatically printed in interactive mode
|
||||
return new Noop();
|
||||
case ManageRolloutActions.GO_BACK:
|
||||
assert(this.options.callingAction, 'calling action must be defined');
|
||||
return this.options.callingAction;
|
||||
@@ -89,3 +98,7 @@ export class ManageRollout implements EASUpdateAction<EASUpdateAction> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class Noop {
|
||||
public async runAsync(): Promise<void> {}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
|
||||
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import {
|
||||
ChannelQuery,
|
||||
UpdateBranchObject,
|
||||
UpdateChannelObject,
|
||||
} from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import { printJsonOnlyOutput } from '../../utils/json';
|
||||
import { getRollout, getRolloutInfo, isConstrainedRolloutInfo, isRollout } from '../branch-mapping';
|
||||
import { printRollout } from '../utils';
|
||||
import {
|
||||
CreateRollout,
|
||||
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
|
||||
@@ -9,10 +17,27 @@ import {
|
||||
EditRollout,
|
||||
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
|
||||
} from './EditRollout';
|
||||
import { EndRollout, NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
|
||||
import {
|
||||
EndRollout,
|
||||
GeneralOptions as EndRolloutGeneralOptions,
|
||||
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
|
||||
} from './EndRollout';
|
||||
import { ManageRolloutActions } from './ManageRollout';
|
||||
import { MainMenuActions, RolloutActions } from './RolloutMainMenu';
|
||||
|
||||
type JSONRolloutOutput = {
|
||||
defaultBranch: UpdateBranchObject;
|
||||
rolledOutBranch: UpdateBranchObject;
|
||||
percentRolledOut: number;
|
||||
runtimeVersion?: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type JSONOutput = {
|
||||
hasRollout: boolean;
|
||||
originalRolloutInfo?: JSONRolloutOutput;
|
||||
currentRolloutInfo?: JSONRolloutOutput;
|
||||
};
|
||||
/**
|
||||
* Control a rollout in non interactive mode.
|
||||
*/
|
||||
@@ -20,10 +45,12 @@ export class NonInteractiveRollout implements EASUpdateAction<void> {
|
||||
constructor(
|
||||
private options: {
|
||||
channelName?: string;
|
||||
json?: boolean;
|
||||
action?: RolloutActions;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
Partial<CreateRolloutNonInteractiveOptions> = {}
|
||||
EndRolloutGeneralOptions &
|
||||
Partial<CreateRolloutNonInteractiveOptions>
|
||||
) {}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<void> {
|
||||
@@ -42,28 +69,102 @@ export class NonInteractiveRollout implements EASUpdateAction<void> {
|
||||
appId: app.projectId,
|
||||
channelName,
|
||||
});
|
||||
const channelObject = await this.getChannelObjectAsync(ctx, {
|
||||
channelName,
|
||||
runtimeVersion: this.getRuntimeVersion(channelInfo),
|
||||
});
|
||||
|
||||
if (!action) {
|
||||
throw new Error(`--action is required in non-interactive mode.`);
|
||||
}
|
||||
await this.runActionAsync(ctx, action, channelInfo, this.options);
|
||||
const updatedChannelInfo = await this.runActionAsync(ctx, action, channelObject);
|
||||
const updatedChannelObject = await this.getChannelObjectAsync(ctx, {
|
||||
channelName,
|
||||
runtimeVersion: this.getRuntimeVersion(updatedChannelInfo),
|
||||
});
|
||||
if (this.options.json) {
|
||||
const json = await this.getJsonAsync({
|
||||
originalChannelObject: channelObject,
|
||||
updatedChannelObject,
|
||||
});
|
||||
printJsonOnlyOutput(json);
|
||||
}
|
||||
}
|
||||
|
||||
async runActionAsync(
|
||||
ctx: EASUpdateContext,
|
||||
action: RolloutActions,
|
||||
channelInfo: UpdateChannelBasicInfoFragment,
|
||||
options: Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
Partial<CreateRolloutNonInteractiveOptions>
|
||||
channelObject: UpdateChannelObject
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
switch (action) {
|
||||
case MainMenuActions.CREATE_NEW:
|
||||
return await new CreateRollout(channelInfo, options).runAsync(ctx);
|
||||
return await new CreateRollout(channelObject, this.options).runAsync(ctx);
|
||||
case ManageRolloutActions.EDIT:
|
||||
return await new EditRollout(channelInfo, options).runAsync(ctx);
|
||||
return await new EditRollout(channelObject, this.options).runAsync(ctx);
|
||||
case ManageRolloutActions.END:
|
||||
return await new EndRollout(channelInfo, options).runAsync(ctx);
|
||||
return await new EndRollout(channelObject, this.options).runAsync(ctx);
|
||||
case ManageRolloutActions.VIEW:
|
||||
return this.viewRollout(channelObject);
|
||||
}
|
||||
}
|
||||
|
||||
viewRollout(channelObject: UpdateChannelObject): UpdateChannelObject {
|
||||
if (!this.options.json) {
|
||||
printRollout(channelObject);
|
||||
Log.warn('For formatted output, add the --json flag to your command.');
|
||||
}
|
||||
return channelObject;
|
||||
}
|
||||
|
||||
async getJsonAsync({
|
||||
originalChannelObject,
|
||||
updatedChannelObject,
|
||||
}: {
|
||||
originalChannelObject: UpdateChannelObject;
|
||||
updatedChannelObject: UpdateChannelObject;
|
||||
}): Promise<JSONOutput> {
|
||||
return {
|
||||
hasRollout: isRollout(updatedChannelObject),
|
||||
...(isRollout(originalChannelObject)
|
||||
? { originalRolloutInfo: await this.getRolloutJsonAsync(originalChannelObject) }
|
||||
: {}),
|
||||
...(isRollout(updatedChannelObject)
|
||||
? { currentRolloutInfo: await this.getRolloutJsonAsync(updatedChannelObject) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async getRolloutJsonAsync(channelObject: UpdateChannelObject): Promise<JSONRolloutOutput> {
|
||||
const rollout = getRollout(channelObject);
|
||||
return {
|
||||
defaultBranch: rollout.defaultBranch,
|
||||
rolledOutBranch: rollout.rolledOutBranch,
|
||||
percentRolledOut: rollout.percentRolledOut,
|
||||
runtimeVersion: this.getRuntimeVersion(channelObject) ?? undefined,
|
||||
updatedAt: channelObject.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
getRuntimeVersion(channelInfo: UpdateChannelBasicInfoFragment): string | undefined {
|
||||
if (isRollout(channelInfo)) {
|
||||
const updatedRolloutInfo = getRolloutInfo(channelInfo);
|
||||
if (isConstrainedRolloutInfo(updatedRolloutInfo)) {
|
||||
return updatedRolloutInfo.runtimeVersion;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getChannelObjectAsync(
|
||||
ctx: EASUpdateContext,
|
||||
{ channelName, runtimeVersion }: { channelName: string; runtimeVersion?: string }
|
||||
): Promise<UpdateChannelObject> {
|
||||
const { graphqlClient, app } = ctx;
|
||||
const { projectId } = app;
|
||||
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName,
|
||||
...(runtimeVersion ? { filter: { runtimeVersions: [runtimeVersion] } } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
|
||||
} from './CreateRollout';
|
||||
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from './EditRollout';
|
||||
import { NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
|
||||
import {
|
||||
GeneralOptions as EndRolloutGeneralOptions,
|
||||
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
|
||||
} from './EndRollout';
|
||||
import { ManageRollout, ManageRolloutActions } from './ManageRollout';
|
||||
import { SelectRollout } from './SelectRollout';
|
||||
|
||||
@@ -24,7 +27,8 @@ export enum MainMenuActions {
|
||||
export type RolloutActions =
|
||||
| MainMenuActions.CREATE_NEW
|
||||
| ManageRolloutActions.EDIT
|
||||
| ManageRolloutActions.END;
|
||||
| ManageRolloutActions.END
|
||||
| ManageRolloutActions.VIEW;
|
||||
|
||||
/**
|
||||
* Manage a rollout for the project.
|
||||
@@ -36,7 +40,8 @@ export class RolloutMainMenu implements EASUpdateAction<void> {
|
||||
action?: RolloutActions;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
Partial<CreateRolloutNonInteractiveOptions> = {}
|
||||
EndRolloutGeneralOptions &
|
||||
Partial<CreateRolloutNonInteractiveOptions>
|
||||
) {}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<void> {
|
||||
@@ -55,7 +60,7 @@ export class RolloutMainMenu implements EASUpdateAction<void> {
|
||||
case MainMenuActions.CREATE_NEW: {
|
||||
const channelInfo = channelName
|
||||
? await this.resolveChannelNameAsync(ctx, channelName)
|
||||
: await this.selectChannelAsync(ctx, channelInfo => !isRollout(channelInfo));
|
||||
: await this.selectChannelToRolloutAsync(ctx);
|
||||
await new CreateRollout(channelInfo, this.options).runAsync(ctx);
|
||||
return null;
|
||||
}
|
||||
@@ -88,14 +93,22 @@ export class RolloutMainMenu implements EASUpdateAction<void> {
|
||||
return channelInfo;
|
||||
}
|
||||
|
||||
async selectChannelAsync(
|
||||
ctx: EASUpdateContext,
|
||||
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean
|
||||
async selectChannelToRolloutAsync(
|
||||
ctx: EASUpdateContext
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
const selectChannelAction = new SelectChannel({ filterPredicate });
|
||||
let hasSomeChannel = false;
|
||||
const selectChannelAction = new SelectChannel({
|
||||
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) => {
|
||||
hasSomeChannel = true;
|
||||
return !isRollout(channelInfo);
|
||||
},
|
||||
});
|
||||
const channelInfo = await selectChannelAction.runAsync(ctx);
|
||||
if (!channelInfo) {
|
||||
throw new Error(`You dont have any channels. Create one with <TODO>`);
|
||||
const error = hasSomeChannel
|
||||
? new Error('All your channels are already part of a rollout.')
|
||||
: new Error(`You dont have any channels. Create one with \`eas channel:create\``);
|
||||
throw error;
|
||||
}
|
||||
return channelInfo;
|
||||
}
|
||||
@@ -114,7 +127,11 @@ export class RolloutMainMenu implements EASUpdateAction<void> {
|
||||
toMainMenuAction(action: RolloutActions): MainMenuActions {
|
||||
if (action === MainMenuActions.CREATE_NEW) {
|
||||
return MainMenuActions.CREATE_NEW;
|
||||
} else if (action === ManageRolloutActions.EDIT || action === ManageRolloutActions.END) {
|
||||
} else if (
|
||||
action === ManageRolloutActions.EDIT ||
|
||||
action === ManageRolloutActions.END ||
|
||||
action === ManageRolloutActions.VIEW
|
||||
) {
|
||||
return MainMenuActions.MANAGE_EXISTING;
|
||||
} else {
|
||||
throw new Error(`Action not supported yet: ` + action);
|
||||
|
||||
@@ -7,21 +7,14 @@ import { RuntimeFragment, UpdateBranchBasicInfoFragment } from '../../graphql/ge
|
||||
import { RuntimeQuery } from '../../graphql/queries/RuntimeQuery';
|
||||
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { Connection, QueryParams, selectPaginatedAsync } from '../../utils/relay';
|
||||
import { formatRuntimeWithUpdateGroup } from '../utils';
|
||||
|
||||
function beginSentence(phrase: string): string {
|
||||
if (typeof phrase !== 'string' || phrase.length === 0) {
|
||||
return phrase; // Return the input without any modification if it's not a string or empty
|
||||
}
|
||||
return phrase.charAt(0).toUpperCase() + phrase.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a runtime from a branch
|
||||
*/
|
||||
export class SelectRuntime implements EASUpdateAction<string> {
|
||||
export class SelectRuntime implements EASUpdateAction<string | null> {
|
||||
private printedType;
|
||||
constructor(
|
||||
private branchInfo: UpdateBranchBasicInfoFragment,
|
||||
@@ -40,8 +33,9 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
Log.warn(
|
||||
`⚠️ Branches ${this.branchInfo.name} and ${intersectBranchName} dont have any updates with the same runtime.`
|
||||
);
|
||||
// TODO(quin): write a learn more
|
||||
Log.warn(`Your updates could be misconfigured. ${learnMore('https://expo.fyi/todo')}`);
|
||||
Log.warn(
|
||||
`Your updates could be misconfigured. ${learnMore('https://expo.fyi/eas-update-rollouts')}`
|
||||
);
|
||||
} else {
|
||||
// no runtime on branch means no updates published on branch
|
||||
Log.warn(`⚠️ There are no updates published on branch ${this.branchInfo.name}.`);
|
||||
@@ -49,10 +43,12 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
}
|
||||
|
||||
formatCantFindRuntime(): string {
|
||||
return `🕵️ Not finding the update you were looking for? ${learnMore('https://expo.fyi/todo')}`;
|
||||
return `🕵️ Not finding the update you were looking for? ${learnMore(
|
||||
'https://expo.fyi/eas-update-rollouts'
|
||||
)}`;
|
||||
}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<string> {
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<string | null> {
|
||||
const { nonInteractive, graphqlClient, app } = ctx;
|
||||
const { projectId } = app;
|
||||
if (nonInteractive) {
|
||||
@@ -65,16 +61,17 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
anotherBranchIdToIntersectRuntimesBy: this.options.anotherBranchToIntersectRuntimesBy?.id,
|
||||
});
|
||||
|
||||
if (!newestRuntimeConnection) {
|
||||
if (newestRuntimeConnection.edges.length === 0) {
|
||||
Log.addNewLineIfNone();
|
||||
this.warnNoRuntime();
|
||||
return await this.promptForRuntimeAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
const moreThanOneRuntime = newestRuntimeConnection.edges.length > 1;
|
||||
Log.log(`✅ ${beginSentence(this.printedType)}${moreThanOneRuntime ? 's' : ''} detected`);
|
||||
const onlyOneRuntime =
|
||||
newestRuntimeConnection.edges.length === 1 && !newestRuntimeConnection.pageInfo.hasNextPage;
|
||||
Log.log(`✅ ${beginSentence(this.printedType)}${onlyOneRuntime ? '' : 's'} detected`);
|
||||
|
||||
if (!moreThanOneRuntime) {
|
||||
if (onlyOneRuntime) {
|
||||
const runtime = newestRuntimeConnection.edges[0].node;
|
||||
const formattedRuntimeWithGroup = await this.displayLatestUpdateGroupAsync({
|
||||
graphqlClient,
|
||||
@@ -93,15 +90,13 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
} else {
|
||||
Log.newLine();
|
||||
Log.warn(this.formatCantFindRuntime());
|
||||
return await this.promptForRuntimeAsync();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Log.log(this.formatCantFindRuntime());
|
||||
const selectedRuntime = await this.selectRuntimesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName: this.branchInfo.name,
|
||||
anotherBranchIdToIntersectRuntimesBy: this.options.anotherBranchToIntersectRuntimesBy?.id,
|
||||
});
|
||||
if (!selectedRuntime) {
|
||||
throw new Error(`No ${this.printedType} selected`);
|
||||
@@ -120,8 +115,8 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
branchName: string;
|
||||
anotherBranchIdToIntersectRuntimesBy?: string;
|
||||
}
|
||||
): Promise<Connection<RuntimeFragment> | null> {
|
||||
const connection = await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
|
||||
): Promise<Connection<RuntimeFragment>> {
|
||||
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
|
||||
appId,
|
||||
name: branchName,
|
||||
first: 1,
|
||||
@@ -129,11 +124,6 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
branchId: anotherBranchIdToIntersectRuntimesBy,
|
||||
},
|
||||
});
|
||||
const { edges } = connection;
|
||||
if (edges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
async displayLatestUpdateGroupAsync({
|
||||
@@ -160,33 +150,29 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
updateGroups.length < 2,
|
||||
`Expected at most one update group. Received: ${JSON.stringify(updateGroups)}`
|
||||
);
|
||||
return formatRuntimeWithUpdateGroup(updateGroups[0], runtime);
|
||||
return formatRuntimeWithUpdateGroup(updateGroups[0], runtime, branchName);
|
||||
}
|
||||
|
||||
async selectRuntimesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
anotherBranchIdToIntersectRuntimesBy,
|
||||
batchSize = 5,
|
||||
}: {
|
||||
appId: string;
|
||||
branchName: string;
|
||||
anotherBranchIdToIntersectRuntimesBy?: string;
|
||||
batchSize?: number;
|
||||
}
|
||||
): Promise<RuntimeFragment | null> {
|
||||
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
|
||||
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
|
||||
appId,
|
||||
name: branchName,
|
||||
name: this.branchInfo.name,
|
||||
first: queryParams.first,
|
||||
after: queryParams.after,
|
||||
last: queryParams.last,
|
||||
before: queryParams.before,
|
||||
filter: {
|
||||
branchId: anotherBranchIdToIntersectRuntimesBy,
|
||||
branchId: this.options.anotherBranchToIntersectRuntimesBy?.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -194,7 +180,7 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
return await this.displayLatestUpdateGroupAsync({
|
||||
graphqlClient,
|
||||
appId,
|
||||
branchName,
|
||||
branchName: this.branchInfo.name,
|
||||
runtime,
|
||||
});
|
||||
};
|
||||
@@ -205,15 +191,11 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
pageSize: batchSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async promptForRuntimeAsync(): Promise<string> {
|
||||
Log.log(`You can input a runtime manually then publish an update later.`);
|
||||
const { runtimeVersion } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'runtimeVersion',
|
||||
message: 'Input a runtime version:',
|
||||
initial: '1.0.0',
|
||||
});
|
||||
return runtimeVersion;
|
||||
function beginSentence(phrase: string): string {
|
||||
if (typeof phrase !== 'string' || phrase.length === 0) {
|
||||
return phrase; // Return the input without any modification if it's not a string or empty
|
||||
}
|
||||
return phrase.charAt(0).toUpperCase() + phrase.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { testBasicChannelInfo, testChannelObject } from '../../../channel/__tests__/fixtures';
|
||||
import { getAlwaysTrueBranchMapping, getBranchMapping } from '../../../channel/branch-mapping';
|
||||
import { updateChannelBranchMappingAsync } from '../../../commands/channel/edit';
|
||||
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
|
||||
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { RolloutBranchMapping, editRolloutBranchMapping } from '../../branch-mapping';
|
||||
import { EditRollout } from '../EditRollout';
|
||||
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/ChannelQuery');
|
||||
jest.mock('../../../commands/channel/edit');
|
||||
describe(EditRollout, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('throws if the channel doesnt have an active rollout', async () => {
|
||||
const ctx = createCtxMock();
|
||||
const editRollout = new EditRollout({
|
||||
...testBasicChannelInfo,
|
||||
branchMapping: JSON.stringify(getAlwaysTrueBranchMapping('test-branch')),
|
||||
});
|
||||
await expect(editRollout.runAsync(ctx)).rejects.toThrowError('is not a rollout');
|
||||
});
|
||||
it('edits the channel with an active rollout', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelAsync).mockResolvedValue(testChannelObject);
|
||||
jest.mocked(updateChannelBranchMappingAsync).mockResolvedValue(testChannelObject);
|
||||
jest.mocked(confirmAsync).mockImplementation(async () => true);
|
||||
const editRollout = new EditRollout(testChannelObject, { percent: 50 });
|
||||
await editRollout.runAsync(ctx);
|
||||
expect(updateChannelBranchMappingAsync).toBeCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
branchMapping: JSON.stringify(
|
||||
editRolloutBranchMapping(
|
||||
getBranchMapping(testChannelObject.branchMapping) as RolloutBranchMapping,
|
||||
50
|
||||
)
|
||||
),
|
||||
})
|
||||
);
|
||||
});
|
||||
it('works in Non-Interactive Mode', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelAsync).mockResolvedValue(testChannelObject);
|
||||
jest.mocked(updateChannelBranchMappingAsync).mockResolvedValue(testChannelObject);
|
||||
jest.mocked(confirmAsync).mockImplementation(async () => true);
|
||||
const editRollout = new EditRollout(testChannelObject, { percent: 50 });
|
||||
await editRollout.runAsync(ctx);
|
||||
expect(updateChannelBranchMappingAsync).toBeCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
branchMapping: JSON.stringify(
|
||||
editRolloutBranchMapping(
|
||||
getBranchMapping(testChannelObject.branchMapping) as RolloutBranchMapping,
|
||||
50
|
||||
)
|
||||
),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ describe(SelectRollout, () => {
|
||||
const rollout = await selectRollout.runAsync(ctx);
|
||||
expect(rollout).toBeNull();
|
||||
});
|
||||
it('returns the rollout without prompting if project only one rollout', async () => {
|
||||
it('still prompts if project has only one rollout', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
|
||||
@@ -34,9 +34,13 @@ describe(SelectRollout, () => {
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: testBasicChannelInfo,
|
||||
}));
|
||||
const selectRollout = new SelectRollout();
|
||||
const rollout = await selectRollout.runAsync(ctx);
|
||||
expect(rollout).toBe(testBasicChannelInfo);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('returns the selected rollout', async () => {
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { testUpdateBranch1, testUpdateBranch2 } from '../../../channel/__tests__/fixtures';
|
||||
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
|
||||
import { NonInteractiveError } from '../../../eas-update/utils';
|
||||
import { RuntimeQuery } from '../../../graphql/queries/RuntimeQuery';
|
||||
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { SelectRuntime } from '../SelectRuntime';
|
||||
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/RuntimeQuery');
|
||||
jest.mock('../../../graphql/queries/UpdateQuery');
|
||||
const runtimeFragment1 = { id: 'test-runtime-1-id', version: '1.0.0' };
|
||||
const runtimeFragment2 = { id: 'test-runtime-2-id', version: '2.0.0' };
|
||||
describe(SelectRuntime, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('returns null if project has no runtimes', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(RuntimeQuery.getRuntimesOnBranchAsync).mockResolvedValue({
|
||||
edges: [],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
const selectRuntime = new SelectRuntime(testUpdateBranch1);
|
||||
const runtime = await selectRuntime.runAsync(ctx);
|
||||
expect(runtime).toBeNull();
|
||||
});
|
||||
it('still prompts if project has only one runtime', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(RuntimeQuery.getRuntimesOnBranchAsync).mockResolvedValue({
|
||||
edges: [{ node: runtimeFragment1, cursor: 'cursor' }],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([]);
|
||||
jest.mocked(confirmAsync).mockImplementation(async () => true);
|
||||
const selectRuntime = new SelectRuntime(testUpdateBranch1);
|
||||
const runtime = await selectRuntime.runAsync(ctx);
|
||||
expect(runtime).toBe('1.0.0');
|
||||
expect(confirmAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('returns the selected runtime', async () => {
|
||||
jest.mocked(RuntimeQuery.getRuntimesOnBranchAsync).mockResolvedValue({
|
||||
edges: [
|
||||
{ node: runtimeFragment1, cursor: 'cursor' },
|
||||
{ node: runtimeFragment2, cursor: 'cursor' },
|
||||
],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: runtimeFragment2,
|
||||
}));
|
||||
const ctx = createCtxMock();
|
||||
const selectRuntime = new SelectRuntime(testUpdateBranch1);
|
||||
const runtime = await selectRuntime.runAsync(ctx);
|
||||
expect(runtime).toBe('2.0.0');
|
||||
});
|
||||
it('filters the runtime by intersecting branch id', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(RuntimeQuery.getRuntimesOnBranchAsync).mockResolvedValue({
|
||||
edges: [],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
const selectRuntime = new SelectRuntime(testUpdateBranch1, {
|
||||
anotherBranchToIntersectRuntimesBy: testUpdateBranch2,
|
||||
});
|
||||
const runtime = await selectRuntime.runAsync(ctx);
|
||||
expect(runtime).toBeNull();
|
||||
expect(RuntimeQuery.getRuntimesOnBranchAsync).toBeCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
filter: { branchId: testUpdateBranch2.id },
|
||||
})
|
||||
);
|
||||
});
|
||||
it('throws an error in Non-Interactive Mode', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
const selectRuntime = new SelectRuntime(testUpdateBranch1);
|
||||
|
||||
// dont fail if users are running in non-interactive mode
|
||||
await expect(selectRuntime.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import {
|
||||
BranchMapping,
|
||||
BranchMappingAlwaysTrue,
|
||||
@@ -16,19 +14,24 @@ import {
|
||||
isAndStatement,
|
||||
isStatement,
|
||||
} from '../channel/branch-mapping';
|
||||
import { getUpdateBranch } from '../channel/utils';
|
||||
import { UpdateChannelBasicInfoFragment } from '../graphql/generated';
|
||||
import { UpdateBranchObject, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import {
|
||||
BranchBasicInfo,
|
||||
ChannelBasicInfo,
|
||||
UpdateChannelInfoWithBranches,
|
||||
getUpdateBranch,
|
||||
} from '../channel/utils';
|
||||
|
||||
export type Rollout = LegacyRollout | ConstrainedRollout;
|
||||
export type Rollout<Branch extends BranchBasicInfo> =
|
||||
| LegacyRollout<Branch>
|
||||
| ConstrainedRollout<Branch>;
|
||||
export type RolloutInfo = LegacyRolloutInfo | ConstrainedRolloutInfo;
|
||||
type ConstrainedRollout = LegacyRollout & {
|
||||
type ConstrainedRollout<Branch extends BranchBasicInfo> = LegacyRollout<Branch> & {
|
||||
runtimeVersion: string;
|
||||
};
|
||||
|
||||
type LegacyRollout = {
|
||||
rolledOutBranch: UpdateBranchObject;
|
||||
defaultBranch: UpdateBranchObject;
|
||||
type LegacyRollout<Branch extends BranchBasicInfo> = {
|
||||
rolledOutBranch: Branch;
|
||||
defaultBranch: Branch;
|
||||
} & LegacyRolloutInfo;
|
||||
|
||||
type ConstrainedRolloutInfo = LegacyRolloutInfo & {
|
||||
@@ -95,11 +98,13 @@ export function isConstrainedRolloutInfo(rollout: RolloutInfo): rollout is Const
|
||||
return 'runtimeVersion' in rollout;
|
||||
}
|
||||
|
||||
export function isConstrainedRollout(rollout: Rollout): rollout is ConstrainedRollout {
|
||||
export function isConstrainedRollout<Branch extends BranchBasicInfo>(
|
||||
rollout: Rollout<Branch>
|
||||
): rollout is ConstrainedRollout<Branch> {
|
||||
return isConstrainedRolloutInfo(rollout);
|
||||
}
|
||||
|
||||
export function getRolloutInfo(basicChannelInfo: UpdateChannelBasicInfoFragment): RolloutInfo {
|
||||
export function getRolloutInfo(basicChannelInfo: ChannelBasicInfo): RolloutInfo {
|
||||
const rolloutBranchMapping = getRolloutBranchMapping(basicChannelInfo.branchMapping);
|
||||
return getRolloutInfoFromBranchMapping(rolloutBranchMapping);
|
||||
}
|
||||
@@ -114,19 +119,24 @@ export function getRolloutInfoFromBranchMapping(branchMapping: RolloutBranchMapp
|
||||
const nodesFromStatement = getNodesFromStatement(statementNode);
|
||||
|
||||
const runtimeVersionNode = nodesFromStatement.find(isRuntimeVersionNode);
|
||||
assert(runtimeVersionNode, 'Runtime version node must be defined.');
|
||||
if (!runtimeVersionNode) {
|
||||
throw new BranchMappingValidationError('Runtime version node must be defined.');
|
||||
}
|
||||
|
||||
assertNodeObject(runtimeVersionNode);
|
||||
const runtimeVersion = runtimeVersionNode.operand;
|
||||
assertString(runtimeVersion);
|
||||
|
||||
const rolloutNode = nodesFromStatement.find(isRolloutNode);
|
||||
assert(rolloutNode, 'Rollout node must be defined.');
|
||||
if (!rolloutNode) {
|
||||
throw new BranchMappingValidationError('Rollout node must be defined.');
|
||||
}
|
||||
assertNodeObject(rolloutNode);
|
||||
const operand = rolloutNode.operand;
|
||||
assertNumber(operand);
|
||||
return {
|
||||
rolledOutBranchId,
|
||||
percentRolledOut: operand * 100,
|
||||
percentRolledOut: Math.round(operand * 100),
|
||||
runtimeVersion,
|
||||
defaultBranchId,
|
||||
};
|
||||
@@ -137,27 +147,29 @@ export function getRolloutInfoFromBranchMapping(branchMapping: RolloutBranchMapp
|
||||
assertNumber(operand);
|
||||
return {
|
||||
rolledOutBranchId,
|
||||
percentRolledOut: operand * 100,
|
||||
percentRolledOut: Math.round(operand * 100),
|
||||
defaultBranchId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getRollout(channel: UpdateChannelObject): Rollout {
|
||||
export function getRollout<Branch extends BranchBasicInfo>(
|
||||
channel: UpdateChannelInfoWithBranches<Branch>
|
||||
): Rollout<Branch> {
|
||||
const rolloutBranchMapping = getRolloutBranchMapping(channel.branchMapping);
|
||||
const rolledOutBranchId = rolloutBranchMapping.data[0].branchId;
|
||||
const rolledOutBranch = getUpdateBranch(channel, rolledOutBranchId);
|
||||
const defaultBranchId = rolloutBranchMapping.data[1].branchId;
|
||||
const defaultBranch = getUpdateBranch(channel, defaultBranchId);
|
||||
const rolloutInfo = getRolloutInfo(channel);
|
||||
return composeRollout(rolloutInfo, defaultBranch, rolledOutBranch);
|
||||
return composeRollout<Branch>(rolloutInfo, defaultBranch, rolledOutBranch);
|
||||
}
|
||||
|
||||
export function composeRollout(
|
||||
export function composeRollout<Branch extends BranchBasicInfo>(
|
||||
rolloutInfo: RolloutInfo,
|
||||
defaultBranch: UpdateBranchObject,
|
||||
rolledOutBranch: UpdateBranchObject
|
||||
): Rollout {
|
||||
defaultBranch: Branch,
|
||||
rolledOutBranch: Branch
|
||||
): Rollout<Branch> {
|
||||
if (rolloutInfo.defaultBranchId !== defaultBranch.id) {
|
||||
throw new BranchMappingValidationError(
|
||||
`Default branch id must match. Received: ${JSON.stringify(rolloutInfo)} ${defaultBranch.id}`
|
||||
@@ -240,11 +252,22 @@ export function isRolloutBranchMapping(
|
||||
return isUnconstrainedRollout(branchMapping) || isRtvConstrainedRollout(branchMapping);
|
||||
}
|
||||
|
||||
export function isRollout(channelInfo: UpdateChannelBasicInfoFragment): boolean {
|
||||
export function isRollout(channelInfo: ChannelBasicInfo): boolean {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
return isRolloutBranchMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function doesTargetRollout(
|
||||
branchMapping: RolloutBranchMapping,
|
||||
runtimeVersion: string
|
||||
): boolean {
|
||||
const rolloutInfo = getRolloutInfoFromBranchMapping(branchMapping);
|
||||
if (!isConstrainedRolloutInfo(rolloutInfo)) {
|
||||
return true;
|
||||
}
|
||||
return rolloutInfo.runtimeVersion === runtimeVersion;
|
||||
}
|
||||
|
||||
export function createRolloutBranchMapping({
|
||||
defaultBranchId,
|
||||
rolloutBranchId,
|
||||
@@ -302,7 +325,9 @@ function editRtvConstrainedRollout(
|
||||
const nodesFromStatement = getNodesFromStatement(statementNode);
|
||||
|
||||
const rolloutNode = nodesFromStatement.find(isRolloutNode);
|
||||
assert(rolloutNode, 'Rollout node must be defined.');
|
||||
if (!rolloutNode) {
|
||||
throw new BranchMappingValidationError('Rollout node must be defined.');
|
||||
}
|
||||
rolloutNode.operand = percent / 100;
|
||||
return newBranchMapping;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { RuntimeFragment, UpdateFragment } from '../graphql/generated';
|
||||
import {
|
||||
RuntimeFragment,
|
||||
UpdateBranchBasicInfoFragment,
|
||||
UpdateFragment,
|
||||
} from '../graphql/generated';
|
||||
import { UpdateBranchObject, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import Log from '../log';
|
||||
import { promptAsync } from '../prompts';
|
||||
import { FormattedUpdateGroupDescription, getUpdateGroupDescriptions } from '../update/utils';
|
||||
import formatFields from '../utils/formatFields';
|
||||
import { Rollout, getRollout, isConstrainedRollout } from './branch-mapping';
|
||||
import { Rollout, getRollout, isConstrainedRollout, isRollout } from './branch-mapping';
|
||||
|
||||
export function printRollout(channel: UpdateChannelObject): void {
|
||||
if (!isRollout(channel)) {
|
||||
Log.log(`Channel ${chalk.bold(channel.name)} doesn't have a rollout.`);
|
||||
return;
|
||||
}
|
||||
const rollout = getRollout(channel);
|
||||
displayRolloutDetails(channel.name, rollout);
|
||||
}
|
||||
|
||||
export function displayRolloutDetails(channelName: string, rollout: Rollout): void {
|
||||
export function displayRolloutDetails(
|
||||
channelName: string,
|
||||
rollout: Rollout<UpdateBranchBasicInfoFragment>
|
||||
): void {
|
||||
const rolledOutPercent = rollout.percentRolledOut;
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold('🚀 Rollout:'));
|
||||
@@ -21,7 +32,7 @@ export function displayRolloutDetails(channelName: string, rollout: Rollout): vo
|
||||
formatFields([
|
||||
{ label: 'Channel', value: channelName },
|
||||
...(isConstrainedRollout(rollout)
|
||||
? [{ label: 'Runtime Version', value: rollout.runtimeVersion }]
|
||||
? [{ label: 'Runtime version', value: rollout.runtimeVersion }]
|
||||
: []),
|
||||
{
|
||||
label: 'Branches',
|
||||
@@ -40,7 +51,11 @@ export function formatBranchWithUpdateGroup(
|
||||
percentRolledOut: number
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.bold(`🍽️ Served by branch ${chalk.bold(branch.name)} (${percentRolledOut}%)`));
|
||||
lines.push(
|
||||
chalk.bold(
|
||||
`➡️ 📱 Latest update on the ${chalk.bold(branch.name)} branch (${percentRolledOut}%)`
|
||||
)
|
||||
);
|
||||
if (!maybeUpdateGroup) {
|
||||
lines.push(`No updates for target runtime`);
|
||||
} else {
|
||||
@@ -52,10 +67,17 @@ export function formatBranchWithUpdateGroup(
|
||||
|
||||
export function formatRuntimeWithUpdateGroup(
|
||||
maybeUpdateGroup: UpdateFragment[] | undefined | null,
|
||||
runtime: RuntimeFragment
|
||||
runtime: RuntimeFragment,
|
||||
branchName: string
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.bold(`🍽️ Served by runtime ${chalk.bold(runtime.version)}:`));
|
||||
lines.push(
|
||||
chalk.bold(
|
||||
`➡️ 📱 Latest update on the ${chalk.bold(branchName)} branch served to runtime ${chalk.bold(
|
||||
runtime.version
|
||||
)}:`
|
||||
)
|
||||
);
|
||||
if (!maybeUpdateGroup) {
|
||||
lines.push(`No updates published for this runtime`);
|
||||
} else {
|
||||
@@ -69,7 +91,7 @@ function formatUpdateGroup(updateGroup: FormattedUpdateGroupDescription): string
|
||||
const lines: string[] = [];
|
||||
const formattedLines = formatFields([
|
||||
{ label: 'Message', value: updateGroup.message ?? 'N/A' },
|
||||
{ label: 'Runtime Version', value: updateGroup.runtimeVersion ?? 'N/A' },
|
||||
{ label: 'Runtime version', value: updateGroup.runtimeVersion ?? 'N/A' },
|
||||
{ label: 'Platforms', value: updateGroup.platforms ?? 'N/A' },
|
||||
{ label: 'Group ID', value: updateGroup.group ?? 'N/A' },
|
||||
]).split('\n');
|
||||
|
||||
@@ -10,7 +10,7 @@ interface IosSimulator {
|
||||
runtime: string;
|
||||
osVersion: string;
|
||||
windowName: string;
|
||||
osType: 'iOS';
|
||||
osType: 'iOS' | 'tvOS';
|
||||
state: 'Booted' | 'Shutdown';
|
||||
isAvailable: boolean;
|
||||
name: string;
|
||||
@@ -33,7 +33,7 @@ export async function selectSimulatorAsync(): Promise<IosSimulator> {
|
||||
message: `Select a simulator to run your app on`,
|
||||
name: 'selectedSimulator',
|
||||
choices: simulators.map(simulator => ({
|
||||
title: `iOS ${simulator.osVersion} ${simulator.name}`,
|
||||
title: `${simulator.osType} ${simulator.osVersion} ${simulator.name}`,
|
||||
value: simulator,
|
||||
})),
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export async function getAvaliableIosSimulatorsListAsync(query?: string): Promis
|
||||
// Create an array [tvOS, 13, 4]
|
||||
const [osType, ...osVersionComponents] = runtimeSuffix.split('-');
|
||||
|
||||
if (osType === 'iOS') {
|
||||
if (osType === 'iOS' || osType === 'tvOS') {
|
||||
// Join the end components [13, 4] -> '13.4'
|
||||
const osVersion = osVersionComponents.join('.');
|
||||
const sims = info.devices[runtime];
|
||||
@@ -80,7 +80,7 @@ export async function getAvaliableIosSimulatorsListAsync(query?: string): Promis
|
||||
runtime,
|
||||
osVersion,
|
||||
windowName: `${device.name} (${osVersion})`,
|
||||
osType: 'iOS' as const,
|
||||
osType,
|
||||
state: device.state as 'Booted' | 'Shutdown',
|
||||
lastBootedAt: device.lastBootedAt ? new Date(device.lastBootedAt) : undefined,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { App, Runtime, UpdateBranch, UpdateChannel } from '../../graphql/generated';
|
||||
import { App, Runtime, UpdateBranch, UpdateChannel, UpdateInsights } from '../../graphql/generated';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import { getBranchNameFromChannelNameAsync } from '../getBranchNameFromChannelNameAsync';
|
||||
|
||||
@@ -125,6 +125,7 @@ function mockUpdateBranches(branchNames: string[]): UpdateBranch[] {
|
||||
createdAt: '2022-12-07T02:24:43.487Z',
|
||||
runtimeVersion: '1.0.0',
|
||||
runtime: {} as Runtime, // Temporary fix to resolve type errors
|
||||
insights: {} as UpdateInsights, // Temporary fix to resolve type errors
|
||||
platform: 'ios',
|
||||
manifestFragment: '...',
|
||||
isRollBackToEmbedded: false,
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import assert from 'assert';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { getUpdateGroupUrl } from '../build/utils/url';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Update } from '../graphql/generated';
|
||||
import fetch from '../fetch';
|
||||
import { UpdateFragment, UpdateInfoGroup } from '../graphql/generated';
|
||||
import { PublishMutation } from '../graphql/mutations/PublishMutation';
|
||||
import Log, { link } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import {
|
||||
CodeSigningInfo,
|
||||
checkDirectiveBodyAgainstUpdateInfoGroup,
|
||||
checkManifestBodyAgainstUpdateInfoGroup,
|
||||
getDirectiveBodyAsync,
|
||||
getManifestBodyAsync,
|
||||
signBody,
|
||||
} from '../utils/code-signing';
|
||||
import formatFields from '../utils/formatFields';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
|
||||
@@ -15,15 +25,7 @@ export type UpdateToRepublish = {
|
||||
groupId: string;
|
||||
branchId: string;
|
||||
branchName: string;
|
||||
} & Pick<
|
||||
Update,
|
||||
| 'message'
|
||||
| 'runtimeVersion'
|
||||
| 'manifestFragment'
|
||||
| 'platform'
|
||||
| 'gitCommitHash'
|
||||
| 'codeSigningInfo'
|
||||
>;
|
||||
} & UpdateFragment;
|
||||
|
||||
/**
|
||||
* @param updatesToPublish The update group to republish
|
||||
@@ -35,6 +37,7 @@ export async function republishAsync({
|
||||
updatesToPublish,
|
||||
targetBranch,
|
||||
updateMessage,
|
||||
codeSigningInfo,
|
||||
json,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
@@ -42,6 +45,7 @@ export async function republishAsync({
|
||||
updatesToPublish: UpdateToRepublish[];
|
||||
targetBranch: { branchName: string; branchId: string };
|
||||
updateMessage: string;
|
||||
codeSigningInfo?: CodeSigningInfo;
|
||||
json?: boolean;
|
||||
}): Promise<void> {
|
||||
const { branchName: targetBranchName, branchId: targetBranchId } = targetBranch;
|
||||
@@ -55,51 +59,110 @@ export async function republishAsync({
|
||||
update.branchName === arbitraryUpdate.branchName &&
|
||||
update.runtimeVersion === arbitraryUpdate.runtimeVersion;
|
||||
assert(updatesToPublish.every(isSameGroup), 'All updates must belong to the same update group');
|
||||
|
||||
assert(
|
||||
updatesToPublish.every(u => u.isRollBackToEmbedded) ||
|
||||
updatesToPublish.every(u => !u.isRollBackToEmbedded),
|
||||
'All updates must either be roll back to embedded updates or not'
|
||||
);
|
||||
|
||||
const { runtimeVersion } = arbitraryUpdate;
|
||||
|
||||
// If codesigning was created for the original update, we need to add it to the republish
|
||||
// If codesigning was created for the original update, we need to add it to the republish.
|
||||
// If one wishes to not sign the republish or sign with a different key, a normal publish should
|
||||
// be performed.
|
||||
const shouldRepublishWithCodesigning = updatesToPublish.some(update => update.codeSigningInfo);
|
||||
if (shouldRepublishWithCodesigning) {
|
||||
Log.withTick(
|
||||
`The republished update will be signed with the same codesigning as the original update.`
|
||||
);
|
||||
if (!codeSigningInfo) {
|
||||
throw new Error(
|
||||
'Must specify --private-key-path argument to sign republished update group for code signing'
|
||||
);
|
||||
}
|
||||
|
||||
for (const update of updatesToPublish) {
|
||||
if (
|
||||
nullthrows(update.codeSigningInfo).alg !== codeSigningInfo.codeSigningMetadata.alg ||
|
||||
nullthrows(update.codeSigningInfo).keyid !== codeSigningInfo.codeSigningMetadata.keyid
|
||||
) {
|
||||
throw new Error(
|
||||
'Republished updates must use the same code signing key and algorithm as original update'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Log.withTick(`The republished update group will be signed`);
|
||||
}
|
||||
|
||||
const publishIndicator = ora('Republishing...').start();
|
||||
let updatesRepublished: Awaited<ReturnType<typeof PublishMutation.publishUpdateGroupAsync>>;
|
||||
|
||||
try {
|
||||
const arbitraryUpdate = updatesToPublish[0];
|
||||
const objectToMergeIn = arbitraryUpdate.isRollBackToEmbedded
|
||||
? {
|
||||
rollBackToEmbeddedInfoGroup: Object.fromEntries(
|
||||
updatesToPublish.map(update => [update.platform, true])
|
||||
),
|
||||
}
|
||||
: {
|
||||
updateInfoGroup: Object.fromEntries(
|
||||
updatesToPublish.map(update => [update.platform, JSON.parse(update.manifestFragment)])
|
||||
),
|
||||
};
|
||||
|
||||
updatesRepublished = await PublishMutation.publishUpdateGroupAsync(graphqlClient, [
|
||||
{
|
||||
branchId: targetBranchId,
|
||||
runtimeVersion,
|
||||
message: updateMessage,
|
||||
updateInfoGroup: Object.fromEntries(
|
||||
updatesToPublish.map(update => [update.platform, JSON.parse(update.manifestFragment)])
|
||||
),
|
||||
...objectToMergeIn,
|
||||
gitCommitHash: updatesToPublish[0].gitCommitHash,
|
||||
awaitingCodeSigningInfo: shouldRepublishWithCodesigning,
|
||||
awaitingCodeSigningInfo: !!codeSigningInfo,
|
||||
},
|
||||
]);
|
||||
|
||||
if (shouldRepublishWithCodesigning) {
|
||||
const codeSigningByPlatform = Object.fromEntries(
|
||||
updatesToPublish.map(update => [update.platform, update.codeSigningInfo])
|
||||
);
|
||||
if (codeSigningInfo) {
|
||||
Log.log('🔒 Signing republished update group');
|
||||
|
||||
await Promise.all(
|
||||
updatesRepublished.map(async update => {
|
||||
const codeSigning = codeSigningByPlatform[update.platform];
|
||||
if (codeSigning) {
|
||||
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, update.id, codeSigning);
|
||||
updatesRepublished.map(async newUpdate => {
|
||||
const response = await fetch(newUpdate.manifestPermalink, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'multipart/mixed' },
|
||||
});
|
||||
|
||||
let signature;
|
||||
if (newUpdate.isRollBackToEmbedded) {
|
||||
const directiveBody = nullthrows(await getDirectiveBodyAsync(response));
|
||||
|
||||
checkDirectiveBodyAgainstUpdateInfoGroup(directiveBody);
|
||||
signature = signBody(directiveBody, codeSigningInfo);
|
||||
} else {
|
||||
const manifestBody = nullthrows(await getManifestBodyAsync(response));
|
||||
|
||||
checkManifestBodyAgainstUpdateInfoGroup(
|
||||
manifestBody,
|
||||
nullthrows(
|
||||
nullthrows(objectToMergeIn.updateInfoGroup)[
|
||||
newUpdate.platform as keyof UpdateInfoGroup
|
||||
]
|
||||
)
|
||||
);
|
||||
signature = signBody(manifestBody, codeSigningInfo);
|
||||
}
|
||||
|
||||
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
|
||||
alg: codeSigningInfo.codeSigningMetadata.alg,
|
||||
keyid: codeSigningInfo.codeSigningMetadata.keyid,
|
||||
sig: signature,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
publishIndicator.succeed('Republished update');
|
||||
publishIndicator.succeed('Republished update group');
|
||||
} catch (error: any) {
|
||||
publishIndicator.fail('Failed to republish update');
|
||||
publishIndicator.fail('Failed to republish update group');
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -124,7 +187,7 @@ export async function republishAsync({
|
||||
{ label: 'Branch', value: targetBranchName },
|
||||
{ label: 'Runtime version', value: arbitraryRepublishedUpdate.runtimeVersion },
|
||||
{ label: 'Platform', value: updatesRepublished.map(update => update.platform).join(', ') },
|
||||
{ label: 'Update Group ID', value: arbitraryRepublishedUpdate.group },
|
||||
{ label: 'Update group ID', value: arbitraryRepublishedUpdate.group },
|
||||
...(updatesRepublishedByPlatform.android
|
||||
? [{ label: 'Android update ID', value: updatesRepublishedByPlatform.android.id }]
|
||||
: []),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile, EasJsonAccessor, EasJsonUtils, errors } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
|
||||
import Log from '../../log';
|
||||
import { selectAsync } from '../../prompts';
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
maybePrintBuildProfileDeprecationWarningsAsync,
|
||||
} from '../profiles';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('@expo/eas-json', () => {
|
||||
const actual = jest.requireActual('@expo/eas-json');
|
||||
@@ -32,6 +36,7 @@ const getBuildProfileDeprecationWarningsAsync = jest.spyOn(
|
||||
);
|
||||
const newLineSpy = jest.spyOn(Log, 'newLine');
|
||||
const warnSpy = jest.spyOn(Log, 'warn');
|
||||
const projectDir = '/app';
|
||||
|
||||
describe(getProfilesAsync, () => {
|
||||
afterEach(() => {
|
||||
@@ -51,6 +56,7 @@ describe(getProfilesAsync, () => {
|
||||
platforms: [Platform.ANDROID, Platform.IOS],
|
||||
profileName: undefined,
|
||||
type: 'build',
|
||||
projectDir,
|
||||
});
|
||||
|
||||
expect(result[0].profileName).toBe('production');
|
||||
@@ -71,6 +77,7 @@ describe(getProfilesAsync, () => {
|
||||
platforms: [Platform.ANDROID],
|
||||
profileName: undefined,
|
||||
type: 'build',
|
||||
projectDir,
|
||||
})
|
||||
).rejects.toThrowError(errors.MissingProfileError);
|
||||
});
|
||||
@@ -86,6 +93,7 @@ describe(getProfilesAsync, () => {
|
||||
platforms: [Platform.ANDROID, Platform.IOS],
|
||||
profileName: 'custom-profile',
|
||||
type: 'build',
|
||||
projectDir,
|
||||
});
|
||||
|
||||
expect(result[0].profileName).toBe('custom-profile');
|
||||
@@ -98,6 +106,52 @@ describe(getProfilesAsync, () => {
|
||||
expect(getBuildProfileAsync).toBeCalledWith(easJsonAccessor, Platform.IOS, 'custom-profile');
|
||||
});
|
||||
|
||||
describe('node version', () => {
|
||||
const nodeVersion = '14.17.1';
|
||||
|
||||
it('is read from profile', async () => {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
getBuildProfileAsync.mockImplementation(async () => {
|
||||
return { node: nodeVersion } as BuildProfile<Platform.ANDROID>;
|
||||
});
|
||||
const result = await getProfilesAsync({
|
||||
easJsonAccessor,
|
||||
platforms: [Platform.ANDROID],
|
||||
profileName: 'custom-profile',
|
||||
type: 'build',
|
||||
projectDir,
|
||||
});
|
||||
|
||||
expect(result[0].profile.node).toBe(nodeVersion);
|
||||
});
|
||||
|
||||
describe('with .nvmrc', () => {
|
||||
beforeEach(async () => {
|
||||
vol.reset();
|
||||
await fs.mkdirp(os.tmpdir());
|
||||
|
||||
vol.fromJSON({ '.nvmrc': nodeVersion }, projectDir);
|
||||
});
|
||||
|
||||
it('is read from .nvmrc', async () => {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
getBuildProfileAsync.mockImplementation(async () => {
|
||||
return {} as BuildProfile<Platform.ANDROID>;
|
||||
});
|
||||
|
||||
const result = await getProfilesAsync({
|
||||
easJsonAccessor,
|
||||
platforms: [Platform.ANDROID],
|
||||
profileName: 'custom-profile',
|
||||
type: 'build',
|
||||
projectDir,
|
||||
});
|
||||
|
||||
expect(result[0].profile.node).toBe(nodeVersion);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('throws validation error if eas.json is invalid', async () => {
|
||||
getBuildProfileAsync.mockImplementation(() => {
|
||||
throw new errors.InvalidEasJsonError('eas.json is not valid');
|
||||
@@ -109,6 +163,7 @@ describe(getProfilesAsync, () => {
|
||||
platforms: [Platform.ANDROID, Platform.IOS],
|
||||
profileName: undefined,
|
||||
type: 'build',
|
||||
projectDir,
|
||||
})
|
||||
).rejects.toThrowError(/eas.json is not valid/);
|
||||
});
|
||||
|
||||
@@ -627,11 +627,15 @@ describe(selectPaginatedAsync, () => {
|
||||
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
|
||||
});
|
||||
|
||||
test('returns the only item when there is only one', async () => {
|
||||
test('still prompts if there is only one item avaiable', async () => {
|
||||
const node = { id: '1', name: 'Node 1' };
|
||||
queryAsync.mockResolvedValueOnce({
|
||||
edges: [{ node }],
|
||||
pageInfo: { hasNextPage: false },
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: node,
|
||||
}));
|
||||
|
||||
const result = await selectPaginatedAsync({
|
||||
queryAsync,
|
||||
@@ -644,6 +648,7 @@ describe(selectPaginatedAsync, () => {
|
||||
expect(queryAsync).toHaveBeenCalledWith({
|
||||
first: pageSize,
|
||||
});
|
||||
expect(promptAsync).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('prompts for selection when there are multiple items', async () => {
|
||||
@@ -677,15 +682,6 @@ describe(selectPaginatedAsync, () => {
|
||||
const node2 = { id: '2', name: 'Node 2' };
|
||||
const node3 = { id: '3', name: 'Node 3' };
|
||||
|
||||
// for preflight
|
||||
queryAsync.mockResolvedValueOnce({
|
||||
edges: [{ node: node1 }, { node: node2 }],
|
||||
pageInfo: {
|
||||
endCursor: 'endCursor',
|
||||
hasNextPage: true,
|
||||
},
|
||||
});
|
||||
|
||||
queryAsync.mockResolvedValueOnce({
|
||||
edges: [{ node: node1 }, { node: node2 }],
|
||||
pageInfo: {
|
||||
@@ -739,15 +735,6 @@ describe(selectPaginatedAsync, () => {
|
||||
const node2 = { id: '2', name: 'Node 2' };
|
||||
const node3 = { id: '3', name: 'Node 3' };
|
||||
|
||||
// for preflight
|
||||
queryAsync.mockResolvedValueOnce({
|
||||
edges: [{ node: node1 }, { node: node2 }],
|
||||
pageInfo: {
|
||||
endCursor: 'endCursor',
|
||||
hasNextPage: true,
|
||||
},
|
||||
});
|
||||
|
||||
queryAsync.mockResolvedValueOnce({
|
||||
edges: [{ node: node1 }, { node: node2 }],
|
||||
pageInfo: {
|
||||
|
||||
@@ -56,7 +56,26 @@ export function shouldUseVersionedExpoCLIExpensive(
|
||||
return !!resolveFrom.silent(projectDir, '@expo/cli');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if we can and should use `expo export` with multiple `--platform` flags.
|
||||
* This is an issue related to `expo export --all` causing issues when users have Metro web configured.
|
||||
* See: https://github.com/expo/expo/pull/23621
|
||||
*/
|
||||
export function shouldUseVersionedExpoCLIWithExplicitPlatformsExpensive(
|
||||
projectDir: string
|
||||
): boolean {
|
||||
const expoCliPath = resolveFrom.silent(projectDir, '@expo/cli/package.json');
|
||||
if (!expoCliPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return gteSdkVersion(require(expoCliPath).version, '0.10.11');
|
||||
}
|
||||
|
||||
export const shouldUseVersionedExpoCLI = memoize(shouldUseVersionedExpoCLIExpensive);
|
||||
export const shouldUseVersionedExpoCLIWithExplicitPlatforms = memoize(
|
||||
shouldUseVersionedExpoCLIWithExplicitPlatformsExpensive
|
||||
);
|
||||
|
||||
export async function expoCommandAsync(
|
||||
projectDir: string,
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
ProfileType,
|
||||
SubmitProfile,
|
||||
} from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import Log, { learnMore } from '../log';
|
||||
|
||||
@@ -24,19 +26,23 @@ export async function getProfilesAsync<T extends ProfileType>({
|
||||
platforms,
|
||||
profileName,
|
||||
type,
|
||||
projectDir,
|
||||
}: {
|
||||
easJsonAccessor: EasJsonAccessor;
|
||||
platforms: Platform[];
|
||||
profileName?: string;
|
||||
projectDir: string;
|
||||
type: T;
|
||||
}): Promise<ProfileData<T>[]> {
|
||||
const results = platforms.map(async function (platform) {
|
||||
const profile = await readProfileAsync({
|
||||
const profile = await readProfileWithOverridesAsync({
|
||||
easJsonAccessor,
|
||||
platform,
|
||||
type,
|
||||
profileName,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
return {
|
||||
profile,
|
||||
profileName: profileName ?? 'production',
|
||||
@@ -47,16 +53,50 @@ export async function getProfilesAsync<T extends ProfileType>({
|
||||
return await Promise.all(results);
|
||||
}
|
||||
|
||||
async function readProfileAsync<T extends ProfileType>({
|
||||
async function maybeSetNodeVersionFromFileAsync(
|
||||
projectDir: string,
|
||||
profile: BuildProfile<Platform>
|
||||
): Promise<void> {
|
||||
if (profile?.node) {
|
||||
return;
|
||||
}
|
||||
const nodeVersion = await getNodeVersionFromFileAsync(projectDir);
|
||||
if (nodeVersion) {
|
||||
Log.log(
|
||||
`The EAS build profile does not specify a Node.js version. Using the version specified in .nvmrc: ${nodeVersion} `
|
||||
);
|
||||
|
||||
profile.node = nodeVersion;
|
||||
}
|
||||
}
|
||||
|
||||
async function getNodeVersionFromFileAsync(projectDir: string): Promise<string | undefined> {
|
||||
const nvmrcPath = path.join(projectDir, '.nvmrc');
|
||||
if (!(await fs.pathExists(nvmrcPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nodeVersion: string;
|
||||
try {
|
||||
nodeVersion = (await fs.readFile(nvmrcPath, 'utf8')).toString().trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return nodeVersion;
|
||||
}
|
||||
|
||||
async function readProfileWithOverridesAsync<T extends ProfileType>({
|
||||
easJsonAccessor,
|
||||
platform,
|
||||
type,
|
||||
profileName,
|
||||
projectDir,
|
||||
}: {
|
||||
easJsonAccessor: EasJsonAccessor;
|
||||
platform: Platform;
|
||||
type: T;
|
||||
profileName?: string;
|
||||
projectDir: string;
|
||||
}): Promise<EasProfile<T>> {
|
||||
if (type === 'build') {
|
||||
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
|
||||
@@ -66,6 +106,7 @@ async function readProfileAsync<T extends ProfileType>({
|
||||
);
|
||||
|
||||
await maybePrintBuildProfileDeprecationWarningsAsync(easJsonAccessor, platform, profileName);
|
||||
await maybeSetNodeVersionFromFileAsync(projectDir, buildProfile);
|
||||
|
||||
return buildProfile as EasProfile<T>;
|
||||
} else {
|
||||
|
||||
@@ -262,14 +262,6 @@ export async function selectPaginatedAsync<T>({
|
||||
getTitleAsync: (node: T) => Promise<string>;
|
||||
printedType: string;
|
||||
}): Promise<T | null> {
|
||||
// Dont bother prompting if there are 0 or 1 items
|
||||
const connectionPreflight = await queryAsync({ first: pageSize });
|
||||
const { edges } = connectionPreflight;
|
||||
if (edges.length === 0) {
|
||||
return null;
|
||||
} else if (edges.length === 1) {
|
||||
return edges[0].node;
|
||||
}
|
||||
return await selectPaginatedInternalAsync({
|
||||
queryAsync,
|
||||
getTitleAsync,
|
||||
@@ -296,11 +288,14 @@ async function selectPaginatedInternalAsync<T>({
|
||||
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
|
||||
getTitleAsync: (node: T) => Promise<string>;
|
||||
printedType: string;
|
||||
}): Promise<T> {
|
||||
}): Promise<T | null> {
|
||||
const limit = queryParams.first ?? queryParams.last;
|
||||
assert(limit, 'queryParams must have either first or last');
|
||||
const connection = await queryAsync(queryParams);
|
||||
const { edges, pageInfo } = connection;
|
||||
if (edges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
/*
|
||||
* The Relay spec has a weird definition on hasNextPage and hasPreviousPage:
|
||||
* 'If the client is paginating with last/before, then the server must return true if prior edges
|
||||
|
||||
@@ -24,6 +24,8 @@ const humanReadableServiceName: Record<StatuspageServiceName, string> = {
|
||||
[StatuspageServiceName.EasBuild]: 'EAS Build',
|
||||
[StatuspageServiceName.EasSubmit]: 'EAS Submit',
|
||||
[StatuspageServiceName.EasUpdate]: 'EAS Update',
|
||||
[StatuspageServiceName.GithubApiRequests]: 'GitHub API Requests',
|
||||
[StatuspageServiceName.GithubWebhooks]: 'Github Webhooks',
|
||||
};
|
||||
|
||||
function warnAboutServiceOutage(service: StatuspageServiceFragment): void {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"version": "3.18.0",
|
||||
"version": "5.3.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.18.6",
|
||||
"@expo/eas-build-job": "1.0.30",
|
||||
"@expo/eas-build-job": "1.0.39",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "10.1.0",
|
||||
"golden-fleece": "1.0.9",
|
||||
"joi": "17.7.0",
|
||||
"log-symbols": "4.1.0",
|
||||
"semver": "7.3.8",
|
||||
"semver": "7.5.2",
|
||||
"terminal-link": "2.1.1",
|
||||
"tslib": "2.4.1"
|
||||
},
|
||||
|
||||
@@ -24,14 +24,19 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The compatible versions of EAS CLI with this config",
|
||||
"examples": [">=0.54.1"]
|
||||
"examples": [
|
||||
">=0.54.1"
|
||||
]
|
||||
},
|
||||
"requireCommit": {
|
||||
"type": "boolean",
|
||||
"description": "If all changes required to be committed before building or submitting"
|
||||
},
|
||||
"appVersionSource": {
|
||||
"enum": ["local", "remote"],
|
||||
"enum": [
|
||||
"local",
|
||||
"remote"
|
||||
],
|
||||
"markdownDescription": "Version policy defines whether version of your app should be based on your local project or values stored on EAS servers (remote).\n\nThis is the configuration required for `eas build:version:set` and works with the `autoIncrement` options per platform.",
|
||||
"markdownEnumDescriptions": [
|
||||
"When using local, the `autoIncrement` is based on your local project values.",
|
||||
@@ -91,12 +96,21 @@
|
||||
"type": "object",
|
||||
"description": "The build profile configuration.",
|
||||
"properties": {
|
||||
"withoutCredentials": {
|
||||
"type": "boolean",
|
||||
"description": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when working with custom builds.",
|
||||
"markdownDescription": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when working with custom builds.",
|
||||
"default": false
|
||||
},
|
||||
"extends": {
|
||||
"type": "string",
|
||||
"description": "The name of the build profile that the current one should inherit values from. This value can't be specified per platform."
|
||||
},
|
||||
"credentialsSource": {
|
||||
"enum": ["remote", "local"],
|
||||
"enum": [
|
||||
"remote",
|
||||
"local"
|
||||
],
|
||||
"description": "The source of credentials used to sign build artifacts. Learn more: https://docs.expo.dev/app-signing/local-credentials/",
|
||||
"markdownDescription": "The source of credentials used to sign build artifacts.\n\n- `remote` - if you want to use the credentials managed by EAS.\n- `local` - if you want to provide your own `credentials.json` file. [learn more](https://docs.expo.dev/app-signing/local-credentials/)",
|
||||
"default": "remote",
|
||||
@@ -118,7 +132,10 @@
|
||||
"markdownDescription": "The channel is a name we can give to multiple builds to identify them easily. [Learn more](https://docs.expo.dev/eas-update/how-eas-update-works/)\n\n**This field only applies to the EAS Update service**, if your project still uses Classic Updates then use the [releaseChannel](https://docs.expo.dev/build-reference/eas-json/#releasechannel) field instead."
|
||||
},
|
||||
"distribution": {
|
||||
"enum": ["internal", "store"],
|
||||
"enum": [
|
||||
"internal",
|
||||
"store"
|
||||
],
|
||||
"description": "The method of distributing your app. Learn more: https://docs.expo.dev/build/internal-distribution/",
|
||||
"markdownDescription": "The method of distributing your app.\n\n- `internal` - with this option you'll be able to share your build URLs with anyone, and they will be able to install the builds to their devices straight from the Expo website. When using `internal`, make sure the build produces an APK or IPA file. Otherwise, the shareable URL will be useless. [Learn more](https://docs.expo.dev/build/internal-distribution/)\n- `store` - produces builds for store uploads, your build URLs won't be shareable.",
|
||||
"markdownEnumDescriptions": [
|
||||
@@ -147,6 +164,16 @@
|
||||
"description": "The version of Yarn to use for the build. See: https://www.npmjs.com/package/yarn",
|
||||
"markdownDescription": "The exact version of [Yarn](https://www.npmjs.com/package/yarn) to use for the build."
|
||||
},
|
||||
"pnpm": {
|
||||
"type": "string",
|
||||
"description": "The version of pnpm to use for the build. See: https://www.npmjs.com/package/pnpm",
|
||||
"markdownDescription": "The exact version of [pnpm](https://www.npmjs.com/package/pnpm) to use for the build."
|
||||
},
|
||||
"bun": {
|
||||
"type": "string",
|
||||
"description": "The version of Bun to use for the build. See: https://bun.sh/",
|
||||
"markdownDescription": "The exact version of [Bun](https://bun.sh/) to use for the build."
|
||||
},
|
||||
"expoCli": {
|
||||
"type": "string",
|
||||
"description": "The version of `expo-cli` used to `prebuild` your app.",
|
||||
@@ -187,7 +214,11 @@
|
||||
"default": "default",
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["default", "medium", "large"]
|
||||
"enum": [
|
||||
"default",
|
||||
"medium",
|
||||
"large"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
@@ -208,8 +239,8 @@
|
||||
"properties": {
|
||||
"withoutCredentials": {
|
||||
"type": "boolean",
|
||||
"description": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when you want to build debug binaries and the debug keystore is checked in to the repository.",
|
||||
"markdownDescription": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when you want to build debug binaries and the debug keystore is checked in to the repository.",
|
||||
"description": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when you want to build debug binaries and the debug keystore is checked in to the repository or working with custom builds.",
|
||||
"markdownDescription": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when you want to build debug binaries and the debug keystore is checked in to the repository or working with custom builds.",
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
@@ -246,7 +277,11 @@
|
||||
"default": "default",
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["default", "medium", "large"]
|
||||
"enum": [
|
||||
"default",
|
||||
"medium",
|
||||
"large"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
@@ -258,7 +293,12 @@
|
||||
"description": "The version of Android NDK."
|
||||
},
|
||||
"autoIncrement": {
|
||||
"enum": [false, true, "versionCode", "version"],
|
||||
"enum": [
|
||||
false,
|
||||
true,
|
||||
"versionCode",
|
||||
"version"
|
||||
],
|
||||
"description": "Controls how EAS CLI bumps your application build version.",
|
||||
"markdownDescription": "Controls how EAS CLI bumps your application build version.\n\nAllowed values:\n- `\"version\"` - bumps the patch of `expo.version` (e.g. `1.2.3` -> `1.2.4`).\n- `\"versionCode\"` (or `true`) - bumps `expo.android.versionCode` (e.g. `3` -> `4`).\n- `false` - versions won't be bumped automatically (default)\n\nIn the case of a bare project, it also updates versions in native code. `expo.version` corresponds to `versionName` and `expo.android.versionCode` to `versionCode` in the `build.gradle`. Google Play uses these values to identify the app build, `versionName` is the version visible to users, whereas `versionCode` defines the version number. The combination of those needs to be unique, so you can bump either of them.\n\nThis feature is not intended for use with dynamic configuration (app.config.js). EAS CLI will throw an error if you don't use app.json.",
|
||||
"default": false,
|
||||
@@ -270,7 +310,10 @@
|
||||
]
|
||||
},
|
||||
"buildType": {
|
||||
"enum": ["app-bundle", "apk"],
|
||||
"enum": [
|
||||
"app-bundle",
|
||||
"apk"
|
||||
],
|
||||
"description": "Type of the artifact you want to build.",
|
||||
"markdownDescription": "Type of the artifact you want to build. It controls what Gradle task will be used, can be overridden by `gradleCommand` or `developmentClient: true` option.\n- `app-bundle` - `:app:bundleRelease`\n- `apk` - `:app:assembleRelease`",
|
||||
"markdownEnumDescriptions": [
|
||||
@@ -301,20 +344,34 @@
|
||||
"type": "object",
|
||||
"description": "The iOS-specific build profile configuration.",
|
||||
"properties": {
|
||||
"withoutCredentials": {
|
||||
"type": "boolean",
|
||||
"description": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when working with custom builds.",
|
||||
"markdownDescription": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when working with custom builds.",
|
||||
"default": false
|
||||
},
|
||||
"simulator": {
|
||||
"type": "boolean",
|
||||
"description": "If set to true, creates build for simulator.",
|
||||
"default": false
|
||||
},
|
||||
"enterpriseProvisioning": {
|
||||
"enum": ["universal", "adhoc"],
|
||||
"enum": [
|
||||
"universal",
|
||||
"adhoc"
|
||||
],
|
||||
"markdownDescription": "Provisioning method used for `\"distribution\": \"internal\"` when you have an Apple account with Apple Developer Enterprise Program membership.\n\nYou can choose if you want to use `adhoc` or `universal` provisioning. The latter is recommended as it does not require you to register each individual device. If you don't provide this option and you still authenticate with an enterprise team, you'll be prompted which provisioning method to use.",
|
||||
"markdownEnumDescriptions": [
|
||||
"Recommended as it does not require you to register each individual device"
|
||||
]
|
||||
},
|
||||
"autoIncrement": {
|
||||
"enum": [false, true, "buildNumber", "version"],
|
||||
"enum": [
|
||||
false,
|
||||
true,
|
||||
"buildNumber",
|
||||
"version"
|
||||
],
|
||||
"description": "Controls how EAS CLI bumps your application build version.",
|
||||
"markdownDescription": "Controls how EAS CLI bumps your application build version.\n\nAllowed values:\n\n- `\"version\"` - bumps the patch of `expo.version` (e.g. `1.2.3` -> `1.2.4`).\n- `\"buildNumber\"` (or `true`) - bumps the last component of `expo.ios.buildNumber` (e.g. `1.2.3.39` -> `1.2.3.40`).\n- `false` - versions won't be bumped automatically (default)\n\nIn the case of a bare project, it also updates versions in native code. `expo.version` corresponds to `CFBundleShortVersionString` and `expo.ios.buildNumber` to `CFBundleVersion` in the `Info.plist`. The App Store is using those values to identify the app build, `CFBundleShortVersionString` is the version visible to users, whereas `CFBundleVersion` defines the build number. The combination of those needs to be unique, so you can bump either of them.\n\nThis feature is not intended for use with dynamic configuration (app.config.js). EAS CLI will throw an error if you don't use app.json.",
|
||||
"default": false,
|
||||
@@ -363,7 +420,12 @@
|
||||
"default": "default",
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["default", "medium", "large", "m-medium"]
|
||||
"enum": [
|
||||
"default",
|
||||
"medium",
|
||||
"large",
|
||||
"m-medium"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
@@ -443,7 +505,12 @@
|
||||
"markdownDescription": "Path to the JSON file with service account key used to authenticate with Google Play. [Learn more](https://expo.fyi/creating-google-service-account)"
|
||||
},
|
||||
"track": {
|
||||
"enum": ["beta", "alpha", "internal", "production"],
|
||||
"enum": [
|
||||
"beta",
|
||||
"alpha",
|
||||
"internal",
|
||||
"production"
|
||||
],
|
||||
"description": "The track of the application to use. Learn more: https://support.google.com/googleplay/android-developer/answer/9859348?hl=en",
|
||||
"markdownDescription": "The [track of the application](https://support.google.com/googleplay/android-developer/answer/9859348?hl=en) to use.",
|
||||
"markdownEnumDescriptions": [
|
||||
@@ -454,7 +521,12 @@
|
||||
]
|
||||
},
|
||||
"releaseStatus": {
|
||||
"enum": ["draft", "inProgress", "halted", "completed"],
|
||||
"enum": [
|
||||
"draft",
|
||||
"inProgress",
|
||||
"halted",
|
||||
"completed"
|
||||
],
|
||||
"description": "The status of a release. Learn more: https://developers.google.com/android-publisher/api-ref/rest/v3/edits.tracks",
|
||||
"markdownDescription": "The status of a release. [Learn more](https://developers.google.com/android-publisher/api-ref/rest/v3/edits.tracks)",
|
||||
"enumDescriptions": [
|
||||
|
||||
@@ -100,6 +100,105 @@ test('valid eas.json for development client builds', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('valid eas.json with top level withoutCredentials property', async () => {
|
||||
await fs.writeJson('/project/eas.json', {
|
||||
build: {
|
||||
production: {},
|
||||
debug: {
|
||||
developmentClient: true,
|
||||
withoutCredentials: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const accessor = EasJsonAccessor.fromProjectPath('/project');
|
||||
const iosProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'debug');
|
||||
const androidProfile = await EasJsonUtils.getBuildProfileAsync(
|
||||
accessor,
|
||||
Platform.ANDROID,
|
||||
'debug'
|
||||
);
|
||||
expect(androidProfile).toEqual({
|
||||
credentialsSource: 'remote',
|
||||
distribution: 'store',
|
||||
developmentClient: true,
|
||||
withoutCredentials: true,
|
||||
});
|
||||
|
||||
expect(iosProfile).toEqual({
|
||||
credentialsSource: 'remote',
|
||||
distribution: 'store',
|
||||
developmentClient: true,
|
||||
withoutCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('valid eas.json with iOS withoutCredentials property', async () => {
|
||||
await fs.writeJson('/project/eas.json', {
|
||||
build: {
|
||||
production: {},
|
||||
debug: {
|
||||
developmentClient: true,
|
||||
ios: { withoutCredentials: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const accessor = EasJsonAccessor.fromProjectPath('/project');
|
||||
const iosProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'debug');
|
||||
const androidProfile = await EasJsonUtils.getBuildProfileAsync(
|
||||
accessor,
|
||||
Platform.ANDROID,
|
||||
'debug'
|
||||
);
|
||||
expect(androidProfile).toEqual({
|
||||
credentialsSource: 'remote',
|
||||
distribution: 'store',
|
||||
developmentClient: true,
|
||||
});
|
||||
|
||||
expect(iosProfile).toEqual({
|
||||
credentialsSource: 'remote',
|
||||
distribution: 'store',
|
||||
developmentClient: true,
|
||||
withoutCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('valid eas.json with top level and platform-sepcific withoutCredentials property', async () => {
|
||||
await fs.writeJson('/project/eas.json', {
|
||||
build: {
|
||||
production: {},
|
||||
debug: {
|
||||
developmentClient: true,
|
||||
withoutCredentials: true,
|
||||
ios: { withoutCredentials: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const accessor = EasJsonAccessor.fromProjectPath('/project');
|
||||
const iosProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'debug');
|
||||
const androidProfile = await EasJsonUtils.getBuildProfileAsync(
|
||||
accessor,
|
||||
Platform.ANDROID,
|
||||
'debug'
|
||||
);
|
||||
expect(androidProfile).toEqual({
|
||||
credentialsSource: 'remote',
|
||||
distribution: 'store',
|
||||
developmentClient: true,
|
||||
withoutCredentials: true,
|
||||
});
|
||||
|
||||
expect(iosProfile).toEqual({
|
||||
credentialsSource: 'remote',
|
||||
distribution: 'store',
|
||||
developmentClient: true,
|
||||
withoutCredentials: false,
|
||||
});
|
||||
});
|
||||
|
||||
test(`valid eas.json with autoIncrement flag at build profile root`, async () => {
|
||||
await fs.writeJson('/project/eas.json', {
|
||||
build: {
|
||||
|
||||
@@ -25,9 +25,11 @@ const CacheSchema = Joi.object({
|
||||
cacheDefaultPaths: Joi.boolean(),
|
||||
customPaths: Joi.array().items(Joi.string()),
|
||||
paths: Joi.array().items(Joi.string()),
|
||||
}).rename('customPaths', 'paths')
|
||||
})
|
||||
.rename('customPaths', 'paths')
|
||||
.messages({
|
||||
'object.rename.override': 'Cannot provide both "cache.customPaths" and "cache.paths" - use "cache.paths"'
|
||||
'object.rename.override':
|
||||
'Cannot provide both "cache.customPaths" and "cache.paths" - use "cache.paths"',
|
||||
});
|
||||
|
||||
const CommonBuildProfileSchema = Joi.object({
|
||||
@@ -37,6 +39,8 @@ const CommonBuildProfileSchema = Joi.object({
|
||||
// build environment
|
||||
env: Joi.object().pattern(Joi.string(), Joi.string().empty(null)),
|
||||
node: Joi.string().empty(null).custom(semverCheck),
|
||||
pnpm: Joi.string().empty(null).custom(semverCheck),
|
||||
bun: Joi.string().empty(null).custom(semverCheck),
|
||||
yarn: Joi.string().empty(null).custom(semverCheck),
|
||||
expoCli: Joi.string().empty(null).custom(semverCheck),
|
||||
|
||||
@@ -63,6 +67,9 @@ const CommonBuildProfileSchema = Joi.object({
|
||||
|
||||
// custom build configuration
|
||||
config: Joi.string(),
|
||||
|
||||
// credentials
|
||||
withoutCredentials: Joi.boolean(),
|
||||
});
|
||||
|
||||
const PlatformBuildProfileSchema = CommonBuildProfileSchema.concat(
|
||||
@@ -84,9 +91,6 @@ const AndroidBuildProfileSchema = PlatformBuildProfileSchema.concat(
|
||||
// build environment
|
||||
ndk: Joi.string().empty(null).custom(semverCheck),
|
||||
|
||||
// credentials
|
||||
withoutCredentials: Joi.boolean(),
|
||||
|
||||
// build configuration
|
||||
gradleCommand: Joi.string(),
|
||||
buildType: Joi.string().valid('apk', 'app-bundle'),
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface CommonBuildProfile {
|
||||
// build environment
|
||||
env?: Record<string, string>;
|
||||
node?: string;
|
||||
pnpm?: string;
|
||||
bun?: string;
|
||||
yarn?: string;
|
||||
expoCli?: string;
|
||||
|
||||
@@ -70,6 +72,9 @@ export interface CommonBuildProfile {
|
||||
|
||||
// custom build configuration
|
||||
config?: string;
|
||||
|
||||
// credentials
|
||||
withoutCredentials?: boolean;
|
||||
}
|
||||
|
||||
interface PlatformBuildProfile extends Omit<CommonBuildProfile, 'autoIncrement'> {
|
||||
@@ -86,9 +91,6 @@ export interface AndroidBuildProfile extends PlatformBuildProfile {
|
||||
image?: Android.BuilderEnvironment['image'];
|
||||
ndk?: string;
|
||||
|
||||
// credentials
|
||||
withoutCredentials?: boolean;
|
||||
|
||||
// build configuration
|
||||
gradleCommand?: string;
|
||||
buildType?: Android.BuildType.APK | Android.BuildType.APP_BUNDLE;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"fs-extra": "10.1.0",
|
||||
"lodash-es": "4.17.21",
|
||||
"marked": "3.0.4",
|
||||
"semver": "7.3.8",
|
||||
"semver": "7.5.2",
|
||||
"tslib": "2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+4
-4
@@ -166,10 +166,10 @@ [email protected]:
|
||||
resolved "https://registry.yarnpkg.com/marked/-/marked-3.0.4.tgz#b8a1539e5e05c6ea9e93f15c0bad1d54ce890406"
|
||||
integrity sha512-jBo8AOayNaEcvBhNobg6/BLhdsK3NvnKWJg33MAAPbvTWiG4QBn9gpW1+7RssrKu4K1dKlN+0goVQwV41xEfOA==
|
||||
|
||||
semver@7.3.8:
|
||||
version "7.3.8"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
|
||||
integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
|
||||
semver@7.5.2:
|
||||
version "7.5.2"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.2.tgz#5b851e66d1be07c1cdaf37dfc856f543325a2beb"
|
||||
integrity sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
|
||||
@@ -1395,10 +1395,18 @@
|
||||
slugify "^1.3.4"
|
||||
sucrase "^3.20.0"
|
||||
|
||||
"@expo/[email protected]0":
|
||||
version "1.0.30"
|
||||
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-1.0.30.tgz#f8c57a673a2cc328f4041346996c480c3d910e92"
|
||||
integrity sha512-KQGn9POn5p3guMNQJ5ykG3wffRlSEpjTCLO87d5lJll9E4u1grpqHJH2chOh5ICxg7tiWcO4iXt5S8tU7Ogpig==
|
||||
"@expo/[email protected]9":
|
||||
version "1.0.39"
|
||||
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-1.0.39.tgz#d3bc6cd50c499f07255bceeb9460e092a19505f7"
|
||||
integrity sha512-OqCCxLx9HRMFQDiZvfpOfNmGhsTrV15IUOhmbp9iIa+uO/VyPpBvXqiA4ENCN9Jmf6yXtirIranCeJcm+jAuSA==
|
||||
dependencies:
|
||||
joi "^17.9.2"
|
||||
semver "^7.5.4"
|
||||
|
||||
"@expo/[email protected]":
|
||||
version "1.0.43"
|
||||
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-1.0.43.tgz#e331b5039372c31b15aedbadc229efee3dcaefba"
|
||||
integrity sha512-LMOoDIEax31uGBGux6/ocbCjUbWK3cUHuCxrsYlU+bvba4pGifegYWt2YQ/HC2477paq+K69IGjOUCHvNWG6Yg==
|
||||
dependencies:
|
||||
joi "^17.9.2"
|
||||
semver "^7.5.4"
|
||||
@@ -1576,10 +1584,10 @@
|
||||
dependencies:
|
||||
cross-spawn "^7.0.3"
|
||||
|
||||
"@expo/[email protected]1":
|
||||
version "1.0.31"
|
||||
resolved "https://registry.yarnpkg.com/@expo/steps/-/steps-1.0.31.tgz#61e0456e54bc63bd714aa40b9be27503c032caf0"
|
||||
integrity sha512-TPuaihUQ9MfVmgG5OzFdMKXcBAgv1dfLZWGpk/k5hWBjPZAoe01UXbw8GCt6F09tS2Z256d2G/kOTJzISLNcjQ==
|
||||
"@expo/[email protected]4":
|
||||
version "1.0.34"
|
||||
resolved "https://registry.yarnpkg.com/@expo/steps/-/steps-1.0.34.tgz#ba2f27a9d3e163b2d0fe6ef7775b237f7d18a286"
|
||||
integrity sha512-jQoxeMWWl2mUwLGyiUD38z/FNao2jbDP8Tjvkr/YAfIOHaro36E3Vmn1YsKTYmtc41TGcFdCzqly95faRNrhvQ==
|
||||
dependencies:
|
||||
"@expo/logger" "1.0.30"
|
||||
"@expo/spawn-async" "^1.7.2"
|
||||
@@ -12084,10 +12092,10 @@ [email protected], semver@^7.3.2:
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
semver@7.3.8, semver@^7.0.0, semver@^7.3.8:
|
||||
version "7.3.8"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
|
||||
integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
|
||||
semver@7.5.2:
|
||||
version "7.5.2"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.2.tgz#5b851e66d1be07c1cdaf37dfc856f543325a2beb"
|
||||
integrity sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
@@ -12110,6 +12118,13 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.3.0:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
|
||||
|
||||
semver@^7.0.0, semver@^7.3.8:
|
||||
version "7.3.8"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
|
||||
integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
semver@^7.3.7:
|
||||
version "7.3.7"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
|
||||
@@ -13651,9 +13666,9 @@ wonka@^6.3.2:
|
||||
integrity sha512-2xXbQ1LnwNS7egVm1HPhW2FyKrekolzhpM3mCwXdQr55gO+tAiY76rhb32OL9kKsW8taj++iP7C6hxlVzbnvrw==
|
||||
|
||||
word-wrap@^1.2.3:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
|
||||
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
||||
|
||||
wordwrap@^1.0.0:
|
||||
version "1.0.0"
|
||||
|
||||
Reference in new issue
Block a user