mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
175fc2b30e | ||
|
|
aec9347d69 | ||
|
|
91500d07c0 | ||
|
|
62ca14b504 | ||
|
|
9158a95d57 | ||
|
|
d0f3e387e7 | ||
|
|
158856f8ec | ||
|
|
1e59957753 | ||
|
|
f97d46040c | ||
|
|
d7485cb5d2 | ||
|
|
071fdb7f4a | ||
|
|
6dd96ffb20 | ||
|
|
f56fb0f7de | ||
|
|
7d28f6add9 | ||
|
|
eebef3e461 | ||
|
|
8336f630ea | ||
|
|
7d6bdbb6ed | ||
|
|
403b5c237f | ||
|
|
2367ed7911 | ||
|
|
b650f76979 | ||
|
|
96e59c198d | ||
|
|
bf1704d268 | ||
|
|
279dfd00c8 | ||
|
|
4d2577865a | ||
|
|
9d88fba40e | ||
|
|
9d2353b4f7 | ||
|
|
44b338d50a | ||
|
|
62114cab3b | ||
|
|
d426fb0355 | ||
|
|
2a8d6683ff | ||
|
|
7b18165390 | ||
|
|
814a44ac27 | ||
|
|
f5483c8996 | ||
|
|
acdbed4762 | ||
|
|
f09e4b54cf | ||
|
|
859fdf5e46 | ||
|
|
6aa72d5b7d | ||
|
|
886900df29 | ||
|
|
a450fed1f8 | ||
|
|
4c78100adb | ||
|
|
0317e6fe71 | ||
|
|
6825877344 | ||
|
|
a47ea88d28 | ||
|
|
a9e7f304ec | ||
|
|
d92a2ae3f4 | ||
|
|
b7dea35da4 | ||
|
|
5a5f11752d | ||
|
|
b6c300b5c2 | ||
|
|
0117dbfa5f | ||
|
|
c106f8a1a4 | ||
|
|
8b9741a70c | ||
|
|
271b85fd74 | ||
|
|
21a481790b | ||
|
|
ab5d9237f1 | ||
|
|
756853f2f3 | ||
|
|
5eb80d15f6 | ||
|
|
a98043b4ad | ||
|
|
d0818932e7 | ||
|
|
23b60ed385 | ||
|
|
5d2811bf21 | ||
|
|
7b202f2aa7 | ||
|
|
fa6e9c1735 | ||
|
|
c3300036d8 | ||
|
|
5efcdb2940 | ||
|
|
8113450ea4 | ||
|
|
d10b0c3c65 | ||
|
|
e8de0e8712 | ||
|
|
e1da1099da | ||
|
|
264aa8fefa | ||
|
|
941d0294b7 | ||
|
|
366f4e8914 | ||
|
|
1460c3c5a2 | ||
|
|
6271472827 | ||
|
|
3bc4a054ae | ||
|
|
dd85d559d7 | ||
|
|
ab10b082e5 | ||
|
|
60b60066f9 | ||
|
|
9e9e517698 | ||
|
|
020c0f2b98 | ||
|
|
dd74ebdc51 | ||
|
|
ddf7381efb | ||
|
|
3b67b912e4 | ||
|
|
37d7fa4510 | ||
|
|
096601473a | ||
|
|
2e25ed81ca | ||
|
|
4e11ea1d77 | ||
|
|
d9d470426b | ||
|
|
119b4ad8b2 | ||
|
|
ae1e9f000d | ||
|
|
eb0505f8f8 | ||
|
|
c8f7800ab5 | ||
|
|
bc4e7becdd | ||
|
|
71ece19c40 | ||
|
|
e9053c0d15 | ||
|
|
cf76296142 |
No files matched your search
@@ -1,3 +1,9 @@
|
||||
<!-- Thanks for contributing to _EAS CLI_! Before you submit your pull request, please make sure to check the following boxes by putting an x in the [ ] (don't: [x ], [ x], do: [x]) -->
|
||||
|
||||
# Checklist
|
||||
|
||||
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary.
|
||||
|
||||
# Why
|
||||
|
||||
Please describe the motivation for this PR, and link to relevant GitHub issues, forums posts, or feature requests.
|
||||
|
||||
+109
-2
@@ -8,9 +8,116 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Retry graphQL call on network or transient server errors. ([#320](https://github.com/expo/eas-cli/pull/320) by [@jkhales](https://github.com/jkhales))
|
||||
- Display more friendly error messages when `eas submit` fails. ([#311](https://github.com/expo/eas-cli/pull/297) by [@barthap](https://github.com/barthap))
|
||||
- Add support for managing webhooks (new commands: `webhook:create`, `webhook:view`, `webhook:list`, `webhook:update`, and `webhook:delete`). ([#314](https://github.com/expo/eas-cli/pull/314) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix detecting application target (iOS builds). ([#238](https://github.com/expo/eas-cli/pull/2238) by [@dsokal](https://github.com/dsokal))
|
||||
- Fix the issue where the first iOS build fails for a project without `ios.bundleIdentifier` set in `app.json`. ([#319](https://github.com/expo/eas-cli/pull/319) by [@dsokal](https://github.com/dsokal))
|
||||
- Bump @expo/config-plugins to fix `eas build:configure` on Windows
|
||||
- Fix provisioning profile validation to actually check status. ([#318](https://github.com/expo/eas-cli/pull/318) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Replace Credentials REST API calls with GraphQL counterparts, also improved DX. ([#293](https://github.com/expo/eas-cli/pull/293), [#299](https://github.com/expo/eas-cli/pull/299), [#301](https://github.com/expo/eas-cli/pull/301), [#317](https://github.com/expo/eas-cli/pull/317) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [0.8.1](https://github.com/expo/eas-cli/releases/tag/v0.8.1) - 2021-04-06
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix the issue where the build status never got updated when running `eas build`. ([#310](https://github.com/expo/eas-cli/pull/310) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.8.0](https://github.com/expo/eas-cli/releases/tag/v0.8.0) - 2021-04-06
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Change the way of disabling cache - add `cache.disabled` field. ([#295](https://github.com/expo/eas-cli/pull/295) by [@dsokal](https://github.com/dsokal))
|
||||
- `secrets:create` now uses flags rather than positional arguments ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
|
||||
- `secrets:create`'s `target` arg is now called `scope` ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
|
||||
- `secrets:list`'s `target` property is now called `scope` ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
|
||||
- `secrets:delete`'s `ID` arg is now optional ([#309](https://github.com/expo/eas-cli/pull/309) by [@fiberjw](https://github.com/fiberjw))
|
||||
- `secrets:delete`'s now allows users to choose secrets from a list ([#309](https://github.com/expo/eas-cli/pull/309) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- `build:view` and `build:list` now showing the distribution type (store / internal) and release channel. ([#284](https://github.com/expo/eas-cli/pull/284) by [@vthibault](https://github.com/vthibault))
|
||||
- Add analytics to EAS Build. ([#162](https://github.com/expo/eas-cli/pull/162) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Improve tar archive support in EAS Submit. ([#297](https://github.com/expo/eas-cli/pull/297) by [@barthap](https://github.com/barthap))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix environment secret creation prompt. ([#298](https://github.com/expo/eas-cli/pull/298) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Replace REST API calls with GraphQL counterparts. ([#286](https://github.com/expo/eas-cli/pull/286), [#288](https://github.com/expo/eas-cli/pull/288), [#303](https://github.com/expo/eas-cli/pull/303), [#306](https://github.com/expo/eas-cli/pull/306), [#307](https://github.com/expo/eas-cli/pull/307) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.7.0](https://github.com/expo/eas-cli/releases/tag/v0.7.0) - 2021-03-22
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Print common error messages when builds fail. ([#272](https://github.com/expo/eas-cli/pull/272) by [@dsokal](https://github.com/dsokal))
|
||||
- Commit automatically if `EAS_BUILD_AUTOCOMMIT` is set. ([#271](https://github.com/expo/eas-cli/pull/271) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Allow for installing custom `bundler` version on EAS Build. ([#277](https://github.com/expo/eas-cli/pull/277) by [@dsokal](https://github.com/dsokal))
|
||||
- Add support for managing environment secrets. ([#275](https://github.com/expo/eas-cli/pull/275) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `eas submit` local archive prompt for `.aab` files when submitting for iOS. ([#273](https://github.com/expo/eas-cli/pull/273) by [@barthap](https://github.com/barthap))
|
||||
- Verify whether "name" field in app.json contains any alphanumeric characters. ([#280](https://github.com/expo/eas-cli/pull/280) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Detect dependency cycles in eas.json build profiles. ([#283](https://github.com/expo/eas-cli/pull/283) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.6.0](https://github.com/expo/eas-cli/releases/tag/v0.6.0) - 2021-03-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Generic iOS projects: build release builds by default. ([#266](https://github.com/expo/eas-cli/pull/266) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Log the size of the archived project when uploading. ([#264](https://github.com/expo/eas-cli/pull/264) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Add more build metadata (release channel, build profile name, git commit hash). ([#265](https://github.com/expo/eas-cli/pull/265) by [@dsokal](https://github.com/dsokal))
|
||||
- Display App Store link after successful submission. ([#144](https://github.com/expo/eas-cli/pull/144) by [@barthap](https://github.com/barthap))
|
||||
- Add `experimental.disableIosBundleIdentifierValidation` flag to eas.json. ([#263](https://github.com/expo/eas-cli/pull/263) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Support internal distribution in non-interactive builds. ([#269](https://github.com/expo/eas-cli/pull/269) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Print Apple Team ID in the output of `device:list` when the team name is unknown. ([#268](https://github.com/expo/eas-cli/pull/268) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.5.0](https://github.com/expo/eas-cli/releases/tag/v0.5.0) - 2021-03-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add support for build cache. ([#247](https://github.com/expo/eas-cli/pull/247) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Enable internal distribution support for self-managed credentials. ([#256](https://github.com/expo/eas-cli/pull/256) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Make sure all files are committed before build. ([#251](https://github.com/expo/eas-cli/pull/251) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Fix `eas submit` support for tar.gz files. ([#257](https://github.com/expo/eas-cli/pull/257)) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Show untracked files when checking `git status`. ([#259](https://github.com/expo/eas-cli/pull/259) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Upgrade `@expo/eas-build-job` from `0.2.12` to `0.2.13`. ([#245](https://github.com/expo/eas-cli/pull/245) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.4.3](https://github.com/expo/eas-cli/releases/tag/v0.4.3) - 2021-02-23
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add support for iOS simulator builds. ([#240](https://github.com/expo/eas-cli/pull/240) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Use fixed version of `@expo/eas-json`. ([#243](https://github.com/expo/eas-cli/pull/243) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [0.4.2](https://github.com/expo/eas-cli/releases/tag/v0.4.2) - 2021-02-18
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix detecting application target (iOS builds). ([#238](https://github.com/expo/eas-cli/pull/238) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.4.1](https://github.com/expo/eas-cli/releases/tag/v0.4.1) - 2021-02-16
|
||||
|
||||
@@ -29,5 +136,5 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `--no-wait` flag for `eas build`. ([#226](https://github.com/expo/eas-cli/pull/226) by [paul-ridgway](https://github.com/paul-ridgway))
|
||||
- Fix `--no-wait` flag for `eas build`. ([#226](https://github.com/expo/eas-cli/pull/226) by [@paul-ridgway](https://github.com/paul-ridgway))
|
||||
- Fix running builds from project subdirectories. ([#229](https://github.com/expo/eas-cli/pull/229) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2020-present 650 Industries, Inc. (aka Expo)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.4.2"
|
||||
"version": "0.9.1"
|
||||
}
|
||||
+175
-30
@@ -7,27 +7,28 @@ EAS command line tool
|
||||
[](https://npmjs.org/package/eas-cli)
|
||||
[](https://github.com/expo/eas-cli/blob/main/package.json)
|
||||
|
||||
<!-- toc -->
|
||||
* [eas-cli](#eas-cli)
|
||||
* [Installation](#installation)
|
||||
* [Usage](#usage)
|
||||
* [Commands](#commands)
|
||||
<!-- tocstop -->
|
||||
|
||||
# Installation
|
||||
|
||||
```sh
|
||||
npm install -g eas-cli
|
||||
# or
|
||||
yarn global add eas-cli
|
||||
```
|
||||
|
||||
# Usage
|
||||
|
||||
<!-- usage -->
|
||||
```sh-session
|
||||
$ npm install -g eas-cli
|
||||
$ eas COMMAND
|
||||
running command...
|
||||
$ eas (-v|--version|version)
|
||||
eas-cli/0.4.2 darwin-x64 node-v14.15.4
|
||||
$ eas --help [COMMAND]
|
||||
USAGE
|
||||
$ eas COMMAND
|
||||
...
|
||||
```sh
|
||||
eas COMMAND
|
||||
# runs the command
|
||||
eas (-v|--version|version)
|
||||
# prints the version
|
||||
eas --help COMMAND
|
||||
# outputs help for specific command
|
||||
```
|
||||
<!-- usagestop -->
|
||||
|
||||
# Commands
|
||||
|
||||
@@ -35,6 +36,7 @@ USAGE
|
||||
* [`eas account:login`](#eas-accountlogin)
|
||||
* [`eas account:logout`](#eas-accountlogout)
|
||||
* [`eas account:view`](#eas-accountview)
|
||||
* [`eas analytics [STATUS]`](#eas-analytics-status)
|
||||
* [`eas build`](#eas-build)
|
||||
* [`eas build:cancel [BUILD_ID]`](#eas-buildcancel-build_id)
|
||||
* [`eas build:configure`](#eas-buildconfigure)
|
||||
@@ -45,7 +47,15 @@ USAGE
|
||||
* [`eas device:list`](#eas-devicelist)
|
||||
* [`eas device:view [UDID]`](#eas-deviceview-udid)
|
||||
* [`eas help [COMMAND]`](#eas-help-command)
|
||||
* [`eas secrets:create`](#eas-secretscreate)
|
||||
* [`eas secrets:delete [ID]`](#eas-secretsdelete-id)
|
||||
* [`eas secrets:list`](#eas-secretslist)
|
||||
* [`eas submit --platform=(android|ios)`](#eas-submit---platformandroidios)
|
||||
* [`eas webhook:create`](#eas-webhookcreate)
|
||||
* [`eas webhook:delete [ID]`](#eas-webhookdelete-id)
|
||||
* [`eas webhook:list`](#eas-webhooklist)
|
||||
* [`eas webhook:update`](#eas-webhookupdate)
|
||||
* [`eas webhook:view ID`](#eas-webhookview-id)
|
||||
|
||||
## `eas account:login`
|
||||
|
||||
@@ -59,7 +69,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/account/login.ts)_
|
||||
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -73,7 +83,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/account/logout.ts)_
|
||||
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -87,7 +97,18 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/account/view.ts)_
|
||||
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
View or change analytics settings
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas analytics [STATUS]
|
||||
```
|
||||
|
||||
_See code: [build/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/analytics.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -106,7 +127,7 @@ OPTIONS
|
||||
--[no-]wait Wait for build(s) to complete
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/build/index.ts)_
|
||||
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -117,7 +138,7 @@ USAGE
|
||||
$ eas build:cancel [BUILD_ID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/build/cancel.ts)_
|
||||
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -132,7 +153,7 @@ OPTIONS
|
||||
--allow-experimental Enable experimental configuration steps.
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/build/configure.ts)_
|
||||
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -148,7 +169,7 @@ OPTIONS
|
||||
--status=(in-queue|in-progress|errored|finished|canceled)
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/build/list.ts)_
|
||||
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/build/list.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -159,7 +180,7 @@ USAGE
|
||||
$ eas build:view [BUILD_ID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/build/view.ts)_
|
||||
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/build/view.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -170,7 +191,7 @@ USAGE
|
||||
$ eas credentials
|
||||
```
|
||||
|
||||
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/credentials.ts)_
|
||||
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/credentials.ts)_
|
||||
|
||||
## `eas device:create`
|
||||
|
||||
@@ -181,7 +202,7 @@ USAGE
|
||||
$ eas device:create
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/device/create.ts)_
|
||||
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/device/create.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -195,7 +216,7 @@ OPTIONS
|
||||
--apple-team-id=apple-team-id
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/device/list.ts)_
|
||||
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/device/list.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -206,7 +227,7 @@ USAGE
|
||||
$ eas device:view [UDID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/device/view.ts)_
|
||||
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/device/view.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -225,6 +246,50 @@ OPTIONS
|
||||
|
||||
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
|
||||
|
||||
## `eas secrets:create`
|
||||
|
||||
Create an environment secret on the current project or owner account.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas secrets:create
|
||||
|
||||
OPTIONS
|
||||
--name=name Name of the secret
|
||||
--scope=(account|project) [default: project] Scope for the secret
|
||||
--value=value Value of the secret
|
||||
```
|
||||
|
||||
_See code: [build/commands/secrets/create.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/secrets/create.ts)_
|
||||
|
||||
## `eas secrets:delete [ID]`
|
||||
|
||||
Delete an environment secret by ID.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas secrets:delete [ID]
|
||||
|
||||
ARGUMENTS
|
||||
ID ID of the secret to delete
|
||||
|
||||
DESCRIPTION
|
||||
Unsure where to find the secret's ID? Run eas secrets:list
|
||||
```
|
||||
|
||||
_See code: [build/commands/secrets/delete.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/secrets/delete.ts)_
|
||||
|
||||
## `eas secrets:list`
|
||||
|
||||
Lists environment secrets available for your current app
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas secrets:list
|
||||
```
|
||||
|
||||
_See code: [build/commands/secrets/list.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/secrets/list.ts)_
|
||||
|
||||
## `eas submit --platform=(android|ios)`
|
||||
|
||||
Submits build artifact to app store
|
||||
@@ -249,8 +314,7 @@ OPTIONS
|
||||
|
||||
iOS specific options=asc-app-id App Store Connect unique application Apple ID
|
||||
number. Providing this param results in skipping
|
||||
app creation step. Learn more:
|
||||
https://expo.fyi/asc-app-id
|
||||
app creation step. Learn more.
|
||||
|
||||
iOS specific options=bundle-identifier Your iOS Bundle Identifier (default:
|
||||
expo.ios.bundleIdentifier from app config)
|
||||
@@ -303,5 +367,86 @@ EXAMPLES
|
||||
and provide its App ID
|
||||
```
|
||||
|
||||
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.4.2/build/commands/submit.ts)_
|
||||
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/submit.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
Create a webhook on the current project.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas webhook:create
|
||||
|
||||
OPTIONS
|
||||
--event=(BUILD) [default: BUILD] Event type that triggers the webhook
|
||||
|
||||
--secret=secret Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature'
|
||||
header.
|
||||
|
||||
--url=url Webhook URL
|
||||
```
|
||||
|
||||
_See code: [build/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
Delete a webhook on the current project.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas webhook:delete [ID]
|
||||
|
||||
ARGUMENTS
|
||||
ID ID of the webhook to delete
|
||||
```
|
||||
|
||||
_See code: [build/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
List webhooks on the current project.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas webhook:list
|
||||
|
||||
OPTIONS
|
||||
--event=(BUILD) Event type that triggers the webhook
|
||||
```
|
||||
|
||||
_See code: [build/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
Create a webhook on the current project.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas webhook:update
|
||||
|
||||
OPTIONS
|
||||
--event=(BUILD) [default: BUILD] Event type that triggers the webhook
|
||||
--id=id (required) Webhook ID
|
||||
|
||||
--secret=secret Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature'
|
||||
header.
|
||||
|
||||
--url=url Webhook URL
|
||||
```
|
||||
|
||||
_See code: [build/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
View a webhook on the current project.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas webhook:view ID
|
||||
|
||||
ARGUMENTS
|
||||
ID ID of the webhook to view
|
||||
```
|
||||
|
||||
_See code: [build/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v0.9.1/build/commands/webhook/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
@@ -3,6 +3,7 @@ schema: 'https://staging.exp.host/--/graphql'
|
||||
documents:
|
||||
- 'src/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/commands/**/*.ts'
|
||||
generates:
|
||||
src/graphql/generated.ts:
|
||||
plugins:
|
||||
|
||||
+5082
-2273
File diff suppressed because it is too large.
Load diff
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "0.4.2",
|
||||
"version": "0.9.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@amplitude/identify": "^1.5.0",
|
||||
"@amplitude/node": "^1.5.0",
|
||||
"@expo/apple-utils": "0.0.0-alpha.17",
|
||||
"@expo/config": "~3.3.19",
|
||||
"@expo/config-plugins": "1.0.19-alpha.0",
|
||||
"@expo/eas-build-job": "0.2.11",
|
||||
"@expo/eas-json": "^0.4.1",
|
||||
"@expo/config-plugins": "1.0.24",
|
||||
"@expo/eas-build-job": "0.2.22",
|
||||
"@expo/eas-json": "^0.9.0",
|
||||
"@expo/json-file": "^8.2.24",
|
||||
"@expo/plist": "^0.0.11",
|
||||
"@expo/plugin-warn-if-update-available": "^1.7.0",
|
||||
@@ -23,7 +25,8 @@
|
||||
"@oclif/config": "^1",
|
||||
"@oclif/errors": "^1.3.3",
|
||||
"@oclif/plugin-help": "^3",
|
||||
"@urql/core": "^1.13.0",
|
||||
"@urql/core": "^2.0.0",
|
||||
"@urql/exchange-retry": "^0.2.0",
|
||||
"chalk": "^4.1.0",
|
||||
"cli-table3": "^0.6.0",
|
||||
"dateformat": "^3.0.3",
|
||||
@@ -39,7 +42,6 @@
|
||||
"indent-string": "^4.0.0",
|
||||
"keychain": "^1.3.0",
|
||||
"lodash": "^4.17.20",
|
||||
"md5-file": "^5.0.0",
|
||||
"mime": "^2.4.7",
|
||||
"minimatch": "^3.0.4",
|
||||
"node-fetch": "^2.6.1",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Identify } from '@amplitude/identify';
|
||||
import * as Amplitude from '@amplitude/node';
|
||||
import os from 'os';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import UserSettings from './user/UserSettings';
|
||||
|
||||
/**
|
||||
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
|
||||
* the src directory and would change the directory structure of the emitted files
|
||||
* under the build directory
|
||||
*/
|
||||
const packageJSON = require('../package.json');
|
||||
|
||||
const PLATFORM_TO_ANALYTICS_PLATFORM: { [platform: string]: string } = {
|
||||
darwin: 'Mac',
|
||||
win32: 'Windows',
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
let client: Amplitude.NodeClient | null = null;
|
||||
let userIdentifyCalled = false;
|
||||
let identifyData: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
identify: Identify;
|
||||
} | null = null;
|
||||
|
||||
export async function initAsync(): Promise<void> {
|
||||
if (process.env.DISABLE_EAS_ANALYTICS) {
|
||||
await UserSettings.setAsync('amplitudeEnabled', false);
|
||||
}
|
||||
const amplitudeEnabled = await UserSettings.getAsync('amplitudeEnabled', true);
|
||||
if (amplitudeEnabled) {
|
||||
const apiKey =
|
||||
process.env.EXPO_STAGING || process.env.EXPO_LOCAL
|
||||
? 'cdebbc678931403439486c4750781544'
|
||||
: '4ac443afd5073c0df6169291db1d3495';
|
||||
client = Amplitude.init(apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserDataAsync(
|
||||
userId: string,
|
||||
properties: Record<string, any>
|
||||
): Promise<void> {
|
||||
const identify = new Identify();
|
||||
Object.entries(properties).forEach(([property, value]) => {
|
||||
identify.set(property, value);
|
||||
});
|
||||
const savedDeviceId = await UserSettings.getAsync('amplitudeDeviceId', null);
|
||||
const deviceId = savedDeviceId ?? uuidv4();
|
||||
if (!savedDeviceId) {
|
||||
await UserSettings.setAsync('amplitudeDeviceId', deviceId);
|
||||
}
|
||||
identifyData = {
|
||||
userId,
|
||||
deviceId,
|
||||
identify,
|
||||
};
|
||||
|
||||
ensureUserIdentified();
|
||||
}
|
||||
|
||||
export async function flushAsync(): Promise<void> {
|
||||
if (client) {
|
||||
await client.flush();
|
||||
}
|
||||
}
|
||||
|
||||
export function logEvent(name: string, properties: Record<string, any> = {}) {
|
||||
if (client) {
|
||||
ensureUserIdentified();
|
||||
const { userId, deviceId } = identifyData ?? {};
|
||||
client.logEvent({
|
||||
event_type: name,
|
||||
event_properties: properties,
|
||||
...(userId && { user_id: userId }),
|
||||
...(deviceId && { device_id: deviceId }),
|
||||
...getContext(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUserIdentified() {
|
||||
if (client && !userIdentifyCalled && identifyData) {
|
||||
client.identify(identifyData.userId, identifyData.deviceId, identifyData.identify);
|
||||
userIdentifyCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function getContext() {
|
||||
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
|
||||
return {
|
||||
os_name: platform,
|
||||
os_version: os.release(),
|
||||
device_brand: platform,
|
||||
device_model: platform,
|
||||
app_version: packageJSON?.version,
|
||||
};
|
||||
}
|
||||
@@ -48,7 +48,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
Workflow.GENERIC,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
@@ -61,7 +61,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
Workflow.GENERIC,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
@@ -75,7 +75,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
Workflow.GENERIC,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
@@ -93,7 +93,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
Workflow.GENERIC,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
@@ -111,7 +111,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
Workflow.GENERIC,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
@@ -126,7 +126,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, true);
|
||||
await ensureCredentialsAsync(provider, Workflow.GENERIC, CredentialsSource.AUTO, true);
|
||||
throw new Error('ensureCredentialsAsync should throw an Error');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(
|
||||
@@ -147,7 +147,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
Workflow.GENERIC,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
@@ -165,7 +165,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, false);
|
||||
await ensureCredentialsAsync(provider, Workflow.GENERIC, CredentialsSource.AUTO, false);
|
||||
throw new Error('ensureCredentialsAsync should throw an Error');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(
|
||||
@@ -186,7 +186,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, true);
|
||||
await ensureCredentialsAsync(provider, Workflow.GENERIC, CredentialsSource.AUTO, true);
|
||||
throw new Error('ensureCredentialsAsync should throw an Error');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch('Credentials for this app are not configured');
|
||||
@@ -204,7 +204,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Managed,
|
||||
Workflow.MANAGED,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
@@ -217,7 +217,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Managed,
|
||||
Workflow.MANAGED,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
@@ -231,7 +231,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Managed,
|
||||
Workflow.MANAGED,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
@@ -247,7 +247,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Managed,
|
||||
Workflow.MANAGED,
|
||||
CredentialsSource.AUTO,
|
||||
false
|
||||
);
|
||||
@@ -265,7 +265,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
Workflow.GENERIC,
|
||||
CredentialsSource.LOCAL,
|
||||
false
|
||||
);
|
||||
@@ -283,7 +283,7 @@ describe(ensureCredentialsAsync, () => {
|
||||
});
|
||||
const src = await ensureCredentialsAsync(
|
||||
provider,
|
||||
Workflow.Generic,
|
||||
Workflow.GENERIC,
|
||||
CredentialsSource.REMOTE,
|
||||
false
|
||||
);
|
||||
|
||||
@@ -90,3 +90,21 @@ async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig)
|
||||
throw new Error('Missing values in AndroidManifest.xml');
|
||||
}
|
||||
}
|
||||
|
||||
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
|
||||
try {
|
||||
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
|
||||
if (!androidManifestPath) {
|
||||
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
|
||||
}
|
||||
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
|
||||
androidManifestPath
|
||||
);
|
||||
return AndroidConfig.Manifest.getMainApplicationMetaDataValue(
|
||||
androidManifest,
|
||||
AndroidConfig.Updates.Config.RELEASE_CHANNEL
|
||||
);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,12 @@ function isApplicationIdValid(applicationId: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(applicationId);
|
||||
}
|
||||
|
||||
export async function ensureApplicationIdIsValidAsync(projectDir: string) {
|
||||
const applicationId = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.Android);
|
||||
export async function ensureApplicationIdIsValidAsync(projectDir: string, exp: ExpoConfig) {
|
||||
const applicationId = await ensureAppIdentifierIsDefinedAsync({
|
||||
projectDir,
|
||||
platform: Platform.ANDROID,
|
||||
exp,
|
||||
});
|
||||
if (!isApplicationIdValid(applicationId)) {
|
||||
const configDescription = getProjectConfigDescription(projectDir);
|
||||
Log.error(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { Android, Metadata, Workflow } from '@expo/eas-build-job';
|
||||
import { EasConfig } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
@@ -8,29 +8,32 @@ import AndroidCredentialsProvider, {
|
||||
AndroidCredentials,
|
||||
} from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { createCredentialsContextAsync } from '../../credentials/context';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import Log from '../../log';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
import { CredentialsResult, prepareBuildRequestForPlatformAsync } from '../build';
|
||||
import { BuildContext, CommandContext, createBuildContext } from '../context';
|
||||
import { ensureCredentialsAsync } from '../credentials';
|
||||
import { transformMetadata } from '../graphql';
|
||||
import { Platform } from '../types';
|
||||
import { ensureApplicationIdIsValidAsync } from './applicationId';
|
||||
import { validateAndSyncProjectConfigurationAsync } from './configure';
|
||||
import { transformGenericJob, transformManagedJob } from './graphql';
|
||||
import { prepareJobAsync } from './prepareJob';
|
||||
|
||||
export async function prepareAndroidBuildAsync(
|
||||
commandCtx: CommandContext,
|
||||
easConfig: EasConfig
|
||||
): Promise<() => Promise<string>> {
|
||||
const buildCtx = createBuildContext<Platform.Android>({
|
||||
const buildCtx = createBuildContext<Platform.ANDROID>({
|
||||
commandCtx,
|
||||
platform: Platform.Android,
|
||||
platform: Platform.ANDROID,
|
||||
easConfig,
|
||||
});
|
||||
const { buildProfile } = buildCtx;
|
||||
|
||||
if (
|
||||
buildProfile.workflow === Workflow.Generic &&
|
||||
buildProfile.workflow === Workflow.GENERIC &&
|
||||
!(await fs.pathExists(path.join(commandCtx.projectDir, 'android')))
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -41,7 +44,7 @@ export async function prepareAndroidBuildAsync(
|
||||
}
|
||||
|
||||
if (
|
||||
buildProfile.workflow === Workflow.Generic &&
|
||||
buildProfile.workflow === Workflow.GENERIC &&
|
||||
buildProfile.distribution === 'internal' &&
|
||||
buildProfile.gradleCommand?.match(/bundle/)
|
||||
) {
|
||||
@@ -60,30 +63,52 @@ This means that it will most likely produce an AAB and you will not be able to i
|
||||
}
|
||||
}
|
||||
|
||||
await ensureApplicationIdIsValidAsync(commandCtx.projectDir);
|
||||
await ensureApplicationIdIsValidAsync(commandCtx.projectDir, commandCtx.exp);
|
||||
|
||||
return await prepareBuildRequestForPlatformAsync({
|
||||
ctx: buildCtx,
|
||||
projectConfiguration: {},
|
||||
ensureCredentialsAsync: ensureAndroidCredentialsAsync,
|
||||
ensureProjectConfiguredAsync: async () => {
|
||||
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
|
||||
if (buildCtx.buildProfile.workflow === Workflow.GENERIC) {
|
||||
await validateAndSyncProjectConfigurationAsync(commandCtx.projectDir, commandCtx.exp);
|
||||
}
|
||||
},
|
||||
prepareJobAsync,
|
||||
sendBuildRequestAsync: async (
|
||||
appId: string,
|
||||
job: Android.Job,
|
||||
metadata: Metadata
|
||||
): Promise<BuildResult> => {
|
||||
const graphqlMetadata = transformMetadata(metadata);
|
||||
if (job.type === Workflow.GENERIC) {
|
||||
const graphqlJob = transformGenericJob(job);
|
||||
return await BuildMutation.createAndroidGenericBuildAsync({
|
||||
appId,
|
||||
job: graphqlJob,
|
||||
metadata: graphqlMetadata,
|
||||
});
|
||||
} else {
|
||||
const graphqlJob = transformManagedJob(job);
|
||||
return await BuildMutation.createAndroidManagedBuildAsync({
|
||||
appId,
|
||||
job: graphqlJob,
|
||||
metadata: graphqlMetadata,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(ctx: BuildContext<Platform.Android>): boolean {
|
||||
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
|
||||
return (
|
||||
ctx.buildProfile.workflow === Workflow.Managed ||
|
||||
(ctx.buildProfile.workflow === Workflow.Generic && !ctx.buildProfile.withoutCredentials)
|
||||
ctx.buildProfile.workflow === Workflow.MANAGED ||
|
||||
(ctx.buildProfile.workflow === Workflow.GENERIC && !ctx.buildProfile.withoutCredentials)
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureAndroidCredentialsAsync(
|
||||
ctx: BuildContext<Platform.Android>
|
||||
ctx: BuildContext<Platform.ANDROID>
|
||||
): Promise<CredentialsResult<AndroidCredentials> | undefined> {
|
||||
if (!shouldProvideCredentials(ctx)) {
|
||||
return;
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void
|
||||
}
|
||||
await AndroidConfig.EasBuild.configureEasBuildAsync(ctx.projectDir);
|
||||
await configureApplicationIdAsync(ctx.projectDir, ctx.exp, ctx.allowExperimental);
|
||||
await ensureApplicationIdIsValidAsync(ctx.projectDir);
|
||||
await ensureApplicationIdIsValidAsync(ctx.projectDir, ctx.exp);
|
||||
|
||||
const easGradlePath = AndroidConfig.EasBuild.getEasBuildGradlePath(ctx.projectDir);
|
||||
await gitAddAsync(easGradlePath, { intentToAdd: true });
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
|
||||
import { AndroidGenericJobInput, AndroidManagedJobInput } from '../../graphql/generated';
|
||||
import { transformProjectArchive } from '../graphql';
|
||||
|
||||
export function transformGenericJob(job: Android.GenericJob): AndroidGenericJobInput {
|
||||
return {
|
||||
projectArchive: transformProjectArchive(job.projectArchive),
|
||||
projectRootDirectory: job.projectRootDirectory,
|
||||
releaseChannel: job.releaseChannel,
|
||||
secrets: job.secrets,
|
||||
builderEnvironment: job.builderEnvironment,
|
||||
cache: job.cache,
|
||||
gradleCommand: job.gradleCommand,
|
||||
artifactPath: job.artifactPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformManagedJob(job: Android.ManagedJob): AndroidManagedJobInput {
|
||||
return {
|
||||
projectArchive: transformProjectArchive(job.projectArchive),
|
||||
projectRootDirectory: job.projectRootDirectory,
|
||||
releaseChannel: job.releaseChannel,
|
||||
secrets: job.secrets,
|
||||
builderEnvironment: job.builderEnvironment,
|
||||
cache: job.cache,
|
||||
username: job.username,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Android,
|
||||
ArchiveSource,
|
||||
ArchiveSourceType,
|
||||
Cache,
|
||||
Job,
|
||||
Workflow,
|
||||
sanitizeJob,
|
||||
@@ -10,7 +11,7 @@ import { AndroidGenericBuildProfile, AndroidManagedBuildProfile } from '@expo/ea
|
||||
import path from 'path';
|
||||
|
||||
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { readSecretEnvsAsync } from '../../credentials/credentialsJson/read';
|
||||
import { readEnvironmentSecretsAsync } from '../../credentials/credentialsJson/read';
|
||||
import { getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { gitRootDirectoryAsync } from '../../utils/git';
|
||||
import { BuildContext } from '../context';
|
||||
@@ -22,13 +23,13 @@ interface JobData {
|
||||
}
|
||||
|
||||
export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.Android>,
|
||||
ctx: BuildContext<Platform.ANDROID>,
|
||||
jobData: JobData
|
||||
): Promise<Job> {
|
||||
if (ctx.buildProfile.workflow === Workflow.Generic) {
|
||||
if (ctx.buildProfile.workflow === Workflow.GENERIC) {
|
||||
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
|
||||
return sanitizeJob(partialJob);
|
||||
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
|
||||
} else if (ctx.buildProfile.workflow === Workflow.MANAGED) {
|
||||
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
|
||||
return sanitizeJob(partialJob);
|
||||
} else {
|
||||
@@ -37,22 +38,23 @@ export async function prepareJobAsync(
|
||||
}
|
||||
|
||||
interface CommonJobProperties {
|
||||
platform: Platform.Android;
|
||||
platform: Platform.ANDROID;
|
||||
projectArchive: ArchiveSource;
|
||||
builderEnvironment: Android.BuilderEnvironment;
|
||||
cache: Cache;
|
||||
secrets: {
|
||||
buildCredentials?: {
|
||||
keystore: Android.Keystore;
|
||||
};
|
||||
secretEnvs?: Record<string, string>;
|
||||
environmentSecrets?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareJobCommonAsync(
|
||||
ctx: BuildContext<Platform.Android>,
|
||||
ctx: BuildContext<Platform.ANDROID>,
|
||||
jobData: JobData
|
||||
): Promise<Partial<CommonJobProperties>> {
|
||||
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
|
||||
const environmentSecrets = await readEnvironmentSecretsAsync(ctx.commandCtx.projectDir);
|
||||
const credentials = jobData.credentials;
|
||||
const buildCredentials = credentials
|
||||
? {
|
||||
@@ -68,7 +70,7 @@ async function prepareJobCommonAsync(
|
||||
: {};
|
||||
|
||||
return {
|
||||
platform: Platform.Android,
|
||||
platform: Platform.ANDROID,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.S3,
|
||||
bucketKey: jobData.archiveBucketKey,
|
||||
@@ -80,15 +82,16 @@ async function prepareJobCommonAsync(
|
||||
ndk: ctx.buildProfile.ndk,
|
||||
env: ctx.buildProfile.env,
|
||||
},
|
||||
cache: ctx.buildProfile.cache,
|
||||
secrets: {
|
||||
...(secretEnvs ? { secretEnvs } : {}),
|
||||
...(environmentSecrets ? { environmentSecrets } : {}),
|
||||
...buildCredentials,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareGenericJobAsync(
|
||||
ctx: BuildContext<Platform.Android>,
|
||||
ctx: BuildContext<Platform.ANDROID>,
|
||||
jobData: JobData,
|
||||
buildProfile: AndroidGenericBuildProfile
|
||||
): Promise<Partial<Android.GenericJob>> {
|
||||
@@ -96,7 +99,7 @@ async function prepareGenericJobAsync(
|
||||
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
|
||||
return {
|
||||
...(await prepareJobCommonAsync(ctx, jobData)),
|
||||
type: Workflow.Generic,
|
||||
type: Workflow.GENERIC,
|
||||
gradleCommand: buildProfile.gradleCommand,
|
||||
artifactPath: buildProfile.artifactPath,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
@@ -105,16 +108,16 @@ async function prepareGenericJobAsync(
|
||||
}
|
||||
|
||||
async function prepareManagedJobAsync(
|
||||
ctx: BuildContext<Platform.Android>,
|
||||
ctx: BuildContext<Platform.ANDROID>,
|
||||
jobData: JobData,
|
||||
buildProfile: AndroidManagedBuildProfile
|
||||
): Promise<Partial<Android.ManagedJob>> {
|
||||
const projectRootDirectory =
|
||||
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
|
||||
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
|
||||
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.exp);
|
||||
return {
|
||||
...(await prepareJobCommonAsync(ctx, jobData)),
|
||||
type: Workflow.Managed,
|
||||
type: Workflow.MANAGED,
|
||||
username: accountName,
|
||||
buildType: buildProfile.buildType,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import { Job, Metadata } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { apiClient } from '../api';
|
||||
import { UploadSessionType } from '../graphql/generated';
|
||||
import { BuildResult } from '../graphql/mutations/BuildMutation';
|
||||
import Log from '../log';
|
||||
import { promptAsync } from '../prompts';
|
||||
import { UploadType, uploadAsync } from '../uploads';
|
||||
import { uploadAsync } from '../uploads';
|
||||
import { formatBytes } from '../utils/files';
|
||||
import { createProgressTracker } from '../utils/progress';
|
||||
import { platformDisplayNames } from './constants';
|
||||
import { requestedPlatformDisplayNames } from './constants';
|
||||
import { BuildContext } from './context';
|
||||
import { collectMetadata } from './metadata';
|
||||
import { AnalyticsEvent, Platform, TrackingContext } from './types';
|
||||
import Analytics from './utils/analytics';
|
||||
import { Platform, TrackingContext } from './types';
|
||||
import Analytics, { Event } from './utils/analytics';
|
||||
import { printDeprecationWarnings } from './utils/printBuildInfo';
|
||||
import {
|
||||
commitChangedFilesAsync,
|
||||
commitPromptAsync,
|
||||
isGitStatusCleanAsync,
|
||||
makeProjectTarballAsync,
|
||||
@@ -25,7 +28,7 @@ export interface CredentialsResult<Credentials> {
|
||||
credentials: Credentials;
|
||||
}
|
||||
|
||||
interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration> {
|
||||
interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration, TJob extends Job> {
|
||||
ctx: BuildContext<TPlatform>;
|
||||
projectConfiguration: ProjectConfiguration;
|
||||
|
||||
@@ -41,25 +44,29 @@ interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration>
|
||||
projectConfiguration?: ProjectConfiguration;
|
||||
}
|
||||
): Promise<Job>;
|
||||
sendBuildRequestAsync(appId: string, job: TJob, metadata: Metadata): Promise<BuildResult>;
|
||||
}
|
||||
|
||||
export async function prepareBuildRequestForPlatformAsync<
|
||||
TPlatform extends Platform,
|
||||
Credentials,
|
||||
ProjectConfiguration
|
||||
>(builder: Builder<TPlatform, Credentials, ProjectConfiguration>): Promise<() => Promise<string>> {
|
||||
ProjectConfiguration,
|
||||
TJob extends Job
|
||||
>(
|
||||
builder: Builder<TPlatform, Credentials, ProjectConfiguration, TJob>
|
||||
): Promise<() => Promise<string>> {
|
||||
const credentialsResult = await withAnalyticsAsync(
|
||||
async () => await builder.ensureCredentialsAsync(builder.ctx),
|
||||
{
|
||||
successEvent: AnalyticsEvent.GATHER_CREDENTIALS_SUCCESS,
|
||||
failureEvent: AnalyticsEvent.GATHER_CREDENTIALS_FAIL,
|
||||
successEvent: Event.GATHER_CREDENTIALS_SUCCESS,
|
||||
failureEvent: Event.GATHER_CREDENTIALS_FAIL,
|
||||
trackingCtx: builder.ctx.trackingCtx,
|
||||
}
|
||||
);
|
||||
if (!builder.ctx.commandCtx.skipProjectConfiguration) {
|
||||
await withAnalyticsAsync(async () => await builder.ensureProjectConfiguredAsync(builder.ctx), {
|
||||
successEvent: AnalyticsEvent.CONFIGURE_PROJECT_SUCCESS,
|
||||
failureEvent: AnalyticsEvent.CONFIGURE_PROJECT_FAIL,
|
||||
successEvent: Event.CONFIGURE_PROJECT_SUCCESS,
|
||||
failureEvent: Event.CONFIGURE_PROJECT_FAIL,
|
||||
trackingCtx: builder.ctx.trackingCtx,
|
||||
});
|
||||
}
|
||||
@@ -67,7 +74,9 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
if (!(await isGitStatusCleanAsync())) {
|
||||
Log.addNewLineIfNone();
|
||||
await reviewAndCommitChangesAsync(
|
||||
`[EAS Build] Run EAS Build for ${platformDisplayNames[builder.ctx.platform as Platform]}`,
|
||||
`[EAS Build] Run EAS Build for ${
|
||||
requestedPlatformDisplayNames[builder.ctx.platform as Platform]
|
||||
}`,
|
||||
{
|
||||
nonInteractive: builder.ctx.commandCtx.nonInteractive,
|
||||
}
|
||||
@@ -87,33 +96,9 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
return await withAnalyticsAsync(
|
||||
async () => {
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Starting ${platformDisplayNames[job.platform]} build`);
|
||||
}
|
||||
const {
|
||||
data: { buildId, deprecationInfo },
|
||||
} = await apiClient
|
||||
.post(`projects/${builder.ctx.commandCtx.projectId}/builds`, {
|
||||
json: {
|
||||
job,
|
||||
metadata,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
printDeprecationWarnings(deprecationInfo);
|
||||
return buildId;
|
||||
},
|
||||
{
|
||||
successEvent: AnalyticsEvent.BUILD_REQUEST_SUCCESS,
|
||||
failureEvent: AnalyticsEvent.BUILD_REQUEST_FAIL,
|
||||
trackingCtx: builder.ctx.trackingCtx,
|
||||
}
|
||||
);
|
||||
return sendBuildRequestAsync(builder, job, metadata);
|
||||
} catch (error) {
|
||||
const body = error?.response?.body;
|
||||
if (body && JSON.parse(body)?.errors?.[0]?.code === 'TURTLE_DEPRECATED_JOB_FORMAT') {
|
||||
if (error?.expoApiV2ErrorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
|
||||
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli');
|
||||
}
|
||||
throw error;
|
||||
@@ -132,19 +117,22 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
projectTarballPath = projectTarball.path;
|
||||
|
||||
const { bucketKey } = await uploadAsync(
|
||||
UploadType.TURTLE_PROJECT_SOURCES,
|
||||
UploadSessionType.EasBuildProjectSources,
|
||||
projectTarball.path,
|
||||
createProgressTracker({
|
||||
total: projectTarball.size,
|
||||
message: 'Uploading to EAS Build',
|
||||
message: ratio =>
|
||||
`Uploading to EAS Build (${formatBytes(projectTarball.size * ratio)} / ${formatBytes(
|
||||
projectTarball.size
|
||||
)})`,
|
||||
completedMessage: `Uploaded to EAS`,
|
||||
})
|
||||
);
|
||||
return bucketKey;
|
||||
},
|
||||
{
|
||||
successEvent: AnalyticsEvent.PROJECT_UPLOAD_SUCCESS,
|
||||
failureEvent: AnalyticsEvent.PROJECT_UPLOAD_FAIL,
|
||||
successEvent: Event.PROJECT_UPLOAD_SUCCESS,
|
||||
failureEvent: Event.PROJECT_UPLOAD_FAIL,
|
||||
trackingCtx: ctx.trackingCtx,
|
||||
}
|
||||
);
|
||||
@@ -155,11 +143,45 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
}
|
||||
}
|
||||
|
||||
async function sendBuildRequestAsync<
|
||||
TPlatform extends Platform,
|
||||
Credentials,
|
||||
ProjectConfiguration,
|
||||
TJob extends Job
|
||||
>(
|
||||
builder: Builder<TPlatform, Credentials, ProjectConfiguration, TJob>,
|
||||
job: TJob,
|
||||
metadata: Metadata
|
||||
): Promise<string> {
|
||||
const { ctx } = builder;
|
||||
return await withAnalyticsAsync(
|
||||
async () => {
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Starting ${requestedPlatformDisplayNames[job.platform]} build`);
|
||||
}
|
||||
|
||||
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
|
||||
ctx.commandCtx.projectId,
|
||||
job,
|
||||
metadata
|
||||
);
|
||||
|
||||
printDeprecationWarnings(deprecationInfo);
|
||||
return build.id;
|
||||
},
|
||||
{
|
||||
successEvent: Event.BUILD_REQUEST_SUCCESS,
|
||||
failureEvent: Event.BUILD_REQUEST_FAIL,
|
||||
trackingCtx: ctx.trackingCtx,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function withAnalyticsAsync<Result>(
|
||||
fn: () => Promise<Result>,
|
||||
analytics: {
|
||||
successEvent: AnalyticsEvent;
|
||||
failureEvent: AnalyticsEvent;
|
||||
successEvent: Event;
|
||||
failureEvent: Event;
|
||||
trackingCtx: TrackingContext;
|
||||
}
|
||||
): Promise<Result> {
|
||||
@@ -183,12 +205,17 @@ enum ShouldCommitChanges {
|
||||
}
|
||||
|
||||
async function reviewAndCommitChangesAsync(
|
||||
commitMessage: string,
|
||||
initialCommitMessage: string,
|
||||
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
|
||||
): Promise<void> {
|
||||
if (process.env.EAS_BUILD_AUTOCOMMIT) {
|
||||
await commitChangedFilesAsync(initialCommitMessage);
|
||||
Log.withTick('Committed changes.');
|
||||
return;
|
||||
}
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode to review and commit changes.'
|
||||
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode or set EAS_BUILD_AUTOCOMMIT=1 in your environment.'
|
||||
);
|
||||
}
|
||||
const { selected } = await promptAsync({
|
||||
@@ -212,10 +239,13 @@ async function reviewAndCommitChangesAsync(
|
||||
"Aborting, run the command again once you're ready. Make sure to commit any changes you've made."
|
||||
);
|
||||
} else if (selected === ShouldCommitChanges.Yes) {
|
||||
await commitPromptAsync(commitMessage);
|
||||
await commitPromptAsync({ initialCommitMessage });
|
||||
Log.withTick('Committed changes.');
|
||||
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
|
||||
await showDiffAsync();
|
||||
await reviewAndCommitChangesAsync(commitMessage, { nonInteractive, askedFirstTime: false });
|
||||
await reviewAndCommitChangesAsync(initialCommitMessage, {
|
||||
nonInteractive,
|
||||
askedFirstTime: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ enum ShouldCommitChanges {
|
||||
}
|
||||
|
||||
async function reviewAndCommitChangesAsync(
|
||||
commitMessage: string,
|
||||
initialCommitMessage: string,
|
||||
askedFirstTime: boolean = true
|
||||
): Promise<void> {
|
||||
const { selected } = await promptAsync({
|
||||
@@ -152,10 +152,10 @@ async function reviewAndCommitChangesAsync(
|
||||
});
|
||||
|
||||
if (selected === ShouldCommitChanges.Yes) {
|
||||
await commitPromptAsync(commitMessage);
|
||||
await commitPromptAsync({ initialCommitMessage });
|
||||
Log.withTick('Committed changes');
|
||||
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
|
||||
await showDiffAsync();
|
||||
await reviewAndCommitChangesAsync(commitMessage, false);
|
||||
await reviewAndCommitChangesAsync(initialCommitMessage, false);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Platform, RequestedPlatform } from './types';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { RequestedPlatform } from './types';
|
||||
|
||||
export const platformDisplayNames = {
|
||||
export const requestedPlatformDisplayNames: Record<RequestedPlatform, string> = {
|
||||
[RequestedPlatform.iOS]: 'iOS',
|
||||
[RequestedPlatform.Android]: 'Android',
|
||||
[RequestedPlatform.All]: 'Android and iOS',
|
||||
};
|
||||
|
||||
export const platformEmojis = {
|
||||
[Platform.iOS]: '🍎',
|
||||
[Platform.Android]: '🤖',
|
||||
export const appPlatformDisplayNames: Record<AppPlatform, string> = {
|
||||
[AppPlatform.Android]: 'Android',
|
||||
[AppPlatform.Ios]: 'iOS',
|
||||
};
|
||||
|
||||
export const appPlatformEmojis = {
|
||||
[AppPlatform.Ios]: '🍎',
|
||||
[AppPlatform.Android]: '🤖',
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import { ExpoConfig, getConfig } from '@expo/config';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidBuildProfile, EasConfig, iOSBuildProfile } from '@expo/eas-json';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getProjectAccountName } from '../project/projectUtils';
|
||||
import { findAccountByName } from '../user/Account';
|
||||
import { Actor } from '../user/User';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import { Platform, RequestedPlatform, TrackingContext } from './types';
|
||||
import Analytics, { Event } from './utils/analytics';
|
||||
|
||||
export interface CommandContext {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
@@ -15,7 +18,6 @@ export interface CommandContext {
|
||||
accountName: string;
|
||||
projectName: string;
|
||||
exp: ExpoConfig;
|
||||
trackingCtx: TrackingContext;
|
||||
nonInteractive: boolean;
|
||||
skipCredentialsCheck: boolean;
|
||||
skipProjectConfiguration: boolean;
|
||||
@@ -25,9 +27,9 @@ export interface CommandContext {
|
||||
export async function createCommandContextAsync({
|
||||
requestedPlatform,
|
||||
profile,
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
trackingCtx,
|
||||
nonInteractive = false,
|
||||
skipCredentialsCheck = false,
|
||||
skipProjectConfiguration = false,
|
||||
@@ -35,16 +37,15 @@ export async function createCommandContextAsync({
|
||||
}: {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
profile: string;
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
projectDir: string;
|
||||
trackingCtx: TrackingContext;
|
||||
nonInteractive: boolean;
|
||||
skipCredentialsCheck: boolean;
|
||||
skipProjectConfiguration: boolean;
|
||||
waitForBuildEnd: boolean;
|
||||
}): Promise<CommandContext> {
|
||||
const user = await ensureLoggedInAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = getProjectAccountName(exp, user);
|
||||
const projectName = exp.slug;
|
||||
|
||||
@@ -57,7 +58,6 @@ export async function createCommandContextAsync({
|
||||
accountName,
|
||||
projectName,
|
||||
exp,
|
||||
trackingCtx,
|
||||
nonInteractive,
|
||||
skipCredentialsCheck,
|
||||
skipProjectConfiguration,
|
||||
@@ -77,7 +77,7 @@ export interface ConfigureContext {
|
||||
hasIosNativeProject: boolean;
|
||||
}
|
||||
|
||||
type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
|
||||
type PlatformBuildProfile<T extends Platform> = T extends Platform.ANDROID
|
||||
? AndroidBuildProfile
|
||||
: iOSBuildProfile;
|
||||
|
||||
@@ -101,13 +101,20 @@ export function createBuildContext<T extends Platform>({
|
||||
if (!buildProfile) {
|
||||
throw new Error(`${platform} build profile does not exist`);
|
||||
}
|
||||
const builderTrackingCtx = {
|
||||
...commandCtx.trackingCtx,
|
||||
|
||||
const accountId = findAccountByName(commandCtx.user.accounts, commandCtx.accountName)?.id;
|
||||
const trackingCtx = {
|
||||
tracking_id: uuidv4(),
|
||||
platform,
|
||||
...(accountId && { account_id: accountId }),
|
||||
account_name: commandCtx.accountName,
|
||||
project_id: commandCtx.projectId,
|
||||
project_type: buildProfile.workflow,
|
||||
};
|
||||
Analytics.logEvent(Event.BUILD_COMMAND, trackingCtx);
|
||||
return {
|
||||
commandCtx,
|
||||
trackingCtx: builderTrackingCtx,
|
||||
trackingCtx,
|
||||
platform,
|
||||
buildProfile,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { EasJsonReader } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../api';
|
||||
import { BuildFragment, BuildStatus } from '../graphql/generated';
|
||||
import { BuildQuery } from '../graphql/queries/BuildQuery';
|
||||
import Log from '../log';
|
||||
import { sleep } from '../utils/promise';
|
||||
import { prepareAndroidBuildAsync } from './android/build';
|
||||
import { CommandContext } from './context';
|
||||
import { prepareIosBuildAsync } from './ios/build';
|
||||
import { Build, BuildStatus, Platform, RequestedPlatform } from './types';
|
||||
import { Platform, RequestedPlatform } from './types';
|
||||
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
|
||||
import { ensureGitRepoExistsAsync, ensureGitStatusIsCleanAsync } from './utils/repository';
|
||||
|
||||
@@ -22,12 +24,9 @@ export async function buildAsync(commandCtx: CommandContext): Promise<void> {
|
||||
Log.newLine();
|
||||
|
||||
if (commandCtx.waitForBuildEnd) {
|
||||
const builds = await waitForBuildEndAsync(
|
||||
commandCtx,
|
||||
scheduledBuilds.map(i => i.buildId)
|
||||
);
|
||||
Log.newLine();
|
||||
const builds = await waitForBuildEndAsync(scheduledBuilds.map(i => i.buildId));
|
||||
printBuildResults(commandCtx.accountName, builds);
|
||||
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +50,11 @@ async function startBuildsAsync(
|
||||
}[] = [];
|
||||
if (shouldBuildAndroid) {
|
||||
const sendBuildRequestAsync = await prepareAndroidBuildAsync(commandCtx, easConfig);
|
||||
builds.push({ platform: Platform.Android, sendBuildRequestAsync });
|
||||
builds.push({ platform: Platform.ANDROID, sendBuildRequestAsync });
|
||||
}
|
||||
if (shouldBuildiOS) {
|
||||
const sendBuildRequestAsync = await prepareIosBuildAsync(commandCtx, easConfig);
|
||||
builds.push({ platform: Platform.iOS, sendBuildRequestAsync });
|
||||
builds.push({ platform: Platform.IOS, sendBuildRequestAsync });
|
||||
}
|
||||
return Promise.all(
|
||||
builds.map(async ({ platform, sendBuildRequestAsync }) => ({
|
||||
@@ -66,57 +65,58 @@ async function startBuildsAsync(
|
||||
}
|
||||
|
||||
async function waitForBuildEndAsync(
|
||||
commandCtx: CommandContext,
|
||||
buildIds: string[],
|
||||
{ timeoutSec = 1800, intervalSec = 30 } = {}
|
||||
): Promise<(Build | null)[]> {
|
||||
): Promise<(BuildFragment | null)[]> {
|
||||
Log.log('Waiting for build to complete. You can press Ctrl+C to exit.');
|
||||
const spinner = ora().start();
|
||||
let time = new Date().getTime();
|
||||
const endTime = time + timeoutSec * 1000;
|
||||
while (time <= endTime) {
|
||||
const builds: (Build | null)[] = await Promise.all(
|
||||
const builds: (BuildFragment | null)[] = await Promise.all(
|
||||
buildIds.map(async buildId => {
|
||||
try {
|
||||
const { data } = await apiClient
|
||||
.get(`projects/${commandCtx.projectId}/builds/${buildId}`)
|
||||
.json();
|
||||
return data;
|
||||
return await BuildQuery.byIdAsync(buildId, { useCache: false });
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
if (builds.length === 1) {
|
||||
switch (builds[0]?.status) {
|
||||
case BuildStatus.FINISHED:
|
||||
const build = nullthrows(builds[0]);
|
||||
switch (build.status) {
|
||||
case BuildStatus.Finished:
|
||||
spinner.succeed('Build finished');
|
||||
return builds;
|
||||
case BuildStatus.IN_QUEUE:
|
||||
case BuildStatus.InQueue:
|
||||
spinner.text = 'Build queued...';
|
||||
break;
|
||||
case BuildStatus.CANCELED:
|
||||
case BuildStatus.Canceled:
|
||||
spinner.text = 'Build canceled';
|
||||
spinner.stopAndPersist();
|
||||
return builds;
|
||||
case BuildStatus.IN_PROGRESS:
|
||||
case BuildStatus.InProgress:
|
||||
spinner.text = 'Build in progress...';
|
||||
break;
|
||||
case BuildStatus.ERRORED:
|
||||
case BuildStatus.Errored:
|
||||
spinner.fail('Build failed');
|
||||
throw new Error(`Standalone build failed!`);
|
||||
if (build.error) {
|
||||
return builds;
|
||||
} else {
|
||||
throw new Error(`Standalone build failed!`);
|
||||
}
|
||||
default:
|
||||
spinner.warn('Unknown status.');
|
||||
throw new Error(`Unknown status: ${builds} - aborting!`);
|
||||
}
|
||||
} else {
|
||||
if (builds.filter(build => build?.status === BuildStatus.FINISHED).length === builds.length) {
|
||||
if (builds.filter(build => build?.status === BuildStatus.Finished).length === builds.length) {
|
||||
spinner.succeed('All builds have finished');
|
||||
return builds;
|
||||
} else if (
|
||||
builds.filter(build =>
|
||||
build?.status
|
||||
? [BuildStatus.FINISHED, BuildStatus.ERRORED, BuildStatus.CANCELED].includes(
|
||||
? [BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(
|
||||
build.status
|
||||
)
|
||||
: false
|
||||
@@ -125,11 +125,11 @@ async function waitForBuildEndAsync(
|
||||
spinner.fail('Some of the builds were canceled or failed.');
|
||||
return builds;
|
||||
} else {
|
||||
const inQueue = builds.filter(build => build?.status === BuildStatus.IN_QUEUE).length;
|
||||
const inProgress = builds.filter(build => build?.status === BuildStatus.IN_PROGRESS).length;
|
||||
const errored = builds.filter(build => build?.status === BuildStatus.ERRORED).length;
|
||||
const finished = builds.filter(build => build?.status === BuildStatus.FINISHED).length;
|
||||
const canceled = builds.filter(build => build?.status === BuildStatus.CANCELED).length;
|
||||
const inQueue = builds.filter(build => build?.status === BuildStatus.InQueue).length;
|
||||
const inProgress = builds.filter(build => build?.status === BuildStatus.InProgress).length;
|
||||
const errored = builds.filter(build => build?.status === BuildStatus.Errored).length;
|
||||
const finished = builds.filter(build => build?.status === BuildStatus.Finished).length;
|
||||
const canceled = builds.filter(build => build?.status === BuildStatus.Canceled).length;
|
||||
const unknownState = builds.length - inQueue - inProgress - errored - finished;
|
||||
spinner.text = [
|
||||
inQueue && `Builds in queue: ${inQueue}`,
|
||||
@@ -151,3 +151,12 @@ async function waitForBuildEndAsync(
|
||||
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
|
||||
);
|
||||
}
|
||||
|
||||
function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | null)[]): void {
|
||||
const failedBuilds = (maybeBuilds.filter(i => i) as BuildFragment[]).filter(
|
||||
i => i.status === BuildStatus.Errored
|
||||
);
|
||||
if (failedBuilds.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import chalk from 'chalk';
|
||||
import { CredentialsProvider } from '../credentials/CredentialsProvider';
|
||||
import Log from '../log';
|
||||
import { confirmAsync, promptAsync } from '../prompts';
|
||||
import { platformDisplayNames } from './constants';
|
||||
import { requestedPlatformDisplayNames } from './constants';
|
||||
|
||||
function logCredentials(target: 'local' | 'remote', platform: Platform) {
|
||||
let message = `Using ${target} ${platformDisplayNames[platform]} credentials`;
|
||||
let message = `Using ${target} ${requestedPlatformDisplayNames[platform]} credentials`;
|
||||
if (target === 'local') message += ` ${chalk.dim('(credentials.json)')}`;
|
||||
if (target === 'remote') message += ` ${chalk.dim('(Expo server)')}`;
|
||||
Log.succeed(message);
|
||||
@@ -19,15 +19,15 @@ async function ensureCredentialsAutoAsync(
|
||||
workflow: Workflow,
|
||||
nonInteractive: boolean
|
||||
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
|
||||
const platform = platformDisplayNames[provider.platform];
|
||||
const platform = requestedPlatformDisplayNames[provider.platform];
|
||||
switch (workflow) {
|
||||
case Workflow.Managed:
|
||||
case Workflow.MANAGED:
|
||||
if (await provider.hasLocalAsync()) {
|
||||
return CredentialsSource.LOCAL;
|
||||
} else {
|
||||
return CredentialsSource.REMOTE;
|
||||
}
|
||||
case Workflow.Generic: {
|
||||
case Workflow.GENERIC: {
|
||||
const hasLocal = await provider.hasLocalAsync();
|
||||
const hasRemote = await provider.hasRemoteAsync();
|
||||
if (hasRemote && hasLocal) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ArchiveSource, ArchiveSourceType, Metadata, Workflow } from '@expo/eas-build-job';
|
||||
|
||||
import {
|
||||
BuildCredentialsSource,
|
||||
BuildMetadataInput,
|
||||
BuildWorkflow,
|
||||
DistributionType,
|
||||
ProjectArchiveSourceInput,
|
||||
ProjectArchiveSourceType,
|
||||
} from '../graphql/generated';
|
||||
|
||||
export function transformProjectArchive(archiveSource: ArchiveSource): ProjectArchiveSourceInput {
|
||||
if (archiveSource.type === ArchiveSourceType.S3) {
|
||||
return {
|
||||
type: ProjectArchiveSourceType.S3,
|
||||
bucketKey: archiveSource.bucketKey,
|
||||
};
|
||||
} else if (archiveSource.type === ArchiveSourceType.URL) {
|
||||
return {
|
||||
type: ProjectArchiveSourceType.Url,
|
||||
url: archiveSource.url,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported project archive source type: '${archiveSource.type}'`);
|
||||
}
|
||||
}
|
||||
|
||||
export function transformMetadata(metadata: Metadata): BuildMetadataInput {
|
||||
return {
|
||||
appIdentifier: metadata.appIdentifier,
|
||||
appName: metadata.appName,
|
||||
appVersion: metadata.appVersion,
|
||||
buildProfile: metadata.buildProfile,
|
||||
cliVersion: metadata.cliVersion,
|
||||
credentialsSource:
|
||||
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
|
||||
distribution: metadata.distribution && transformDistribution(metadata.distribution),
|
||||
gitCommitHash: metadata.gitCommitHash,
|
||||
releaseChannel: metadata.releaseChannel,
|
||||
sdkVersion: metadata.sdkVersion,
|
||||
trackingContext: metadata.trackingContext,
|
||||
workflow: metadata.workflow && transformWorkflow(metadata.workflow),
|
||||
};
|
||||
}
|
||||
|
||||
function transformCredentialsSource(
|
||||
credentialsSource: Metadata['credentialsSource']
|
||||
): BuildCredentialsSource {
|
||||
if (credentialsSource === 'local') {
|
||||
return BuildCredentialsSource.Local;
|
||||
} else {
|
||||
return BuildCredentialsSource.Remote;
|
||||
}
|
||||
}
|
||||
|
||||
function transformDistribution(distribution: Metadata['distribution']): DistributionType {
|
||||
if (distribution === 'internal') {
|
||||
return DistributionType.Internal;
|
||||
} else if (distribution === 'simulator') {
|
||||
return DistributionType.Simulator;
|
||||
} else {
|
||||
return DistributionType.Store;
|
||||
}
|
||||
}
|
||||
|
||||
function transformWorkflow(workflow: Metadata['workflow']): BuildWorkflow {
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
return BuildWorkflow.Generic;
|
||||
} else {
|
||||
return BuildWorkflow.Managed;
|
||||
}
|
||||
}
|
||||
@@ -86,3 +86,12 @@ async function writeExpoPlistAsync(
|
||||
await writePlistAsync(expoPlistPath, expoPlist);
|
||||
await gitAddAsync(expoPlistPath, { intentToAdd: true });
|
||||
}
|
||||
|
||||
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
|
||||
try {
|
||||
const expoPlist = await readExpoPlistAsync(projectDir);
|
||||
return expoPlist[IOSConfig.Updates.Config.RELEASE_CHANNEL] ?? null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,36 @@
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { Ios, Metadata, Workflow } from '@expo/eas-build-job';
|
||||
import { EasConfig } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { prepareBuildRequestForPlatformAsync } from '../build';
|
||||
import { BuildContext, CommandContext, createBuildContext } from '../context';
|
||||
import { transformMetadata } from '../graphql';
|
||||
import { Platform } from '../types';
|
||||
import { ensureBundleIdentifierIsValidAsync } from './bundleIdentifer';
|
||||
import { validateAndSyncProjectConfigurationAsync } from './configure';
|
||||
import { ensureIosCredentialsAsync } from './credentials';
|
||||
import { prepareJobAsync } from './prepareJob';
|
||||
import { transformGenericJob, transformManagedJob } from './graphql';
|
||||
import { prepareJobAsync, sanitizedTargetName } from './prepareJob';
|
||||
|
||||
export async function prepareIosBuildAsync(
|
||||
commandCtx: CommandContext,
|
||||
easConfig: EasConfig
|
||||
): Promise<() => Promise<string>> {
|
||||
const buildCtx = createBuildContext<Platform.iOS>({
|
||||
const buildCtx = createBuildContext<Platform.IOS>({
|
||||
commandCtx,
|
||||
platform: Platform.iOS,
|
||||
platform: Platform.IOS,
|
||||
easConfig,
|
||||
});
|
||||
|
||||
if (
|
||||
buildCtx.buildProfile.workflow === Workflow.Generic &&
|
||||
buildCtx.buildProfile.workflow === Workflow.GENERIC &&
|
||||
!(await fs.pathExists(path.join(commandCtx.projectDir, 'ios')))
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -39,15 +42,23 @@ export async function prepareIosBuildAsync(
|
||||
|
||||
let iosBuildScheme: string | undefined;
|
||||
let iosApplicationTarget: string | undefined;
|
||||
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
|
||||
if (buildCtx.buildProfile.workflow === Workflow.GENERIC) {
|
||||
iosBuildScheme = buildCtx.buildProfile.scheme ?? (await resolveSchemeAsync(buildCtx));
|
||||
iosApplicationTarget = await IOSConfig.BuildScheme.getApplicationTargetForSchemeAsync(
|
||||
buildCtx.commandCtx.projectDir,
|
||||
iosBuildScheme
|
||||
);
|
||||
} else {
|
||||
if (!commandCtx.exp.name) {
|
||||
throw new Error('"expo.name" is required in your app.json');
|
||||
}
|
||||
iosApplicationTarget = sanitizedTargetName(commandCtx.exp.name);
|
||||
if (!iosApplicationTarget) {
|
||||
throw new Error('"expo.name" needs to contain some alphanumeric characters');
|
||||
}
|
||||
}
|
||||
|
||||
await ensureBundleIdentifierIsValidAsync(commandCtx.projectDir);
|
||||
await ensureBundleIdentifierIsValidAsync(commandCtx.projectDir, commandCtx.exp);
|
||||
|
||||
return await prepareBuildRequestForPlatformAsync({
|
||||
ctx: buildCtx,
|
||||
@@ -64,10 +75,32 @@ export async function prepareIosBuildAsync(
|
||||
});
|
||||
},
|
||||
prepareJobAsync,
|
||||
sendBuildRequestAsync: async (
|
||||
appId: string,
|
||||
job: Ios.Job,
|
||||
metadata: Metadata
|
||||
): Promise<BuildResult> => {
|
||||
const graphqlMetadata = transformMetadata(metadata);
|
||||
if (job.type === Workflow.GENERIC) {
|
||||
const graphqlJob = transformGenericJob(job);
|
||||
return await BuildMutation.createIosGenericBuildAsync({
|
||||
appId,
|
||||
job: graphqlJob,
|
||||
metadata: graphqlMetadata,
|
||||
});
|
||||
} else {
|
||||
const graphqlJob = transformManagedJob(job);
|
||||
return await BuildMutation.createIosManagedBuildAsync({
|
||||
appId,
|
||||
job: graphqlJob,
|
||||
metadata: graphqlMetadata,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSchemeAsync(ctx: BuildContext<Platform.iOS>): Promise<string> {
|
||||
async function resolveSchemeAsync(ctx: BuildContext<Platform.IOS>): Promise<string> {
|
||||
const schemes = IOSConfig.BuildScheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
|
||||
if (schemes.length === 1) {
|
||||
return schemes[0];
|
||||
|
||||
@@ -21,8 +21,12 @@ function isBundleIdentifierValid(bundleIdentifier: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(bundleIdentifier);
|
||||
}
|
||||
|
||||
export async function ensureBundleIdentifierIsValidAsync(projectDir: string) {
|
||||
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.iOS);
|
||||
export async function ensureBundleIdentifierIsValidAsync(projectDir: string, exp: ExpoConfig) {
|
||||
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync({
|
||||
projectDir,
|
||||
platform: Platform.IOS,
|
||||
exp,
|
||||
});
|
||||
if (!isBundleIdentifierValid(bundleIdentifier)) {
|
||||
const configDescription = getProjectConfigDescription(projectDir);
|
||||
Log.error(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { VersionAutoIncrement, iOSBuildProfile } from '@expo/eas-json';
|
||||
import {
|
||||
EasJsonReader,
|
||||
VersionAutoIncrement,
|
||||
iOSBuildProfile,
|
||||
iOSGenericBuildProfile,
|
||||
} from '@expo/eas-json';
|
||||
|
||||
import Log from '../../log';
|
||||
import { ConfigureContext } from '../context';
|
||||
@@ -17,8 +22,14 @@ export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
|
||||
if (!ctx.hasIosNativeProject) {
|
||||
return;
|
||||
}
|
||||
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
|
||||
await ensureBundleIdentifierIsValidAsync(ctx.projectDir);
|
||||
|
||||
const disableIosBundleIdentifierValidation = (
|
||||
await new EasJsonReader(ctx.projectDir, 'all').readRawAsync()
|
||||
)?.experimental?.disableIosBundleIdentifierValidation;
|
||||
if (!disableIosBundleIdentifierValidation) {
|
||||
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
|
||||
await ensureBundleIdentifierIsValidAsync(ctx.projectDir, ctx.exp);
|
||||
}
|
||||
|
||||
if (isExpoUpdatesInstalled(ctx.projectDir)) {
|
||||
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
|
||||
@@ -38,14 +49,20 @@ export async function validateAndSyncProjectConfigurationAsync({
|
||||
const { workflow, autoIncrement } = buildProfile;
|
||||
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement);
|
||||
|
||||
if (workflow === Workflow.Generic) {
|
||||
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(
|
||||
projectDir
|
||||
);
|
||||
if (!bundleIdentifierFromPbxproj || bundleIdentifierFromPbxproj !== exp.ios?.bundleIdentifier) {
|
||||
throw new Error(
|
||||
'Bundle identifier is not configured correctly in your Xcode project. Please run "eas build:configure" to configure it.'
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
const genericBuildProfile = buildProfile as iOSGenericBuildProfile;
|
||||
if (!genericBuildProfile.disableIosBundleIdentifierValidation) {
|
||||
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(
|
||||
projectDir
|
||||
);
|
||||
if (
|
||||
!bundleIdentifierFromPbxproj ||
|
||||
bundleIdentifierFromPbxproj !== exp.ios?.bundleIdentifier
|
||||
) {
|
||||
throw new Error(
|
||||
'Bundle identifier is not configured correctly in your Xcode project. Please run "eas build:configure" to configure it.'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isExpoUpdatesInstalled(projectDir)) {
|
||||
await syncUpdatesConfigurationAsync(projectDir, exp);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { CredentialsSource, DistributionType } from '@expo/eas-json';
|
||||
import { CredentialsSource, iOSDistributionType } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
|
||||
import { createCredentialsContextAsync } from '../../credentials/context';
|
||||
@@ -13,7 +13,7 @@ import { ensureCredentialsAsync } from '../credentials';
|
||||
import { Platform } from '../types';
|
||||
|
||||
export async function ensureIosCredentialsAsync(
|
||||
ctx: BuildContext<Platform.iOS>
|
||||
ctx: BuildContext<Platform.IOS>
|
||||
): Promise<CredentialsResult<IosCredentials> | undefined> {
|
||||
if (!shouldProvideCredentials(ctx)) {
|
||||
return;
|
||||
@@ -27,7 +27,7 @@ export async function ensureIosCredentialsAsync(
|
||||
},
|
||||
workflow: ctx.buildProfile.workflow,
|
||||
credentialsSource: ctx.buildProfile.credentialsSource,
|
||||
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
|
||||
distribution: ctx.buildProfile.distribution ?? 'store',
|
||||
nonInteractive: ctx.commandCtx.nonInteractive,
|
||||
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
|
||||
});
|
||||
@@ -37,7 +37,7 @@ interface ResolveCredentialsParams {
|
||||
app: AppLookupParams;
|
||||
workflow: Workflow;
|
||||
credentialsSource: CredentialsSource;
|
||||
distribution: DistributionType;
|
||||
distribution: iOSDistributionType;
|
||||
nonInteractive: boolean;
|
||||
skipCredentialsCheck: boolean;
|
||||
}
|
||||
@@ -68,6 +68,6 @@ export async function resolveIosCredentialsAsync(
|
||||
};
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(ctx: BuildContext<Platform.iOS>): boolean {
|
||||
return true;
|
||||
function shouldProvideCredentials(ctx: BuildContext<Platform.IOS>): boolean {
|
||||
return ctx.buildProfile.distribution !== 'simulator';
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Env, Ios } from '@expo/eas-build-job';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import {
|
||||
DistributionType,
|
||||
IosGenericJobInput,
|
||||
IosJobSecretsInput,
|
||||
IosManagedBuildType,
|
||||
IosManagedJobInput,
|
||||
IosSchemeBuildConfiguration,
|
||||
} from '../../graphql/generated';
|
||||
import { transformProjectArchive } from '../graphql';
|
||||
|
||||
export function transformGenericJob(job: Ios.GenericJob): IosGenericJobInput {
|
||||
return {
|
||||
projectArchive: transformProjectArchive(job.projectArchive),
|
||||
projectRootDirectory: job.projectRootDirectory,
|
||||
releaseChannel: job.releaseChannel,
|
||||
distribution: job.distribution && transformDistributionType(job.distribution),
|
||||
secrets: transformIosSecrets(job.secrets),
|
||||
builderEnvironment: job.builderEnvironment,
|
||||
cache: job.cache,
|
||||
scheme: job.scheme,
|
||||
schemeBuildConfiguration:
|
||||
job.schemeBuildConfiguration &&
|
||||
transformSchemeBuildConfiguration(job.schemeBuildConfiguration),
|
||||
artifactPath: job.artifactPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformManagedJob(job: Ios.ManagedJob): IosManagedJobInput {
|
||||
return {
|
||||
projectArchive: transformProjectArchive(job.projectArchive),
|
||||
projectRootDirectory: job.projectRootDirectory,
|
||||
releaseChannel: job.releaseChannel,
|
||||
distribution: job.distribution && transformDistributionType(job.distribution),
|
||||
secrets: transformIosSecrets(job.secrets),
|
||||
builderEnvironment: job.builderEnvironment,
|
||||
cache: job.cache,
|
||||
buildType: job.buildType && transformBuildType(job.buildType),
|
||||
username: job.username,
|
||||
};
|
||||
}
|
||||
|
||||
function transformDistributionType(distributionType: Ios.DistributionType): DistributionType {
|
||||
if (distributionType === 'store') {
|
||||
return DistributionType.Store;
|
||||
} else if (distributionType === 'internal') {
|
||||
return DistributionType.Internal;
|
||||
} else {
|
||||
return DistributionType.Simulator;
|
||||
}
|
||||
}
|
||||
|
||||
function transformIosSecrets(secrets: {
|
||||
buildCredentials?: Ios.BuildCredentials;
|
||||
env?: Env;
|
||||
}): IosJobSecretsInput {
|
||||
const buildCredentials: IosJobSecretsInput['buildCredentials'] = [];
|
||||
|
||||
for (const targetName of Object.keys(secrets.buildCredentials ?? {})) {
|
||||
buildCredentials.push({
|
||||
targetName,
|
||||
provisioningProfileBase64: nullthrows(secrets.buildCredentials?.[targetName])
|
||||
.provisioningProfileBase64,
|
||||
distributionCertificate: nullthrows(secrets.buildCredentials?.[targetName])
|
||||
.distributionCertificate,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
buildCredentials,
|
||||
environmentSecrets: secrets.env,
|
||||
};
|
||||
}
|
||||
|
||||
function transformSchemeBuildConfiguration(
|
||||
schemeBuildConfiguration: Ios.SchemeBuildConfiguration
|
||||
): IosSchemeBuildConfiguration {
|
||||
if (schemeBuildConfiguration === Ios.SchemeBuildConfiguration.DEBUG) {
|
||||
return IosSchemeBuildConfiguration.Debug;
|
||||
} else {
|
||||
return IosSchemeBuildConfiguration.Release;
|
||||
}
|
||||
}
|
||||
|
||||
function transformBuildType(buildType: Ios.ManagedBuildType): IosManagedBuildType {
|
||||
if (buildType === Ios.ManagedBuildType.DEVELOPMENT_CLIENT) {
|
||||
return IosManagedBuildType.DevelopmentClient;
|
||||
} else {
|
||||
return IosManagedBuildType.Release;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceType,
|
||||
Cache,
|
||||
Ios,
|
||||
Job,
|
||||
Workflow,
|
||||
iOS,
|
||||
sanitizeJob,
|
||||
} from '@expo/eas-build-job';
|
||||
import { iOSGenericBuildProfile, iOSManagedBuildProfile } from '@expo/eas-json';
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
IosCredentials,
|
||||
IosTargetCredentials,
|
||||
isCredentialsMap,
|
||||
readSecretEnvsAsync,
|
||||
readEnvironmentSecretsAsync,
|
||||
} from '../../credentials/credentialsJson/read';
|
||||
import { getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { gitRootDirectoryAsync } from '../../utils/git';
|
||||
@@ -31,13 +32,13 @@ interface JobData {
|
||||
}
|
||||
|
||||
export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
jobData: JobData
|
||||
): Promise<Job> {
|
||||
if (ctx.buildProfile.workflow === Workflow.Generic) {
|
||||
if (ctx.buildProfile.workflow === Workflow.GENERIC) {
|
||||
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
|
||||
return sanitizeJob(partialJob);
|
||||
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
|
||||
} else if (ctx.buildProfile.workflow === Workflow.MANAGED) {
|
||||
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
|
||||
return sanitizeJob(partialJob);
|
||||
} else {
|
||||
@@ -46,25 +47,27 @@ export async function prepareJobAsync(
|
||||
}
|
||||
|
||||
interface CommonJobProperties {
|
||||
platform: Platform.iOS;
|
||||
platform: Platform.IOS;
|
||||
projectArchive: ArchiveSource;
|
||||
builderEnvironment: iOS.BuilderEnvironment;
|
||||
releaseChannel: string;
|
||||
builderEnvironment: Ios.BuilderEnvironment;
|
||||
releaseChannel?: string;
|
||||
distribution?: Ios.DistributionType;
|
||||
cache: Cache;
|
||||
secrets: {
|
||||
buildCredentials: iOS.BuildCredentials;
|
||||
secretEnvs?: Record<string, string>;
|
||||
buildCredentials: Ios.BuildCredentials;
|
||||
environmentSecrets?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareJobCommonAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
{
|
||||
archiveBucketKey,
|
||||
credentials,
|
||||
targetName,
|
||||
}: { archiveBucketKey: string; credentials?: IosCredentials; targetName?: string }
|
||||
): Promise<Partial<CommonJobProperties>> {
|
||||
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
|
||||
const environmentSecrets = await readEnvironmentSecretsAsync(ctx.commandCtx.projectDir);
|
||||
|
||||
let buildCredentials: CommonJobProperties['secrets']['buildCredentials'] = {};
|
||||
if (credentials && isCredentialsMap(credentials)) {
|
||||
@@ -83,27 +86,30 @@ async function prepareJobCommonAsync(
|
||||
}
|
||||
|
||||
return {
|
||||
platform: Platform.iOS,
|
||||
platform: Platform.IOS,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.S3,
|
||||
bucketKey: archiveBucketKey,
|
||||
},
|
||||
distribution: ctx.buildProfile.distribution,
|
||||
builderEnvironment: {
|
||||
image: ctx.buildProfile.image,
|
||||
node: ctx.buildProfile.node,
|
||||
yarn: ctx.buildProfile.yarn,
|
||||
bundler: ctx.buildProfile.bundler,
|
||||
cocoapods: ctx.buildProfile.cocoapods,
|
||||
fastlane: ctx.buildProfile.fastlane,
|
||||
env: ctx.buildProfile.env,
|
||||
},
|
||||
cache: ctx.buildProfile.cache,
|
||||
secrets: {
|
||||
...(secretEnvs ? { secretEnvs } : {}),
|
||||
...(environmentSecrets ? { environmentSecrets } : {}),
|
||||
buildCredentials,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function prepareTargetCredentials(targetCredentials: IosTargetCredentials): iOS.TargetCredentials {
|
||||
function prepareTargetCredentials(targetCredentials: IosTargetCredentials): Ios.TargetCredentials {
|
||||
return {
|
||||
provisioningProfileBase64: targetCredentials.provisioningProfile,
|
||||
distributionCertificate: {
|
||||
@@ -114,10 +120,10 @@ function prepareTargetCredentials(targetCredentials: IosTargetCredentials): iOS.
|
||||
}
|
||||
|
||||
async function prepareGenericJobAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
jobData: JobData,
|
||||
buildProfile: iOSGenericBuildProfile
|
||||
): Promise<Partial<iOS.GenericJob>> {
|
||||
): Promise<Partial<Ios.GenericJob>> {
|
||||
const projectRootDirectory =
|
||||
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
|
||||
return {
|
||||
@@ -126,9 +132,12 @@ async function prepareGenericJobAsync(
|
||||
credentials: jobData.credentials,
|
||||
targetName: jobData.projectConfiguration.iosApplicationTarget,
|
||||
})),
|
||||
type: Workflow.Generic,
|
||||
type: Workflow.GENERIC,
|
||||
scheme: jobData.projectConfiguration.iosBuildScheme,
|
||||
schemeBuildConfiguration: buildProfile.schemeBuildConfiguration,
|
||||
schemeBuildConfiguration:
|
||||
buildProfile.schemeBuildConfiguration === 'Auto'
|
||||
? undefined
|
||||
: buildProfile.schemeBuildConfiguration,
|
||||
artifactPath: buildProfile.artifactPath,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
projectRootDirectory,
|
||||
@@ -136,21 +145,20 @@ async function prepareGenericJobAsync(
|
||||
}
|
||||
|
||||
async function prepareManagedJobAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
jobData: JobData,
|
||||
buildProfile: iOSManagedBuildProfile
|
||||
): Promise<Partial<iOS.ManagedJob>> {
|
||||
): Promise<Partial<Ios.ManagedJob>> {
|
||||
const projectRootDirectory =
|
||||
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
|
||||
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
|
||||
const targetName = sanitizedName(ctx.commandCtx.exp.name);
|
||||
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.exp);
|
||||
return {
|
||||
...(await prepareJobCommonAsync(ctx, {
|
||||
archiveBucketKey: jobData.archiveBucketKey,
|
||||
credentials: jobData.credentials,
|
||||
targetName,
|
||||
targetName: jobData.projectConfiguration.iosApplicationTarget,
|
||||
})),
|
||||
type: Workflow.Managed,
|
||||
type: Workflow.MANAGED,
|
||||
buildType: buildProfile.buildType,
|
||||
username: accountName,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
@@ -160,7 +168,7 @@ async function prepareManagedJobAsync(
|
||||
|
||||
// copy-pasted from expo-cli/packages/xdl/src/Exp.ts
|
||||
// it's used in eject
|
||||
function sanitizedName(name: string) {
|
||||
export function sanitizedTargetName(name: string) {
|
||||
return name
|
||||
.replace(/[\W_]+/g, '')
|
||||
.normalize('NFD')
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { CredentialsSource, DistributionType } from '@expo/eas-json';
|
||||
import { Metadata } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
|
||||
import { getAppIdentifierAsync } from '../project/projectUtils';
|
||||
import { gitCommitHashAsync } from '../utils/git';
|
||||
import { readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync } from './android/UpdatesModule';
|
||||
import { BuildContext } from './context';
|
||||
import { BuildMetadata, Platform } from './types';
|
||||
import { readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync } from './ios/UpdatesModule';
|
||||
import { Platform } from './types';
|
||||
import { isExpoUpdatesInstalled } from './utils/updates';
|
||||
|
||||
/**
|
||||
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
|
||||
@@ -18,17 +23,44 @@ export async function collectMetadata<T extends Platform>(
|
||||
}: {
|
||||
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
|
||||
}
|
||||
): Promise<BuildMetadata> {
|
||||
): Promise<Metadata> {
|
||||
return {
|
||||
trackingContext: ctx.trackingCtx,
|
||||
appVersion: ctx.commandCtx.exp.version!,
|
||||
cliVersion: packageJSON.version,
|
||||
workflow: ctx.buildProfile.workflow,
|
||||
credentialsSource,
|
||||
sdkVersion: ctx.commandCtx.exp.sdkVersion,
|
||||
trackingContext: ctx.trackingCtx,
|
||||
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
|
||||
releaseChannel: await resolveReleaseChannel(ctx),
|
||||
distribution: ctx.buildProfile.distribution ?? 'store',
|
||||
appName: ctx.commandCtx.exp.name,
|
||||
appIdentifier:
|
||||
(await getAppIdentifierAsync(ctx.commandCtx.projectDir, ctx.platform)) ?? undefined,
|
||||
(await getAppIdentifierAsync({
|
||||
projectDir: ctx.commandCtx.projectDir,
|
||||
platform: ctx.platform,
|
||||
exp: ctx.commandCtx.exp,
|
||||
})) ?? undefined,
|
||||
buildProfile: ctx.commandCtx.profile,
|
||||
gitCommitHash: await gitCommitHashAsync(),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveReleaseChannel<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
): Promise<string | undefined> {
|
||||
if (!isExpoUpdatesInstalled(ctx.commandCtx.projectDir)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (ctx.buildProfile.releaseChannel) {
|
||||
return ctx.buildProfile.releaseChannel;
|
||||
}
|
||||
|
||||
let maybeReleaseChannel: string | null;
|
||||
if (ctx.platform === Platform.ANDROID) {
|
||||
maybeReleaseChannel = await readAndroidReleaseChannelSafelyAsync(ctx.commandCtx.projectDir);
|
||||
} else {
|
||||
maybeReleaseChannel = await readIosReleaseChannelSafelyAsync(ctx.commandCtx.projectDir);
|
||||
}
|
||||
return maybeReleaseChannel ?? 'default';
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import {
|
||||
AndroidBuildProfile,
|
||||
CredentialsSource,
|
||||
DistributionType,
|
||||
iOSBuildProfile,
|
||||
} from '@expo/eas-json';
|
||||
export { Platform } from '@expo/eas-build-job';
|
||||
|
||||
export enum RequestedPlatform {
|
||||
Android = 'android',
|
||||
@@ -12,8 +6,6 @@ export enum RequestedPlatform {
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export { Platform };
|
||||
|
||||
export enum BuildStatus {
|
||||
IN_QUEUE = 'in-queue',
|
||||
IN_PROGRESS = 'in-progress',
|
||||
@@ -23,103 +15,3 @@ export enum BuildStatus {
|
||||
}
|
||||
|
||||
export type TrackingContext = Record<string, string | number>;
|
||||
|
||||
export interface Build {
|
||||
id: string;
|
||||
status: BuildStatus;
|
||||
platform: Platform;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
artifacts?: BuildArtifacts;
|
||||
metadata?: Partial<BuildMetadata>;
|
||||
}
|
||||
|
||||
interface BuildArtifacts {
|
||||
buildUrl?: string;
|
||||
logsUrl: string;
|
||||
}
|
||||
|
||||
export type BuildMetadata = {
|
||||
/**
|
||||
* Application version (the expo.version key in app.json/app.config.js)
|
||||
*/
|
||||
appVersion: string;
|
||||
|
||||
/**
|
||||
* EAS CLI version
|
||||
*/
|
||||
cliVersion: string;
|
||||
|
||||
/**
|
||||
* Build workflow
|
||||
* It's either 'generic' or 'managed'
|
||||
*/
|
||||
workflow: Workflow;
|
||||
|
||||
/**
|
||||
* Credentials source
|
||||
* Credentials could be obtained either from credential.json or EAS servers.
|
||||
*/
|
||||
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
|
||||
|
||||
/**
|
||||
* Expo SDK version
|
||||
* It's determined by the expo package version in package.json.
|
||||
* It's undefined if the expo package is not installed for the project.
|
||||
*/
|
||||
sdkVersion?: string;
|
||||
|
||||
/**
|
||||
* Release channel (for expo-updates)
|
||||
* It's undefined if the expo-updates package is not installed for the project.
|
||||
*/
|
||||
releaseChannel?: string;
|
||||
|
||||
/**
|
||||
* Tracking context
|
||||
* It's used to track build process across different Expo services and tools.
|
||||
*/
|
||||
trackingContext: TrackingContext;
|
||||
|
||||
/**
|
||||
* Distribution type
|
||||
* Indicates whether this is a build for store or internal distribution.
|
||||
*/
|
||||
distribution: DistributionType;
|
||||
|
||||
/**
|
||||
* App name (expo.name in app.json/app.config.js)
|
||||
*/
|
||||
appName?: string;
|
||||
|
||||
/**
|
||||
* App identifier:
|
||||
* - iOS builds: the bundle identifier (expo.ios.bundleIdentifier in app.json/app.config.js)
|
||||
* - Android builds: the application id (expo.android.package in app.json/app.config.js)
|
||||
*/
|
||||
appIdentifier?: string;
|
||||
};
|
||||
|
||||
export type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
|
||||
? AndroidBuildProfile
|
||||
: iOSBuildProfile;
|
||||
|
||||
export enum AnalyticsEvent {
|
||||
BUILD_COMMAND = 'builds cli build command',
|
||||
PROJECT_UPLOAD_SUCCESS = 'builds cli project upload success',
|
||||
PROJECT_UPLOAD_FAIL = 'builds cli project upload fail',
|
||||
GATHER_CREDENTIALS_SUCCESS = 'builds cli gather credentials success',
|
||||
GATHER_CREDENTIALS_FAIL = 'builds cli gather credentials fail',
|
||||
CONFIGURE_PROJECT_SUCCESS = 'builds cli configure project success',
|
||||
CONFIGURE_PROJECT_FAIL = 'builds cli configure project fail',
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'builds cli build request fail',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'builds cli build status',
|
||||
|
||||
CREDENTIALS_SYNC_COMMAND = 'builds cli credentials sync command',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'builds cli credentials sync update local success',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'builds cli credentials sync update local fail',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'builds cli credentials sync update remote success',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'builds cli credentials sync update remote fail',
|
||||
}
|
||||
@@ -1,3 +1,27 @@
|
||||
import { logEvent } from '../../analytics';
|
||||
|
||||
export enum Event {
|
||||
BUILD_COMMAND = 'build cli build command',
|
||||
PROJECT_UPLOAD_SUCCESS = 'build cli project upload success',
|
||||
PROJECT_UPLOAD_FAIL = 'build cli project upload fail',
|
||||
GATHER_CREDENTIALS_SUCCESS = 'build cli gather credentials success',
|
||||
GATHER_CREDENTIALS_FAIL = 'build cli gather credentials fail',
|
||||
CONFIGURE_PROJECT_SUCCESS = 'build cli configure project success',
|
||||
CONFIGURE_PROJECT_FAIL = 'build cli configure project fail',
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
CREDENTIALS_SYNC_COMMAND = 'build cli credentials sync command',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'build cli credentials sync update local success',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'build cli credentials sync update local fail',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'build cli credentials sync update remote success',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',
|
||||
}
|
||||
|
||||
export default {
|
||||
logEvent: (a: any, b: any) => {},
|
||||
logEvent(name: Event, properties: Record<string, any>) {
|
||||
logEvent(name, properties);
|
||||
},
|
||||
};
|
||||
@@ -1,57 +1,66 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { BuildFragment, BuildStatus as GraphQLBuildStatus } from '../../graphql/generated';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { platformDisplayNames } from '../constants';
|
||||
import { Build, BuildStatus } from '../types';
|
||||
import { appPlatformDisplayNames } from '../constants';
|
||||
import { getBuildLogsUrl } from './url';
|
||||
|
||||
interface Options {
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
export default function formatBuild(build: Build, { accountName }: Options) {
|
||||
export function formatGraphQLBuild(build: BuildFragment) {
|
||||
const actor = getActorName(build);
|
||||
const account = build.project.__typename === 'App' ? build.project.ownerAccount.name : 'unknown';
|
||||
const fields: { label: string; value: string }[] = [
|
||||
{ label: 'ID', value: build.id },
|
||||
{
|
||||
label: 'Platform',
|
||||
value: platformDisplayNames[build.platform],
|
||||
value: appPlatformDisplayNames[build.platform],
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case BuildStatus.IN_QUEUE:
|
||||
case GraphQLBuildStatus.InQueue:
|
||||
return chalk.blue('in queue');
|
||||
case BuildStatus.IN_PROGRESS:
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return chalk.blue('in progress');
|
||||
case BuildStatus.CANCELED:
|
||||
case GraphQLBuildStatus.Canceled:
|
||||
return chalk.gray('canceled');
|
||||
case BuildStatus.FINISHED:
|
||||
case GraphQLBuildStatus.Finished:
|
||||
return chalk.green('finished');
|
||||
case BuildStatus.ERRORED:
|
||||
case GraphQLBuildStatus.Errored:
|
||||
return chalk.red('errored');
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Distribution',
|
||||
value: build.distribution?.toLowerCase() ?? chalk.gray('unknown'),
|
||||
},
|
||||
{
|
||||
label: 'Release Channel',
|
||||
value: build.releaseChannel ?? chalk.gray('unknown'),
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
value: getBuildLogsUrl({ buildId: build.id, account: accountName }),
|
||||
value: getBuildLogsUrl({ buildId: build.id, account }),
|
||||
},
|
||||
{
|
||||
label: 'Artifact',
|
||||
get value() {
|
||||
if (build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS) {
|
||||
if (
|
||||
build.status === GraphQLBuildStatus.InQueue ||
|
||||
build.status === GraphQLBuildStatus.InProgress
|
||||
) {
|
||||
}
|
||||
switch (build.status) {
|
||||
case BuildStatus.IN_QUEUE:
|
||||
case BuildStatus.IN_PROGRESS:
|
||||
case GraphQLBuildStatus.InQueue:
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return '<in progress>';
|
||||
case BuildStatus.CANCELED:
|
||||
case BuildStatus.ERRORED:
|
||||
case GraphQLBuildStatus.Canceled:
|
||||
case GraphQLBuildStatus.Errored:
|
||||
return '---------';
|
||||
case BuildStatus.FINISHED: {
|
||||
case GraphQLBuildStatus.Finished: {
|
||||
const url = build.artifacts?.buildUrl;
|
||||
return url ? url : chalk.red('not found');
|
||||
}
|
||||
@@ -64,13 +73,23 @@ export default function formatBuild(build: Build, { accountName }: Options) {
|
||||
{
|
||||
label: 'Finished at',
|
||||
value:
|
||||
build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS
|
||||
build.status === GraphQLBuildStatus.InQueue ||
|
||||
build.status === GraphQLBuildStatus.InProgress
|
||||
? '<in progress>'
|
||||
: new Date(build.updatedAt).toLocaleString(),
|
||||
},
|
||||
// TODO: we can do it once we migrate to GraphQL
|
||||
// { label: 'Started by', get: build => 'TODO' },
|
||||
{ label: 'Started by', value: actor ?? 'unknown' },
|
||||
];
|
||||
|
||||
return formatFields(fields);
|
||||
}
|
||||
|
||||
const getActorName = (build: BuildFragment): string => {
|
||||
if (!build.initiatingActor) {
|
||||
return 'unknown';
|
||||
} else if (build.initiatingActor.__typename === 'User') {
|
||||
return build.initiatingActor.username;
|
||||
} else {
|
||||
return build.initiatingActor.firstName ?? 'unknown';
|
||||
}
|
||||
};
|
||||
@@ -1,17 +1,22 @@
|
||||
import { DistributionType } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import Log from '../../log';
|
||||
import { platformDisplayNames, platformEmojis } from '../constants';
|
||||
import { Build } from '../types';
|
||||
import {
|
||||
BuildError,
|
||||
BuildFragment,
|
||||
BuildStatus,
|
||||
DistributionType,
|
||||
EasBuildDeprecationInfo,
|
||||
EasBuildDeprecationInfoType,
|
||||
} from '../../graphql/generated';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import {
|
||||
appPlatformDisplayNames,
|
||||
appPlatformEmojis,
|
||||
requestedPlatformDisplayNames,
|
||||
} from '../constants';
|
||||
import { getBuildLogsUrl } from './url';
|
||||
|
||||
export interface DeprecationInfo {
|
||||
type: 'user-facing' | 'internal';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function printLogsUrls(
|
||||
accountName: string,
|
||||
builds: { platform: 'android' | 'ios'; buildId: string }[]
|
||||
@@ -29,55 +34,78 @@ export function printLogsUrls(
|
||||
buildId,
|
||||
account: accountName,
|
||||
});
|
||||
Log.log(`${platformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`);
|
||||
Log.log(
|
||||
`${requestedPlatformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function printBuildResults(accountName: string, builds: (Build | null)[]): void {
|
||||
export function printBuildResults(accountName: string, builds: (BuildFragment | null)[]): void {
|
||||
Log.newLine();
|
||||
if (builds.length === 1) {
|
||||
const [build] = builds;
|
||||
assert(build, 'Build should be defined');
|
||||
printBuildResult(accountName, build);
|
||||
} else {
|
||||
(builds.filter(i => i) as Build[])
|
||||
.filter(build => build.status === 'finished')
|
||||
.forEach(build => printBuildResult(accountName, build));
|
||||
(builds.filter(i => i) as BuildFragment[]).forEach(build =>
|
||||
printBuildResult(accountName, build)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printBuildResult(accountName: string, build: Build): void {
|
||||
if (build.metadata?.distribution === DistributionType.INTERNAL) {
|
||||
function printBuildResult(accountName: string, build: BuildFragment): void {
|
||||
Log.addNewLineIfNone();
|
||||
if (build.status === BuildStatus.Errored) {
|
||||
const userError = build.error;
|
||||
Log.error(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} build failed${userError ? ':' : ''}`
|
||||
);
|
||||
if (userError) {
|
||||
printUserError(userError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (build.status === BuildStatus.Canceled) {
|
||||
Log.error(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} build was canceled`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (build.distribution === DistributionType.Internal) {
|
||||
const logsUrl = getBuildLogsUrl({
|
||||
buildId: build.id,
|
||||
account: accountName,
|
||||
});
|
||||
Log.log(
|
||||
`${platformEmojis[build.platform]} Open this link on your ${
|
||||
platformDisplayNames[build.platform]
|
||||
`${appPlatformEmojis[build.platform]} Open this link on your ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} devices to install the app:`
|
||||
);
|
||||
Log.log(`${chalk.underline(logsUrl)}`);
|
||||
Log.newLine();
|
||||
} else {
|
||||
// TODO: it looks like buildUrl could possibly be undefined, based on the code below.
|
||||
// we should account for this case better if it is possible
|
||||
const url = build.artifacts?.buildUrl ?? '';
|
||||
Log.log(`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} app:`);
|
||||
Log.log(`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`);
|
||||
Log.log(`${chalk.underline(url)}`);
|
||||
Log.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): void {
|
||||
export function printDeprecationWarnings(deprecationInfo?: EasBuildDeprecationInfo | null): void {
|
||||
if (!deprecationInfo) {
|
||||
return;
|
||||
}
|
||||
if (deprecationInfo.type === 'internal') {
|
||||
if (deprecationInfo.type === EasBuildDeprecationInfoType.Internal) {
|
||||
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
|
||||
Log.warn("Changes won't affect your project config.");
|
||||
Log.warn(deprecationInfo.message);
|
||||
} else if (deprecationInfo.type === 'user-facing') {
|
||||
} else if (deprecationInfo.type === EasBuildDeprecationInfoType.UserFacing) {
|
||||
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
|
||||
Log.warn(
|
||||
'There might be some changes necessary to your project config, latest eas-cli will provide more specific error messages.'
|
||||
@@ -88,3 +116,10 @@ export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): voi
|
||||
Log.warn(deprecationInfo);
|
||||
}
|
||||
}
|
||||
|
||||
export function printUserError(error: BuildError): void {
|
||||
Log.error(error.message);
|
||||
if (error.docsUrl) {
|
||||
Log.error(learnMore(error.docsUrl, { dim: false }));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import Log from '../../log';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { formatBytes } from '../../utils/files';
|
||||
import {
|
||||
doesGitRepoExistAsync,
|
||||
getGitDiffOutputAsync,
|
||||
@@ -43,12 +44,11 @@ async function ensureGitRepoExistsAsync(): Promise<void> {
|
||||
|
||||
Log.log("We're going to make an initial commit for you repository.");
|
||||
|
||||
await spawnAsync('git', ['add', '-A']);
|
||||
await commitPromptAsync('Initial commit');
|
||||
await commitPromptAsync({ initialCommitMessage: 'Initial commit', commitAllFiles: true });
|
||||
}
|
||||
|
||||
async function isGitStatusCleanAsync(): Promise<boolean> {
|
||||
const changes = await gitStatusAsync();
|
||||
const changes = await gitStatusAsync({ showUntracked: true });
|
||||
return changes.length === 0;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void
|
||||
message: `Commit changes to git?`,
|
||||
});
|
||||
if (answer) {
|
||||
await commitPromptAsync();
|
||||
await commitPromptAsync({ commitAllFiles: true });
|
||||
} else {
|
||||
throw new Error('Please commit all changes. Aborting...');
|
||||
}
|
||||
@@ -120,7 +120,6 @@ async function makeProjectTarballAsync(): Promise<{ path: string; size: number }
|
||||
try {
|
||||
await spawnAsync('git', [
|
||||
'clone',
|
||||
'--local',
|
||||
'--no-hardlinks',
|
||||
'--depth',
|
||||
'1',
|
||||
@@ -130,19 +129,26 @@ async function makeProjectTarballAsync(): Promise<{ path: string; size: number }
|
||||
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
|
||||
'.',
|
||||
]);
|
||||
} finally {
|
||||
// Stop the timer
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
await fs.remove(shallowClonePath);
|
||||
|
||||
const duration = endTimer(compressTimerLabel);
|
||||
if (spinner.isSpinning) {
|
||||
const prettyTime = formatMilliseconds(duration);
|
||||
spinner.succeed(`Compressed project files ${chalk.dim(prettyTime)}`);
|
||||
spinner.fail();
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await fs.remove(shallowClonePath);
|
||||
}
|
||||
clearTimeout(timer);
|
||||
|
||||
const { size } = await fs.stat(tarPath);
|
||||
const duration = endTimer(compressTimerLabel);
|
||||
if (spinner.isSpinning) {
|
||||
const prettyTime = formatMilliseconds(duration);
|
||||
spinner.succeed(
|
||||
`Compressed project files ${chalk.dim(`${prettyTime} (${formatBytes(size)})`)}`
|
||||
);
|
||||
}
|
||||
|
||||
return { size, path: tarPath };
|
||||
}
|
||||
|
||||
@@ -151,7 +157,13 @@ async function showDiffAsync() {
|
||||
await gitDiffAsync({ withPager: outputTooLarge });
|
||||
}
|
||||
|
||||
async function commitPromptAsync(initialCommitMessage?: string): Promise<void> {
|
||||
async function commitPromptAsync({
|
||||
initialCommitMessage,
|
||||
commitAllFiles,
|
||||
}: {
|
||||
initialCommitMessage?: string;
|
||||
commitAllFiles?: boolean;
|
||||
} = {}): Promise<void> {
|
||||
const { message } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'message',
|
||||
@@ -159,6 +171,9 @@ async function commitPromptAsync(initialCommitMessage?: string): Promise<void> {
|
||||
initial: initialCommitMessage,
|
||||
validate: (input: string) => input !== '',
|
||||
});
|
||||
if (commitAllFiles) {
|
||||
await spawnAsync('git', ['add', '-A']);
|
||||
}
|
||||
await commitChangedFilesAsync(message);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { Command } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import Log from '../../log';
|
||||
import { getUserAsync } from '../../user/User';
|
||||
import { getActorDisplayName } from '../../user/actions';
|
||||
import { getActorDisplayName, getUserAsync } from '../../user/User';
|
||||
|
||||
export default class AccountView extends Command {
|
||||
static description = 'show the username you are logged in as';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import Log from '../log';
|
||||
import UserSettings from '../user/UserSettings';
|
||||
|
||||
export default class AnalyticsView extends Command {
|
||||
static description = 'View or change analytics settings';
|
||||
|
||||
static args = [{ name: 'STATUS', options: ['on', 'off'] }];
|
||||
|
||||
async run() {
|
||||
const { STATUS: status } = this.parse(AnalyticsView).args;
|
||||
if (status) {
|
||||
await UserSettings.setAsync('amplitudeEnabled', status === 'on');
|
||||
Log.withTick(`${status === 'on' ? 'Enabling' : 'Disabling'} analytics.`);
|
||||
} else {
|
||||
const amplitudeEnabled = await UserSettings.getAsync('amplitudeEnabled', null);
|
||||
Log.log(
|
||||
`Analytics are ${
|
||||
amplitudeEnabled === false ? 'disabled' : 'enabled'
|
||||
} on this eas-cli installation.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { UpdateBranch } from '../../graphql/generated';
|
||||
import {
|
||||
CreateUpdateBranchForAppMutation,
|
||||
CreateUpdateBranchForAppMutationVariables,
|
||||
UpdateBranch,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
|
||||
@@ -14,35 +18,33 @@ import { getBranchNameAsync } from '../../utils/git';
|
||||
|
||||
export async function createUpdateBranchOnAppAsync({
|
||||
appId,
|
||||
branchName,
|
||||
}: {
|
||||
appId: string;
|
||||
branchName: string;
|
||||
}): Promise<Pick<UpdateBranch, 'id' | 'branchName'>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
name,
|
||||
}: CreateUpdateBranchForAppMutationVariables): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
|
||||
const result = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ updateBranch: { createUpdateBranchForApp: Pick<UpdateBranch, 'id' | 'branchName'> } },
|
||||
{ appId: string; branchName: string }
|
||||
>(
|
||||
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
|
||||
gql`
|
||||
mutation createUpdateBranchForApp($appId: ID!, $branchName: String!) {
|
||||
mutation createUpdateBranchForApp($appId: ID!, $name: String!) {
|
||||
updateBranch {
|
||||
createUpdateBranchForApp(appId: $appId, branchName: $branchName) {
|
||||
createUpdateBranchForApp(appId: $appId, name: $name) {
|
||||
id
|
||||
branchName
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
name,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateBranch.createUpdateBranchForApp;
|
||||
const newBranch = result.updateBranch.createUpdateBranchForApp;
|
||||
if (!newBranch) {
|
||||
throw new Error(`Could not create branch ${name}.`);
|
||||
}
|
||||
return newBranch;
|
||||
}
|
||||
|
||||
export default class BranchCreate extends Command {
|
||||
@@ -51,7 +53,7 @@ export default class BranchCreate extends Command {
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'branchName',
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the branch to create',
|
||||
},
|
||||
@@ -66,7 +68,7 @@ export default class BranchCreate extends Command {
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { branchName },
|
||||
args: { name },
|
||||
flags,
|
||||
} = this.parse(BranchCreate);
|
||||
|
||||
@@ -82,14 +84,14 @@ export default class BranchCreate extends Command {
|
||||
projectName: exp.slug,
|
||||
});
|
||||
|
||||
if (!branchName) {
|
||||
if (!name) {
|
||||
const validationMessage = 'Branch name may not be empty.';
|
||||
if (flags.json) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ branchName } = await promptAsync({
|
||||
({ name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'branchName',
|
||||
name: 'name',
|
||||
message: 'Please name the branch:',
|
||||
initial:
|
||||
(await getBranchNameAsync()) || `branch-${Math.random().toString(36).substr(2, 4)}`,
|
||||
@@ -97,7 +99,7 @@ export default class BranchCreate extends Command {
|
||||
}));
|
||||
}
|
||||
|
||||
const newBranch = await createUpdateBranchOnAppAsync({ appId: projectId, branchName });
|
||||
const newBranch = await createUpdateBranchOnAppAsync({ appId: projectId, name });
|
||||
|
||||
if (flags.json) {
|
||||
Log.log(newBranch);
|
||||
@@ -105,7 +107,7 @@ export default class BranchCreate extends Command {
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Created a new branch: ${chalk.bold(newBranch.branchName)} on project ${chalk.bold(
|
||||
`️Created a new branch: ${chalk.bold(newBranch.name)} on project ${chalk.bold(
|
||||
`@${accountName}/${exp.slug}`
|
||||
)}.`
|
||||
);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
DeleteUpdateBranchMutation,
|
||||
DeleteUpdateBranchMutationVariables,
|
||||
DeleteUpdateBranchResult,
|
||||
GetBranchInfoQuery,
|
||||
GetBranchInfoQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../prompts';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
async function getBranchInfoAsync({
|
||||
appId,
|
||||
name,
|
||||
}: GetBranchInfoQueryVariables): Promise<{ branchId: string }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetBranchInfoQuery, GetBranchInfoQueryVariables>(
|
||||
gql`
|
||||
query GetBranchInfo($appId: String!, $name: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateBranchByName(name: $name) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
name,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const branchId = data.app?.byId.updateBranchByName.id;
|
||||
if (!branchId) {
|
||||
throw new Error(`Could not find branch ${name} on ${appId}`);
|
||||
}
|
||||
return { branchId };
|
||||
}
|
||||
|
||||
async function deleteBranchOnAppAsync({
|
||||
branchId,
|
||||
}: DeleteUpdateBranchMutationVariables): Promise<DeleteUpdateBranchResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeleteUpdateBranchMutation, DeleteUpdateBranchMutationVariables>(
|
||||
gql`
|
||||
mutation DeleteUpdateBranch($branchId: ID!) {
|
||||
updateBranch {
|
||||
deleteUpdateBranch(branchId: $branchId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
branchId,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateBranch.deleteUpdateBranch;
|
||||
}
|
||||
|
||||
export default class BranchDelete extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Republish an update group';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the branch to delete',
|
||||
},
|
||||
];
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: `return JSON with the edited branch's ID and name.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { name },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(BranchDelete);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: exp.slug,
|
||||
});
|
||||
|
||||
if (!name) {
|
||||
const validationMessage = 'branch name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Please enter the name of the branch to delete:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const { branchId } = await getBranchInfoAsync({ appId: projectId, name });
|
||||
|
||||
if (!jsonFlag) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`You are about to permamently delete branch: "${name}" and all of the updates published on it.` +
|
||||
`\nThis action is irreversible.`
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
|
||||
if (!confirmed) {
|
||||
Log.error(`Cancelled deletion of branch: "${name}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const deletionResult = await deleteBranchOnAppAsync({
|
||||
branchId,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(deletionResult));
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Deleted branch "${name}" and all of its updates on project ${chalk.bold(
|
||||
`@${accountName}/${exp.slug}`
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,66 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import { CLIError } from '@oclif/errors';
|
||||
import CliTable from 'cli-table3';
|
||||
import gql from 'graphql-tag';
|
||||
import { format } from 'timeago.js';
|
||||
|
||||
import { graphqlClient } from '../../graphql/client';
|
||||
import { RootQuery, Update, UpdateBranch } from '../../graphql/generated';
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
BranchesByAppQuery,
|
||||
BranchesByAppQueryVariables,
|
||||
Maybe,
|
||||
Robot,
|
||||
Update,
|
||||
User,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, getProjectFullNameAsync } from '../../project/projectUtils';
|
||||
import { getActorDisplayName } from '../../user/actions';
|
||||
import { getActorDisplayName } from '../../user/User';
|
||||
|
||||
const BRANCHES_LIMIT = 10_000;
|
||||
export async function listBranchesAsync({ fullName }: { fullName: string }) {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<BranchesByAppQuery, BranchesByAppQueryVariables>(
|
||||
gql`
|
||||
query BranchesByAppQuery($fullName: String!, $limit: Int!) {
|
||||
app {
|
||||
byFullName(fullName: $fullName) {
|
||||
id
|
||||
fullName
|
||||
updateBranches(offset: 0, limit: $limit) {
|
||||
id
|
||||
name
|
||||
updates(offset: 0, limit: 1) {
|
||||
id
|
||||
actor {
|
||||
__typename
|
||||
id
|
||||
... on User {
|
||||
username
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
updatedAt
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
fullName,
|
||||
limit: BRANCHES_LIMIT,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data?.app?.byFullName.updateBranches ?? [];
|
||||
}
|
||||
|
||||
export default class BranchList extends Command {
|
||||
static hidden = true;
|
||||
@@ -31,81 +81,31 @@ export default class BranchList extends Command {
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const fullName = await getProjectFullNameAsync(projectDir);
|
||||
const branches = await this.listBranchesAsync({ fullName });
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const fullName = await getProjectFullNameAsync(exp);
|
||||
const branches = await listBranchesAsync({ fullName });
|
||||
if (flags.json) {
|
||||
Log.log(JSON.stringify(branches, null, 2));
|
||||
} else {
|
||||
const table = new CliTable({ head: ['Branch', 'Latest update'] });
|
||||
table.push(
|
||||
...branches.map((branch: UpdateBranch) => [
|
||||
branch.branchName,
|
||||
formatUpdate(branch.updates[0]),
|
||||
])
|
||||
);
|
||||
table.push(...branches.map(branch => [branch.name, formatUpdate(branch.updates[0])]));
|
||||
Log.log(table.toString());
|
||||
if (branches.length >= BRANCHES_LIMIT) {
|
||||
Log.warn(`Showing first ${BRANCHES_LIMIT} branches, some results might be omitted.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listBranchesAsync({ fullName }: { fullName: string }): Promise<UpdateBranch[]> {
|
||||
const { data, error } = await graphqlClient
|
||||
.query<RootQuery>(
|
||||
gql`
|
||||
query BranchesByAppQuery($fullName: String!, $limit: Int!) {
|
||||
app {
|
||||
byFullName(fullName: $fullName) {
|
||||
id
|
||||
fullName
|
||||
updateBranches(offset: 0, limit: $limit) {
|
||||
id
|
||||
branchName
|
||||
updates(offset: 0, limit: 1) {
|
||||
id
|
||||
actor {
|
||||
__typename
|
||||
id
|
||||
... on User {
|
||||
username
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
updatedAt
|
||||
updateMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
fullName,
|
||||
limit: BRANCHES_LIMIT,
|
||||
}
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
if (error) {
|
||||
if (error.networkError) {
|
||||
throw new CLIError(`Fetching branches failed: ${error.networkError.message}`);
|
||||
} else {
|
||||
throw new CLIError(error.graphQLErrors.map(e => e.message).join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
return data?.app?.byFullName.updateBranches ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdate(update: Update | undefined): string {
|
||||
export function formatUpdate(
|
||||
update: Pick<Update, 'id' | 'updatedAt' | 'message'> & {
|
||||
actor?: Maybe<Pick<User, 'username' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
|
||||
}
|
||||
): string {
|
||||
if (!update) {
|
||||
return 'N/A';
|
||||
}
|
||||
const message = update.updateMessage ? `"${update.updateMessage}" ` : '';
|
||||
const message = update.message ? `"${update.message}" ` : '';
|
||||
return `${message}(${format(update.updatedAt, 'en_US')} by ${getActorDisplayName(
|
||||
update.actor as any
|
||||
)})`;
|
||||
|
||||
@@ -1,58 +1,123 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import dateFormat from 'dateformat';
|
||||
import gql from 'graphql-tag';
|
||||
import { uniqBy } from 'lodash';
|
||||
import ora from 'ora';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
Actor,
|
||||
GetUpdateGroupAsyncQuery,
|
||||
RootQueryUpdatesByGroupArgs,
|
||||
Update,
|
||||
UpdateInfoGroup,
|
||||
} from '../../graphql/generated';
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getBranchByNameAsync,
|
||||
getProjectAccountNameAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { buildUpdateInfoGroupAsync, collectAssets, uploadAssetsAsync } from '../../project/publish';
|
||||
import { promptAsync } from '../../prompts';
|
||||
buildBundlesAsync,
|
||||
buildUpdateInfoGroupAsync,
|
||||
collectAssets,
|
||||
uploadAssetsAsync,
|
||||
} from '../../project/publish';
|
||||
import { promptAsync, selectAsync } from '../../prompts';
|
||||
import { getLastCommitMessageAsync } from '../../utils/git';
|
||||
import { formatUpdate, listBranchesAsync } from './list';
|
||||
import { viewUpdateBranchAsync } from './view';
|
||||
|
||||
type PublishPlatforms = 'android' | 'ios';
|
||||
|
||||
async function getUpdateGroupAsync({
|
||||
group,
|
||||
}: RootQueryUpdatesByGroupArgs): Promise<GetUpdateGroupAsyncQuery['updatesByGroup']> {
|
||||
const { updatesByGroup } = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetUpdateGroupAsyncQuery, RootQueryUpdatesByGroupArgs>(
|
||||
gql`
|
||||
query getUpdateGroupAsync($group: ID!) {
|
||||
updatesByGroup(group: $group) {
|
||||
id
|
||||
group
|
||||
runtimeVersion
|
||||
manifestFragment
|
||||
platform
|
||||
message
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
group,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return updatesByGroup;
|
||||
}
|
||||
|
||||
export default class BranchPublish extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Publish an update group to a branch.';
|
||||
|
||||
static flags = {
|
||||
'input-dir': flags.string({
|
||||
description: 'location of the bundle',
|
||||
default: 'dist',
|
||||
required: false,
|
||||
}),
|
||||
branch: flags.string({
|
||||
description: 'current name of the branch.',
|
||||
description: 'name of the branch to publish on.',
|
||||
}),
|
||||
message: flags.string({
|
||||
description: 'Short message describing the updates.',
|
||||
required: false,
|
||||
}),
|
||||
republish: flags.boolean({
|
||||
description: 'republish an update group',
|
||||
exclusive: ['input-dir', 'skip-bundler'],
|
||||
}),
|
||||
group: flags.string({
|
||||
description: 'update group to republish',
|
||||
exclusive: ['input-dir', 'skip-bundler'],
|
||||
}),
|
||||
'input-dir': flags.string({
|
||||
description: 'location of the bundle',
|
||||
default: 'dist',
|
||||
required: false,
|
||||
}),
|
||||
'skip-bundler': flags.boolean({
|
||||
description: `skip running Expo CLI to bundle the app before publishing`,
|
||||
default: false,
|
||||
}),
|
||||
json: flags.boolean({
|
||||
description: `return a json with the edited branch's ID and name.`,
|
||||
description: `return a json with the new update group.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
flags: { json: jsonFlag, branch: branchName, message: updateMessage, 'input-dir': inputDir },
|
||||
flags: {
|
||||
json: jsonFlag,
|
||||
branch: name,
|
||||
message,
|
||||
republish,
|
||||
group,
|
||||
'input-dir': inputDir,
|
||||
'skip-bundler': skipBundler,
|
||||
},
|
||||
} = this.parse(BranchPublish);
|
||||
// If a group was specified, that means we are republishing it.
|
||||
republish = group ? true : republish;
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const {
|
||||
exp: { slug, runtimeVersion },
|
||||
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
const { slug, runtimeVersion } = exp;
|
||||
if (!runtimeVersion) {
|
||||
throw new Error(
|
||||
"Couldn't find 'runtimeVersion'. Please specify it under the 'expo' key in 'app.json'"
|
||||
@@ -63,58 +128,110 @@ export default class BranchPublish extends Command {
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
if (!branchName) {
|
||||
if (!name) {
|
||||
const validationMessage = 'branch name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ branchName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'branchName',
|
||||
message: 'Please enter the name of the branch to publish on:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
|
||||
const branches = await listBranchesAsync({ fullName: `@${accountName}/${slug}` });
|
||||
name = await selectAsync<string>(
|
||||
'which branch would you like to publish on?',
|
||||
branches.map(branch => {
|
||||
return {
|
||||
title: `${branch.name} ${chalk.grey(
|
||||
`- current update: ${formatUpdate(branch.updates[0])}`
|
||||
)}`,
|
||||
value: branch.name,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
assert(name, 'branch name must be specified.');
|
||||
|
||||
const { id: branchId, updates } = await viewUpdateBranchAsync({
|
||||
appId: projectId,
|
||||
name,
|
||||
});
|
||||
|
||||
let updateInfoGroup: UpdateInfoGroup = {};
|
||||
let oldMessage: string, oldRuntimeVersion: string;
|
||||
if (republish) {
|
||||
// If we are republishing, we don't need to worry about building the bundle or uploading the assets.
|
||||
// Instead we get the `updateInfoGroup` from the update we wish to republish.
|
||||
let updatesToRepublish: Pick<
|
||||
Update,
|
||||
'group' | 'message' | 'runtimeVersion' | 'manifestFragment' | 'platform'
|
||||
>[];
|
||||
if (group) {
|
||||
updatesToRepublish = await getUpdateGroupAsync({ group });
|
||||
} else {
|
||||
const updateGroups = uniqBy(updates, u => u.group).map(update => ({
|
||||
title: formatUpdateTitle(update),
|
||||
value: update.group,
|
||||
}));
|
||||
if (updateGroups.length === 0) {
|
||||
throw new Error(
|
||||
`There are no updates on branch "${name}". Did you mean to do a regular publish?`
|
||||
);
|
||||
}
|
||||
const updateGroup = await selectAsync<string>(
|
||||
'which update would you like to republish?',
|
||||
updateGroups
|
||||
);
|
||||
updatesToRepublish = updates.filter(update => update.group === updateGroup);
|
||||
}
|
||||
|
||||
for (const update of updatesToRepublish) {
|
||||
const { platform, manifestFragment } = update;
|
||||
updateInfoGroup[platform as PublishPlatforms] = JSON.parse(manifestFragment);
|
||||
}
|
||||
// These are the same for each member of an update group
|
||||
group = updatesToRepublish[0].group;
|
||||
oldMessage = updatesToRepublish[0].message ?? '';
|
||||
oldRuntimeVersion = updatesToRepublish[0].runtimeVersion;
|
||||
} else {
|
||||
// build bundle and upload assets for a new publish
|
||||
if (!skipBundler) {
|
||||
await buildBundlesAsync({ projectDir, inputDir });
|
||||
}
|
||||
|
||||
const assetSpinner = ora('Uploading assets...').start();
|
||||
try {
|
||||
const assets = collectAssets(inputDir!);
|
||||
await uploadAssetsAsync(assets);
|
||||
updateInfoGroup = await buildUpdateInfoGroupAsync(assets);
|
||||
assetSpinner.succeed('Uploaded assets!');
|
||||
} catch (e) {
|
||||
assetSpinner.fail('Failed to upload assets');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateMessage) {
|
||||
if (!message) {
|
||||
const validationMessage = 'publish message may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ publishMessage: updateMessage } = await promptAsync({
|
||||
({ publishMessage: message } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'publishMessage',
|
||||
message: `Please enter a publication message.`,
|
||||
initial: (await getLastCommitMessageAsync())?.trim(),
|
||||
initial: republish
|
||||
? `Republish "${oldMessage!}" - group: ${group}`
|
||||
: (await getLastCommitMessageAsync())?.trim(),
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const { id: branchId } = await getBranchByNameAsync({
|
||||
appId: projectId,
|
||||
branchName: branchName!,
|
||||
});
|
||||
|
||||
let updateInfoGroup;
|
||||
const assetSpinner = ora('Uploading assets...').start();
|
||||
try {
|
||||
const assets = collectAssets(inputDir!);
|
||||
await uploadAssetsAsync(assets);
|
||||
updateInfoGroup = await buildUpdateInfoGroupAsync(assets);
|
||||
assetSpinner.succeed('Uploaded assets!');
|
||||
} catch (e) {
|
||||
assetSpinner.fail('Failed to upload assets');
|
||||
throw e;
|
||||
}
|
||||
|
||||
let newUpdateGroup;
|
||||
const publishSpinner = ora('Publishing...').start();
|
||||
try {
|
||||
newUpdateGroup = await PublishMutation.publishUpdateGroupAsync({
|
||||
branchId,
|
||||
updateInfoGroup,
|
||||
runtimeVersion,
|
||||
updateMessage,
|
||||
runtimeVersion: republish ? oldRuntimeVersion! : runtimeVersion,
|
||||
message,
|
||||
});
|
||||
publishSpinner.succeed('Published!');
|
||||
} catch (e) {
|
||||
@@ -123,7 +240,7 @@ export default class BranchPublish extends Command {
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(newUpdateGroup);
|
||||
Log.log(JSON.stringify(newUpdateGroup));
|
||||
} else {
|
||||
const outputMessage = new Table({
|
||||
wordWrap: true,
|
||||
@@ -148,12 +265,23 @@ export default class BranchPublish extends Command {
|
||||
});
|
||||
outputMessage.push(
|
||||
[chalk.dim('project'), `@${accountName}/${slug}`],
|
||||
[chalk.dim('branch'), branchName],
|
||||
[chalk.dim('branch'), name],
|
||||
[chalk.dim('runtimeVersion'), runtimeVersion],
|
||||
[chalk.dim('updateGroupID'), newUpdateGroup.updateGroup],
|
||||
[chalk.dim('message'), updateMessage]
|
||||
[chalk.dim('groupID'), newUpdateGroup.group],
|
||||
[chalk.dim('message'), message]
|
||||
);
|
||||
Log.log(outputMessage.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdateTitle(
|
||||
update: Pick<Update, 'message' | 'createdAt' | 'runtimeVersion'> & {
|
||||
actor?: Pick<Actor, 'firstName'> | null;
|
||||
}
|
||||
): string {
|
||||
const { message, createdAt, actor, runtimeVersion } = update;
|
||||
return `[${dateFormat(createdAt, 'mmm dd HH:MM')} by ${
|
||||
actor?.firstName ?? 'unknown'
|
||||
}, runtimeVersion: ${runtimeVersion}] ${message}`;
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { EditUpdateBranchInput, UpdateBranch } from '../../graphql/generated';
|
||||
import {
|
||||
EditUpdateBranchInput,
|
||||
EditUpdateBranchMutation,
|
||||
EditUpdateBranchMutationVariables,
|
||||
UpdateBranch,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
|
||||
@@ -13,27 +18,18 @@ import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
async function renameUpdateBranchOnAppAsync({
|
||||
appId,
|
||||
currentName,
|
||||
name,
|
||||
newName,
|
||||
}: {
|
||||
appId: string;
|
||||
currentName: string;
|
||||
newName: string;
|
||||
}): Promise<UpdateBranch> {
|
||||
}: EditUpdateBranchInput): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ updateBranch: { editUpdateBranch: UpdateBranch } },
|
||||
{
|
||||
input: EditUpdateBranchInput;
|
||||
}
|
||||
>(
|
||||
.mutation<EditUpdateBranchMutation, EditUpdateBranchMutationVariables>(
|
||||
gql`
|
||||
mutation EditUpdateBranch($input: EditUpdateBranchInput!) {
|
||||
updateBranch {
|
||||
editUpdateBranch(input: $input) {
|
||||
id
|
||||
branchName
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,8 +37,8 @@ async function renameUpdateBranchOnAppAsync({
|
||||
{
|
||||
input: {
|
||||
appId,
|
||||
branchName: currentName,
|
||||
newBranchName: newName,
|
||||
name,
|
||||
newName,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -79,8 +75,9 @@ export default class BranchRename extends Command {
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = await getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: exp.slug,
|
||||
@@ -114,18 +111,18 @@ export default class BranchRename extends Command {
|
||||
|
||||
const editedBranch = await renameUpdateBranchOnAppAsync({
|
||||
appId: projectId,
|
||||
currentName: currentName!,
|
||||
name: currentName!,
|
||||
newName: newName!,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(editedBranch);
|
||||
Log.log(JSON.stringify(editedBranch));
|
||||
return;
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Renamed branch from ${currentName} to ${chalk.bold(
|
||||
editedBranch.branchName
|
||||
editedBranch.name
|
||||
)} on project ${chalk.bold(`@${accountName}/${exp.slug}`)}.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,15 @@ import gql from 'graphql-tag';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { Update } from '../../graphql/generated';
|
||||
import {
|
||||
Maybe,
|
||||
Robot,
|
||||
Update,
|
||||
UpdateBranch,
|
||||
User,
|
||||
ViewBranchQuery,
|
||||
ViewBranchQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
@@ -14,52 +22,40 @@ import { promptAsync } from '../../prompts';
|
||||
|
||||
const PAGE_LIMIT = 10_000;
|
||||
|
||||
type TruncatedUpdate = Pick<Update, 'updateGroup' | 'updateMessage' | 'createdAt' | 'actor'>;
|
||||
type TruncatedUpdate = Pick<Update, 'group' | 'message' | 'createdAt' | 'actor'>;
|
||||
|
||||
async function viewUpdateBranchAsync({
|
||||
export async function viewUpdateBranchAsync({
|
||||
appId,
|
||||
branchName,
|
||||
}: {
|
||||
appId: string;
|
||||
branchName: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
branchName: string;
|
||||
updates: TruncatedUpdate[];
|
||||
}> {
|
||||
name,
|
||||
}: Pick<ViewBranchQueryVariables, 'appId' | 'name'>): Promise<
|
||||
Pick<UpdateBranch, 'id' | 'name'> & {
|
||||
updates: (Pick<
|
||||
Update,
|
||||
'id' | 'group' | 'message' | 'createdAt' | 'runtimeVersion' | 'platform' | 'manifestFragment'
|
||||
> & {
|
||||
actor?: Maybe<Pick<User, 'firstName' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
|
||||
})[];
|
||||
}
|
||||
> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{
|
||||
app: {
|
||||
byId: {
|
||||
updateBranchByBranchName: {
|
||||
id: string;
|
||||
branchName: string;
|
||||
updates: TruncatedUpdate[];
|
||||
};
|
||||
};
|
||||
};
|
||||
},
|
||||
{
|
||||
appId: string;
|
||||
branchName: string;
|
||||
limit: number;
|
||||
}
|
||||
>(
|
||||
.query<ViewBranchQuery, ViewBranchQueryVariables>(
|
||||
gql`
|
||||
query ViewBranch($appId: String!, $branchName: String!, $limit: Int!) {
|
||||
query ViewBranch($appId: String!, $name: String!, $limit: Int!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateBranchByBranchName(branchName: $branchName) {
|
||||
updateBranchByName(name: $name) {
|
||||
id
|
||||
branchName
|
||||
name
|
||||
updates(offset: 0, limit: $limit) {
|
||||
id
|
||||
updateGroup
|
||||
updateMessage
|
||||
group
|
||||
message
|
||||
createdAt
|
||||
runtimeVersion
|
||||
platform
|
||||
manifestFragment
|
||||
actor {
|
||||
id
|
||||
... on User {
|
||||
@@ -77,13 +73,17 @@ async function viewUpdateBranchAsync({
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
name,
|
||||
limit: PAGE_LIMIT,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.app.byId.updateBranchByBranchName;
|
||||
const updateBranch = data.app?.byId.updateBranchByName;
|
||||
if (!updateBranch) {
|
||||
throw new Error(`Could not find branch "${name}"`);
|
||||
}
|
||||
return updateBranch;
|
||||
}
|
||||
|
||||
export default class BranchView extends Command {
|
||||
@@ -92,7 +92,7 @@ export default class BranchView extends Command {
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'branchName',
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the branch to view',
|
||||
},
|
||||
@@ -107,7 +107,7 @@ export default class BranchView extends Command {
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { branchName },
|
||||
args: { name },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(BranchView);
|
||||
|
||||
@@ -115,23 +115,23 @@ export default class BranchView extends Command {
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const {
|
||||
exp: { slug },
|
||||
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
const { slug } = exp;
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
if (!branchName) {
|
||||
if (!name) {
|
||||
const validationMessage = 'Branch name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ branchName } = await promptAsync({
|
||||
({ name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'branchName',
|
||||
name: 'name',
|
||||
message: 'Please enter the name of the branch to view:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
@@ -139,38 +139,38 @@ export default class BranchView extends Command {
|
||||
|
||||
const UpdateBranch = await viewUpdateBranchAsync({
|
||||
appId: projectId,
|
||||
branchName,
|
||||
name,
|
||||
});
|
||||
|
||||
const updates = Object.values(groupBy(UpdateBranch.updates, u => u.updateGroup)).map(
|
||||
updateGroup => updateGroup[0]
|
||||
const updates = Object.values(groupBy(UpdateBranch.updates, u => u.group)).map(
|
||||
group => group[0]
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log({ ...UpdateBranch, updates });
|
||||
Log.log(JSON.stringify({ ...UpdateBranch, updates }));
|
||||
return;
|
||||
}
|
||||
|
||||
const updateGroupTable = new Table({
|
||||
head: ['createdAt', 'updateMessage', 'updateGroup', 'actor'],
|
||||
const groupTable = new Table({
|
||||
head: ['createdAt', 'message', 'group', 'actor'],
|
||||
wordWrap: true,
|
||||
});
|
||||
|
||||
for (const update of updates) {
|
||||
updateGroupTable.push([
|
||||
groupTable.push([
|
||||
new Date(update.createdAt).toLocaleString(),
|
||||
update.updateMessage,
|
||||
update.updateGroup,
|
||||
update.message,
|
||||
update.group,
|
||||
update.actor?.firstName,
|
||||
]);
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Branch: ${chalk.bold(UpdateBranch.branchName)} on project ${chalk.bold(
|
||||
`️Branch: ${chalk.bold(UpdateBranch.name)} on project ${chalk.bold(
|
||||
`@${accountName}/${slug}`
|
||||
)}. Branch ID: ${chalk.bold(UpdateBranch.id)}`
|
||||
);
|
||||
Log.log(chalk.bold('Recent update groups published on this branch:'));
|
||||
Log.log(updateGroupTable.toString());
|
||||
Log.log(groupTable.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
import ora from 'ora';
|
||||
|
||||
import { platformEmojis } from '../../build/constants';
|
||||
import { Platform } from '../../build/types';
|
||||
import { appPlatformEmojis } from '../../build/constants';
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { AppPlatform, Build, BuildStatus } from '../../graphql/generated';
|
||||
import {
|
||||
Build,
|
||||
BuildStatus,
|
||||
CancelBuildMutation,
|
||||
CancelBuildMutationVariables,
|
||||
} from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
@@ -19,7 +24,7 @@ import { confirmAsync, selectAsync } from '../../prompts';
|
||||
async function cancelBuildAsync(buildId: string): Promise<Pick<Build, 'id' | 'status'>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<{ build: { cancel: { id: string; status: BuildStatus } } }>(
|
||||
.mutation<CancelBuildMutation, CancelBuildMutationVariables>(
|
||||
gql`
|
||||
mutation CancelBuildMutation($buildId: ID!) {
|
||||
build(buildId: $buildId) {
|
||||
@@ -37,15 +42,10 @@ async function cancelBuildAsync(buildId: string): Promise<Pick<Build, 'id' | 'st
|
||||
return data.build!.cancel;
|
||||
}
|
||||
|
||||
const appPlatformMap = {
|
||||
[AppPlatform.Android]: Platform.Android,
|
||||
[AppPlatform.Ios]: Platform.iOS,
|
||||
};
|
||||
|
||||
function formatUnfinishedBuild(
|
||||
build: Pick<Build, 'id' | 'platform' | 'status' | 'createdAt'>
|
||||
): string {
|
||||
const platform = platformEmojis[appPlatformMap[build.platform]];
|
||||
const platform = appPlatformEmojis[build.platform];
|
||||
const startTime = new Date(build.createdAt).toLocaleString();
|
||||
const status = chalk.blue(build.status === BuildStatus.InQueue ? 'in-queue' : 'in-progress');
|
||||
return `${platform} Started at: ${startTime}, Status: ${status}, Id: ${build.id}`;
|
||||
@@ -109,8 +109,9 @@ export default class BuildCancel extends Command {
|
||||
const { BUILD_ID: buildIdFromArg } = this.parse(BuildCancel).args;
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
const projectFullName = await getProjectFullNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectFullName = await getProjectFullNameAsync(exp);
|
||||
|
||||
if (buildIdFromArg) {
|
||||
await ensureBuildExistsAsync(buildIdFromArg);
|
||||
|
||||
@@ -70,10 +70,9 @@ function logSuccess(platform: RequestedPlatform) {
|
||||
|
||||
- Run ${chalk.bold('eas build')} when you are ready to create your first build.
|
||||
- Once the build is completed, run ${chalk.bold('eas submit')} to upload the app to ${storesText}
|
||||
- ${learnMore(
|
||||
'https://docs.expo.io/build/introduction',
|
||||
'Learn more about other capabilities of EAS Build'
|
||||
)}`);
|
||||
- ${learnMore('https://docs.expo.io/build/introduction', {
|
||||
learnMoreMessage: 'Learn more about other capabilities of EAS Build',
|
||||
})}`);
|
||||
}
|
||||
|
||||
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { ExpoConfig, getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import {
|
||||
flushAsync as flushAnalyticsAsync,
|
||||
initAsync as initAnalyticsAsync,
|
||||
} from '../../analytics';
|
||||
import { configureAsync } from '../../build/configure';
|
||||
import { createCommandContextAsync } from '../../build/context';
|
||||
import { buildAsync } from '../../build/create';
|
||||
import { AnalyticsEvent, Build as BuildType, RequestedPlatform } from '../../build/types';
|
||||
import Analytics from '../../build/utils/analytics';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import { RequestedPlatform } from '../../build/types';
|
||||
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
|
||||
import { isGitStatusCleanAsync } from '../../build/utils/repository';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
@@ -58,55 +61,55 @@ export default class Build extends Command {
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = this.parse(Build);
|
||||
const user = await ensureLoggedInAsync();
|
||||
await initAnalyticsAsync(); // TODO: run in oclif hook for every command
|
||||
try {
|
||||
const user = await ensureLoggedInAsync();
|
||||
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
if (!flags.platform && nonInteractive) {
|
||||
throw new Error('--platform is required when building in non-interactive mode');
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
if (!flags.platform && nonInteractive) {
|
||||
throw new Error('--platform is required when building in non-interactive mode');
|
||||
}
|
||||
const platform =
|
||||
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
let { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
|
||||
accountName,
|
||||
platform,
|
||||
projectId,
|
||||
user,
|
||||
});
|
||||
if (accountHasPendingBuilds) {
|
||||
return;
|
||||
}
|
||||
|
||||
exp = (await ensureProjectConfiguredAsync(projectDir)) ?? exp;
|
||||
|
||||
const commandCtx = await createCommandContextAsync({
|
||||
requestedPlatform: platform,
|
||||
profile: flags.profile,
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
nonInteractive,
|
||||
skipCredentialsCheck: flags['skip-credentials-check'],
|
||||
skipProjectConfiguration: flags['skip-project-configuration'],
|
||||
waitForBuildEnd: flags.wait,
|
||||
});
|
||||
await buildAsync(commandCtx);
|
||||
} finally {
|
||||
await flushAnalyticsAsync(); // TODO: run in oclif hook for every command
|
||||
}
|
||||
const platform =
|
||||
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
|
||||
accountName,
|
||||
platform,
|
||||
projectId,
|
||||
user,
|
||||
});
|
||||
if (accountHasPendingBuilds) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureProjectConfiguredAsync(projectDir);
|
||||
|
||||
const trackingCtx = {
|
||||
tracking_id: uuidv4(),
|
||||
requested_platform: flags.platform,
|
||||
};
|
||||
Analytics.logEvent(AnalyticsEvent.BUILD_COMMAND, trackingCtx);
|
||||
|
||||
const commandCtx = await createCommandContextAsync({
|
||||
requestedPlatform: platform,
|
||||
profile: flags.profile,
|
||||
projectDir,
|
||||
projectId,
|
||||
trackingCtx,
|
||||
nonInteractive,
|
||||
skipCredentialsCheck: flags['skip-credentials-check'],
|
||||
skipProjectConfiguration: flags['skip-project-configuration'],
|
||||
waitForBuildEnd: flags.wait,
|
||||
});
|
||||
await buildAsync(commandCtx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,17 +142,12 @@ async function ensureNoPendingBuildsExistAsync({
|
||||
Log.newLine();
|
||||
const results = await Promise.all(
|
||||
pendingBuilds.map(pendingBuild => {
|
||||
return apiClient.get<{ data: BuildType }>(
|
||||
`projects/${projectId}/builds/${pendingBuild?.id}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
return BuildQuery.byIdAsync(nullthrows(pendingBuild).id);
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
Log.log(formatBuild(result.body.data, { accountName }));
|
||||
Log.log(formatGraphQLBuild(result));
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
@@ -191,9 +189,9 @@ async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
return platform;
|
||||
}
|
||||
|
||||
async function ensureProjectConfiguredAsync(projectDir: string): Promise<void> {
|
||||
async function ensureProjectConfiguredAsync(projectDir: string): Promise<ExpoConfig | null> {
|
||||
if (await fs.pathExists(path.join(projectDir, 'eas.json'))) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const confirm = await confirmAsync({
|
||||
message: 'This app is not set up for building with EAS. Set it up now?',
|
||||
@@ -209,6 +207,8 @@ async function ensureProjectConfiguredAsync(projectDir: string): Promise<void> {
|
||||
'Build process requires clean git working tree, please commit all your changes and run `eas build` again'
|
||||
);
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
return exp;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Aborting, please run ${chalk.bold('eas build:configure')} or create eas.json (${learnMore(
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import { Build } from '../../build/types';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import { BuildStatus, RequestedPlatform } from '../../build/types';
|
||||
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
|
||||
import { AppPlatform, BuildStatus as GraphQLBuildStatus } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
@@ -17,34 +18,45 @@ export default class BuildList extends Command {
|
||||
static description = 'list all builds for your project';
|
||||
|
||||
static flags = {
|
||||
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
|
||||
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished', 'canceled'] }),
|
||||
platform: flags.enum({
|
||||
options: [RequestedPlatform.All, RequestedPlatform.Android, RequestedPlatform.iOS],
|
||||
}),
|
||||
status: flags.enum({
|
||||
options: [
|
||||
BuildStatus.IN_QUEUE,
|
||||
BuildStatus.IN_PROGRESS,
|
||||
BuildStatus.ERRORED,
|
||||
BuildStatus.FINISHED,
|
||||
BuildStatus.CANCELED,
|
||||
],
|
||||
}),
|
||||
limit: flags.integer(),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { platform, status, limit = 10 } = this.parse(BuildList).flags;
|
||||
const { platform: requestedPlatform, status: buildStatus, limit = 10 } = this.parse(
|
||||
BuildList
|
||||
).flags;
|
||||
|
||||
const platform = toAppPlatform(requestedPlatform);
|
||||
const graphqlBuildStatus = toGraphQLBuildStatus(buildStatus);
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const projectName = await getProjectFullNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectName = await getProjectFullNameAsync(exp);
|
||||
|
||||
const spinner = ora().start('Fetching the build list for the project…');
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<{ data: { builds: Build[] } }>(
|
||||
`projects/${projectId}/builds`,
|
||||
{
|
||||
searchParams: { platform, status, limit },
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
const { builds } = response.body.data;
|
||||
const builds = await BuildQuery.allForAppAsync(projectId, {
|
||||
limit,
|
||||
platform,
|
||||
status: graphqlBuildStatus,
|
||||
});
|
||||
|
||||
if (builds.length) {
|
||||
if (platform || status) {
|
||||
if (platform || graphqlBuildStatus) {
|
||||
spinner.succeed(
|
||||
`Showing ${builds.length} matching builds for the project ${projectName}`
|
||||
);
|
||||
@@ -53,7 +65,7 @@ export default class BuildList extends Command {
|
||||
}
|
||||
|
||||
const list = builds
|
||||
.map(build => formatBuild(build, { accountName }))
|
||||
.map(build => formatGraphQLBuild(build))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`);
|
||||
|
||||
Log.log(`\n${list}`);
|
||||
@@ -66,3 +78,29 @@ export default class BuildList extends Command {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toAppPlatform = (requestedPlatform?: RequestedPlatform): AppPlatform | undefined => {
|
||||
if (!requestedPlatform || requestedPlatform === RequestedPlatform.All) {
|
||||
return undefined;
|
||||
} else if (requestedPlatform === RequestedPlatform.Android) {
|
||||
return AppPlatform.Android;
|
||||
} else {
|
||||
return AppPlatform.Ios;
|
||||
}
|
||||
};
|
||||
|
||||
const toGraphQLBuildStatus = (buildStatus: BuildStatus): GraphQLBuildStatus | undefined => {
|
||||
if (!buildStatus) {
|
||||
return undefined;
|
||||
} else if (buildStatus === BuildStatus.IN_QUEUE) {
|
||||
return GraphQLBuildStatus.InQueue;
|
||||
} else if (buildStatus === BuildStatus.IN_PROGRESS) {
|
||||
return GraphQLBuildStatus.InProgress;
|
||||
} else if (buildStatus === BuildStatus.ERRORED) {
|
||||
return GraphQLBuildStatus.Errored;
|
||||
} else if (buildStatus === BuildStatus.FINISHED) {
|
||||
return GraphQLBuildStatus.Finished;
|
||||
} else {
|
||||
return GraphQLBuildStatus.Canceled;
|
||||
}
|
||||
};
|
||||
@@ -1,14 +1,13 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import { HTTPError } from 'got';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import { Build } from '../../build/types';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
|
||||
import { BuildFragment } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
@@ -22,34 +21,24 @@ export default class BuildView extends Command {
|
||||
const { BUILD_ID: buildId } = this.parse(BuildView).args;
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const projectName = await getProjectFullNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectName = await getProjectFullNameAsync(exp);
|
||||
|
||||
const spinner = ora().start('Fetching the build…');
|
||||
|
||||
try {
|
||||
let build: Build;
|
||||
let build: BuildFragment;
|
||||
|
||||
if (buildId == null) {
|
||||
const response = await apiClient.get<{ data: { builds: Build[] } }>(
|
||||
`projects/${projectId}/builds`,
|
||||
{
|
||||
searchParams: { limit: 1 },
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
build = response.body.data.builds[0];
|
||||
if (buildId) {
|
||||
build = await BuildQuery.byIdAsync(buildId);
|
||||
} else {
|
||||
const response = await apiClient.get<{ data: Build }>(
|
||||
`projects/${projectId}/builds/${buildId}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
build = response.body.data;
|
||||
const builds = await BuildQuery.allForAppAsync(projectId, { limit: 1 });
|
||||
if (builds.length === 0) {
|
||||
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
|
||||
return;
|
||||
}
|
||||
build = builds[0];
|
||||
}
|
||||
|
||||
if (buildId) {
|
||||
@@ -58,29 +47,17 @@ export default class BuildView extends Command {
|
||||
spinner.succeed(`Showing the last build for the project ${projectName}`);
|
||||
}
|
||||
|
||||
Log.log(`\n${formatBuild(build, { accountName })}`);
|
||||
} catch (e) {
|
||||
const error = e as HTTPError;
|
||||
|
||||
if (error.response.statusCode === 400) {
|
||||
if (buildId) {
|
||||
spinner.fail(
|
||||
`Couldn't find a build for the project ${projectName} matching the id ${buildId}`
|
||||
);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
|
||||
}
|
||||
Log.log(`\n${formatGraphQLBuild(build)}`);
|
||||
} catch (err) {
|
||||
if (buildId) {
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
|
||||
} else {
|
||||
if (buildId) {
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
|
||||
);
|
||||
}
|
||||
spinner.fail(
|
||||
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { UpdateChannel } from '../../graphql/generated';
|
||||
import {
|
||||
CreateUpdateChannelOnAppMutation,
|
||||
CreateUpdateChannelOnAppMutationVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import {
|
||||
@@ -23,32 +26,21 @@ async function createUpdateChannelOnAppAsync({
|
||||
appId: string;
|
||||
channelName: string;
|
||||
branchId: string;
|
||||
}): Promise<UpdateChannel> {
|
||||
}): Promise<CreateUpdateChannelOnAppMutation> {
|
||||
// Point the new channel at a branch with its same name.
|
||||
const branchMapping = JSON.stringify({
|
||||
data: [{ branchId, branchMappingLogic: 'true' }],
|
||||
version: 0,
|
||||
});
|
||||
const data = await withErrorHandlingAsync(
|
||||
return await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ updateChannel: { createUpdateChannelForApp: UpdateChannel } },
|
||||
{ appId: string; channelName: string; branchMapping: string }
|
||||
>(
|
||||
.mutation<CreateUpdateChannelOnAppMutation, CreateUpdateChannelOnAppMutationVariables>(
|
||||
gql`
|
||||
mutation CreateUpdateChannelForApp(
|
||||
$appId: ID!
|
||||
$channelName: String!
|
||||
$branchMapping: String!
|
||||
) {
|
||||
mutation CreateUpdateChannelOnApp($appId: ID!, $name: String!, $branchMapping: String!) {
|
||||
updateChannel {
|
||||
createUpdateChannelForApp(
|
||||
appId: $appId
|
||||
channelName: $channelName
|
||||
branchMapping: $branchMapping
|
||||
) {
|
||||
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
|
||||
id
|
||||
channelName
|
||||
name
|
||||
branchMapping
|
||||
}
|
||||
}
|
||||
@@ -56,13 +48,12 @@ async function createUpdateChannelOnAppAsync({
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
channelName,
|
||||
name: channelName,
|
||||
branchMapping,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateChannel.createUpdateChannelForApp;
|
||||
}
|
||||
|
||||
export default class ChannelCreate extends Command {
|
||||
@@ -71,7 +62,7 @@ export default class ChannelCreate extends Command {
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'channelName',
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the channel to create',
|
||||
},
|
||||
@@ -87,7 +78,7 @@ export default class ChannelCreate extends Command {
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { channelName },
|
||||
args: { name: channelName },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ChannelCreate);
|
||||
|
||||
@@ -95,10 +86,9 @@ export default class ChannelCreate extends Command {
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const {
|
||||
exp: { slug },
|
||||
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
const { slug } = exp;
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
@@ -109,9 +99,9 @@ export default class ChannelCreate extends Command {
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ channelName } = await promptAsync({
|
||||
({ name: channelName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'channelName',
|
||||
name: 'name',
|
||||
message: 'Please name the channel:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
@@ -122,32 +112,40 @@ export default class ChannelCreate extends Command {
|
||||
try {
|
||||
const existingBranch = await getBranchByNameAsync({
|
||||
appId: projectId,
|
||||
branchName: channelName,
|
||||
name: channelName,
|
||||
});
|
||||
branchId = existingBranch.id;
|
||||
branchMessage = `We found a branch with the same name`;
|
||||
} catch (e) {
|
||||
const newBranch = await createUpdateBranchOnAppAsync({
|
||||
appId: projectId,
|
||||
branchName: channelName,
|
||||
name: channelName,
|
||||
});
|
||||
branchId = newBranch.id;
|
||||
branchMessage = `We also went ahead and made a branch with the same name`;
|
||||
}
|
||||
|
||||
const newChannel = await createUpdateChannelOnAppAsync({
|
||||
const {
|
||||
updateChannel: { createUpdateChannelForApp: newChannel },
|
||||
} = await createUpdateChannelOnAppAsync({
|
||||
appId: projectId,
|
||||
channelName,
|
||||
branchId,
|
||||
});
|
||||
|
||||
if (!newChannel) {
|
||||
throw new Error(
|
||||
`Could not create channel with name ${channelName} on app with id ${projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(newChannel);
|
||||
Log.log(JSON.stringify(newChannel));
|
||||
return;
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Created a new channel ${chalk.bold(newChannel.channelName)} on project ${chalk.bold(
|
||||
`️Created a new channel ${chalk.bold(newChannel.name)} on project ${chalk.bold(
|
||||
`@${accountName}/${slug}`
|
||||
)}. ${branchMessage} and have pointed the channel at it. You can now update your app by publishing!`
|
||||
);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
GetChannelByNameToEditQuery,
|
||||
GetChannelByNameToEditQueryVariables,
|
||||
UpdateBranch,
|
||||
UpdateChannel,
|
||||
UpdateChannelBranchMappingMutation,
|
||||
UpdateChannelBranchMappingMutationVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getBranchByNameAsync,
|
||||
getProjectAccountNameAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
async function getChannelByNameForAppAsync({
|
||||
appId,
|
||||
channelName,
|
||||
}: GetChannelByNameToEditQueryVariables): Promise<
|
||||
Pick<UpdateChannel, 'id' | 'name'> & {
|
||||
updateBranches: Pick<UpdateBranch, 'id' | 'name'>[];
|
||||
}
|
||||
> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetChannelByNameToEditQuery, GetChannelByNameToEditQueryVariables>(
|
||||
gql`
|
||||
query GetChannelByNameToEdit($appId: String!, $channelName: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateChannelByName(name: $channelName) {
|
||||
id
|
||||
name
|
||||
updateBranches(offset: 0, limit: 1) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, channelName }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const updateChannelByNameResult = data.app?.byId.updateChannelByName;
|
||||
if (!updateChannelByNameResult) {
|
||||
throw new Error(`Could not find a channel named ${channelName} on app with id ${appId}`);
|
||||
}
|
||||
return updateChannelByNameResult;
|
||||
}
|
||||
|
||||
export async function updateChannelBranchMappingAsync({
|
||||
channelId,
|
||||
branchMapping,
|
||||
}: UpdateChannelBranchMappingMutationVariables): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
}> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
|
||||
gql`
|
||||
mutation UpdateChannelBranchMapping($channelId: ID!, $branchMapping: String!) {
|
||||
updateChannel {
|
||||
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
|
||||
id
|
||||
name
|
||||
branchMapping
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ channelId, branchMapping }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const channel = data.updateChannel.editUpdateChannel;
|
||||
if (!channel) {
|
||||
throw new Error(`Could not fine channel with id ${channelId}`);
|
||||
}
|
||||
return data.updateChannel.editUpdateChannel!;
|
||||
}
|
||||
|
||||
export default class ChannelEdit extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Point a channel at a new branch.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the channel to edit',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
branch: flags.string({
|
||||
description: 'Name of the branch to point to',
|
||||
}),
|
||||
json: flags.boolean({
|
||||
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { name: channelName },
|
||||
flags: { branch: branchName, json: jsonFlag },
|
||||
} = this.parse(ChannelEdit);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
const { slug } = exp;
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
if (!channelName) {
|
||||
const validationMessage = 'A channel name is required to edit a specific channel.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name: channelName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Please enter the name of the channel to edit:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const existingChannel = await getChannelByNameForAppAsync({ appId: projectId, channelName });
|
||||
if (existingChannel.updateBranches.length > 1) {
|
||||
throw new Error(
|
||||
'There is a rollout in progress. Please manage it with "channel:rollout" instead.'
|
||||
);
|
||||
}
|
||||
const existingBranch = existingChannel.updateBranches[0];
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
chalk`Channel {bold ${existingChannel.name}} is currently set to branch {bold ${existingBranch.name}}`
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
if (!branchName) {
|
||||
const validationMessage = 'branch name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name: branchName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: chalk`What branch should it change to?`,
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
|
||||
const channel = await updateChannelBranchMappingAsync({
|
||||
channelId: existingChannel.id,
|
||||
// todo: move branch mapping logic to utility
|
||||
branchMapping: JSON.stringify({
|
||||
data: [{ branchId: branch.id, branchMappingLogic: 'true' }],
|
||||
version: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(channel));
|
||||
return;
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
chalk`Channel {bold ${channel.name}} is now set to branch {bold ${branch.name}}.\n`
|
||||
);
|
||||
Log.log(
|
||||
chalk`Users with builds on channel {bold ${channel.name}} will now receive the active update on {bold ${branch.name}}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
GetAllChannelsForAppQuery,
|
||||
GetAllChannelsForAppQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
|
||||
const CHANNEL_LIMIT = 10_000;
|
||||
|
||||
async function getAllUpdateChannelForAppAsync({
|
||||
appId,
|
||||
}: {
|
||||
appId: string;
|
||||
}): Promise<GetAllChannelsForAppQuery> {
|
||||
return await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetAllChannelsForAppQuery, GetAllChannelsForAppQueryVariables>(
|
||||
gql`
|
||||
query GetAllChannelsForApp($appId: String!, $offset: Int!, $limit: Int!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateChannels(offset: $offset, limit: $limit) {
|
||||
id
|
||||
name
|
||||
updateBranches(offset: 0, limit: 1) {
|
||||
id
|
||||
name
|
||||
updates(offset: 0, limit: 1) {
|
||||
id
|
||||
group
|
||||
message
|
||||
createdAt
|
||||
actor {
|
||||
id
|
||||
... on User {
|
||||
firstName
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, offset: 0, limit: CHANNEL_LIMIT }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
}
|
||||
|
||||
export default class ChannelList extends Command {
|
||||
static hidden = true;
|
||||
static description = 'List all channels on the current project.';
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const {
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ChannelList);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
const { slug } = exp;
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
const getAllUpdateChannelForAppResult = await getAllUpdateChannelForAppAsync({
|
||||
appId: projectId,
|
||||
});
|
||||
const channels = getAllUpdateChannelForAppResult.app?.byId.updateChannels;
|
||||
if (!channels) {
|
||||
throw new Error(`Could not find channels on project with id ${projectId}`);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(channels));
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new Table({
|
||||
head: ['channel', 'branch', 'update', 'message', 'created-at', 'actor'],
|
||||
wordWrap: true,
|
||||
});
|
||||
|
||||
for (const channel of channels) {
|
||||
// TODO (cedric): refactor when multiple branches per channel are available
|
||||
const branch = channel.updateBranches[0];
|
||||
const update = branch.updates[0];
|
||||
|
||||
table.push([
|
||||
channel.name,
|
||||
branch.name,
|
||||
update?.group,
|
||||
update?.message,
|
||||
update?.createdAt && new Date(update.createdAt).toLocaleString(),
|
||||
update?.actor?.firstName,
|
||||
]);
|
||||
}
|
||||
|
||||
Log.log(chalk`{bold Channels for this app:}`);
|
||||
Log.log(table.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { GetChannelByNameForAppQuery, UpdateBranch } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getBranchByNameAsync,
|
||||
getProjectAccountNameAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync, selectAsync } from '../../prompts';
|
||||
import { updateChannelBranchMappingAsync } from './edit';
|
||||
import { BranchMapping, getBranchMapping, getUpdateChannelByNameForAppAsync } from './view';
|
||||
|
||||
async function promptForRolloutPercentAsync({
|
||||
promptMessage,
|
||||
}: {
|
||||
promptMessage: string;
|
||||
}): Promise<number> {
|
||||
const { name: rolloutPercent } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
format: value => {
|
||||
return parseInt(value, 10);
|
||||
},
|
||||
message: promptMessage,
|
||||
initial: 0,
|
||||
validate: (rolloutPercent: string): true | string => {
|
||||
const floatValue = parseFloat(rolloutPercent);
|
||||
return Number.isInteger(floatValue) && floatValue >= 0 && floatValue <= 100
|
||||
? true
|
||||
: 'The rollout percentage must be an integer between 0 and 100 inclusive.';
|
||||
},
|
||||
});
|
||||
return rolloutPercent;
|
||||
}
|
||||
|
||||
function getRolloutInfo(
|
||||
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery
|
||||
): {
|
||||
newBranch: Pick<UpdateBranch, 'name' | 'id'>;
|
||||
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
|
||||
currentPercent: number;
|
||||
} {
|
||||
const { branchMapping } = getBranchMapping(getUpdateChannelByNameForAppResult);
|
||||
const [newBranchId, oldBranchId] = branchMapping.data.map(d => d.branchId);
|
||||
const newBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.updateBranches.filter(
|
||||
branch => branch.id === newBranchId
|
||||
)[0];
|
||||
const oldBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.updateBranches.filter(
|
||||
branch => branch.id === oldBranchId
|
||||
)[0];
|
||||
|
||||
if (!newBranch || !oldBranch) {
|
||||
throw new Error(
|
||||
`Branch mapping rollout is missing a branch for channel "${getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.name}".`
|
||||
);
|
||||
}
|
||||
|
||||
const currentPercent = 100 * branchMapping.data[0].branchMappingLogic.operand;
|
||||
return { newBranch, oldBranch, currentPercent };
|
||||
}
|
||||
|
||||
async function startRolloutAsync({
|
||||
channelName,
|
||||
branchName,
|
||||
percent,
|
||||
jsonFlag,
|
||||
projectId,
|
||||
fullName,
|
||||
currentBranchMapping,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
}: {
|
||||
channelName?: string;
|
||||
branchName?: string;
|
||||
percent?: number;
|
||||
jsonFlag: boolean;
|
||||
projectId: string;
|
||||
fullName: string;
|
||||
currentBranchMapping: BranchMapping;
|
||||
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery;
|
||||
}): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
if (!branchName) {
|
||||
const validationMessage = 'A branch must be specified.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name: branchName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: `Select a branch to rollout onto ${channelName}`,
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
|
||||
const oldBranchId = currentBranchMapping.data[0].branchId;
|
||||
if (branch.id === oldBranchId) {
|
||||
throw new Error(
|
||||
`channel "${channelName}" is already pointing at branch "${branchName}". Rollouts must be done with distinct branches.`
|
||||
);
|
||||
}
|
||||
|
||||
if (percent == null) {
|
||||
if (jsonFlag) {
|
||||
throw new Error(
|
||||
'You must specify a percent with the --percent flag when initiating a rollout with the --json flag.'
|
||||
);
|
||||
}
|
||||
const promptMessage = `What percent of users should be directed to the branch "${branchName}"?`;
|
||||
percent = await promptForRolloutPercentAsync({ promptMessage });
|
||||
}
|
||||
|
||||
const newBranchMapping = {
|
||||
version: 0,
|
||||
data: [
|
||||
{
|
||||
branchId: branch.id,
|
||||
branchMappingLogic: {
|
||||
operand: percent / 100,
|
||||
clientKey: 'rolloutToken',
|
||||
branchMappingOperator: 'hash_lt',
|
||||
},
|
||||
},
|
||||
currentBranchMapping.data[0],
|
||||
],
|
||||
};
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync({
|
||||
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.id!,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
|
||||
const oldBranch = getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.updateBranches.filter(
|
||||
branch => branch.id === oldBranchId
|
||||
)[0];
|
||||
if (!oldBranch) {
|
||||
throw new Error(
|
||||
`Branch mapping is missing its only branch for channel "${channelName}" on app "${fullName}"`
|
||||
);
|
||||
}
|
||||
|
||||
const logMessage = `️Started a rollout of branch ${chalk.bold(
|
||||
branchName
|
||||
)} onto channel ${chalk.bold(channelName!)}! ${chalk.bold(
|
||||
percent
|
||||
)}% of users will be directed to branch ${chalk.bold(branchName)}, ${chalk.bold(
|
||||
100 - percent
|
||||
)}% to branch ${chalk.bold(oldBranch.name)}.`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
|
||||
async function editRolloutAsync({
|
||||
channelName,
|
||||
percent,
|
||||
jsonFlag,
|
||||
currentBranchMapping,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
}: {
|
||||
channelName?: string;
|
||||
percent?: number;
|
||||
jsonFlag: boolean;
|
||||
currentBranchMapping: BranchMapping;
|
||||
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery;
|
||||
}): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(
|
||||
getUpdateChannelByNameForAppResult
|
||||
);
|
||||
|
||||
if (percent == null) {
|
||||
if (jsonFlag) {
|
||||
throw new Error(
|
||||
'A rollout is already in progress. If you wish to modify it you must use specify the new rollout percentage with the --percent flag.'
|
||||
);
|
||||
}
|
||||
const promptMessage = `Currently ${currentPercent}% of all users are routed to branch ${
|
||||
newBranch.name
|
||||
} and ${100 - currentPercent}% of all users are routed to branch ${
|
||||
oldBranch.name
|
||||
}. What percent of users should be directed to the branch ${newBranch.name}?`;
|
||||
percent = await promptForRolloutPercentAsync({ promptMessage });
|
||||
}
|
||||
|
||||
const newBranchMapping = { ...currentBranchMapping };
|
||||
newBranchMapping.data[0].branchMappingLogic.operand = percent / 100;
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync({
|
||||
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.id!,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
|
||||
const logMessage = `️Rollout of branch ${chalk.bold(newBranch.name)} onto channel ${chalk.bold(
|
||||
channelName!
|
||||
)} updated from ${chalk.bold(currentPercent)}% to ${chalk.bold(percent)}%. ${chalk.bold(
|
||||
percent
|
||||
)}% of users will be directed to branch ${chalk.bold(newBranch.name)}, ${chalk.bold(
|
||||
100 - percent
|
||||
)}% to branch ${chalk.bold(oldBranch.name)}.`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
|
||||
async function endRolloutAsync({
|
||||
channelName,
|
||||
branchName,
|
||||
jsonFlag,
|
||||
projectId,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
}: {
|
||||
channelName?: string;
|
||||
branchName?: string;
|
||||
jsonFlag: boolean;
|
||||
projectId: string;
|
||||
getUpdateChannelByNameForAppResult: GetChannelByNameForAppQuery;
|
||||
}): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
// end rollout
|
||||
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(
|
||||
getUpdateChannelByNameForAppResult
|
||||
);
|
||||
|
||||
let endOnNewBranch;
|
||||
if (branchName) {
|
||||
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
|
||||
switch (branch.id) {
|
||||
case newBranch.id:
|
||||
endOnNewBranch = true;
|
||||
break;
|
||||
case oldBranch.id:
|
||||
endOnNewBranch = false;
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`The branch "${branchName}" specified by --branch must be one of the branches involved in the rollout: "${newBranch.name}" or "${oldBranch.name}".`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (jsonFlag) {
|
||||
throw new Error(
|
||||
'Branch name must be specified with the --branch flag when both the --end and --json flag are true.'
|
||||
);
|
||||
}
|
||||
endOnNewBranch = await selectAsync<boolean>(
|
||||
'Ending the rollout will send all traffic to a single branch. Which one should that be?',
|
||||
[
|
||||
{
|
||||
title: `${newBranch.name} ${chalk.grey(`- current percent: ${100 - currentPercent}%`)}`,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
title: `${oldBranch.name} ${chalk.grey(`- current percent: ${currentPercent}%`)}`,
|
||||
value: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
if (endOnNewBranch == null) {
|
||||
throw new Error('Branch to end on is undefined.');
|
||||
}
|
||||
|
||||
const newBranchMapping = {
|
||||
version: 0,
|
||||
data: [
|
||||
{
|
||||
branchId: endOnNewBranch ? newBranch.id : oldBranch.id,
|
||||
branchMappingLogic: 'true',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync({
|
||||
channelId: getUpdateChannelByNameForAppResult.app?.byId.updateChannelByName.id!,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
const logMessage = `️Rollout on channel ${chalk.bold(
|
||||
channelName
|
||||
)} ended. All traffic is now sent to branch ${chalk.bold(
|
||||
endOnNewBranch ? newBranch.name : oldBranch.name
|
||||
)}`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
|
||||
export default class ChannelRollout extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Rollout a new branch out to a channel incrementally.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'channel',
|
||||
required: true,
|
||||
description: 'rollout that the channel is on',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
branch: flags.string({
|
||||
description: 'branch to rollout',
|
||||
required: false,
|
||||
}),
|
||||
percent: flags.integer({
|
||||
description: 'percent of traffic to redirect to the new branch',
|
||||
required: false,
|
||||
}),
|
||||
end: flags.boolean({
|
||||
description: 'end the rollout',
|
||||
default: false,
|
||||
}),
|
||||
json: flags.boolean({
|
||||
description: 'print output as a JSON object with the new channel ID, name and branch mapping',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const {
|
||||
args: { channel: channelName },
|
||||
flags: { json: jsonFlag, end: endFlag },
|
||||
} = this.parse(ChannelRollout);
|
||||
const {
|
||||
flags: { branch: branchName, percent },
|
||||
} = this.parse(ChannelRollout);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
const { slug } = exp;
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
const getUpdateChannelByNameForAppResult = await getUpdateChannelByNameForAppAsync({
|
||||
appId: projectId,
|
||||
channelName: channelName!,
|
||||
});
|
||||
const { branchMapping: currentBranchMapping, isRollout } = getBranchMapping(
|
||||
getUpdateChannelByNameForAppResult
|
||||
);
|
||||
|
||||
if (currentBranchMapping.data.length === 0) {
|
||||
throw new Error('The channel is not pointing at any branches.');
|
||||
}
|
||||
if (currentBranchMapping.data.length > 2) {
|
||||
throw new Error('"channel:rollout" cannot handle branch mappings with more than 2 branches.');
|
||||
}
|
||||
// This combination doesn't make sense. Throw an error explaining the options.
|
||||
if (isRollout && branchName && !endFlag) {
|
||||
throw new Error(
|
||||
`There is a rollout in progress. You can only either edit the rollout percent or 'end' it.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This if/else block has three branches:
|
||||
* 1. The branch mapping is not a rollout, i.e. it is pointing to a single branch.
|
||||
* 2. The branch mapping is a rollout.
|
||||
* a. increase/decrease the rollout percentage.
|
||||
* b. end the rollout.
|
||||
*/
|
||||
let rolloutMutationResult: {
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
};
|
||||
if (!isRollout) {
|
||||
rolloutMutationResult = await startRolloutAsync({
|
||||
channelName,
|
||||
branchName,
|
||||
percent,
|
||||
jsonFlag,
|
||||
projectId,
|
||||
fullName: `@${accountName}/${slug}`,
|
||||
currentBranchMapping,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
});
|
||||
} else if (endFlag) {
|
||||
rolloutMutationResult = await endRolloutAsync({
|
||||
channelName,
|
||||
branchName,
|
||||
jsonFlag,
|
||||
projectId,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
});
|
||||
} else {
|
||||
rolloutMutationResult = await editRolloutAsync({
|
||||
channelName,
|
||||
percent,
|
||||
jsonFlag,
|
||||
currentBranchMapping,
|
||||
getUpdateChannelByNameForAppResult,
|
||||
});
|
||||
}
|
||||
if (!rolloutMutationResult) {
|
||||
throw new Error('rollout result is empty');
|
||||
}
|
||||
const { newChannelInfo, logMessage } = rolloutMutationResult;
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(newChannelInfo));
|
||||
return;
|
||||
}
|
||||
Log.withTick(logMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
GetChannelByNameForAppQuery,
|
||||
GetChannelByNameForAppQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
export type BranchMapping = {
|
||||
version: number;
|
||||
data: {
|
||||
branchId: string;
|
||||
branchMappingLogic: {
|
||||
operand: number;
|
||||
clientKey: string;
|
||||
branchMappingOperator: string;
|
||||
} & string;
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the branch mapping and determine whether it is a rollout.
|
||||
* Ensure that the branch mapping is properly formatted.
|
||||
*/
|
||||
export function getBranchMapping(
|
||||
getChannelByNameForAppQuery: GetChannelByNameForAppQuery
|
||||
): { branchMapping: BranchMapping; isRollout: boolean; rolloutPercent?: number } {
|
||||
const branchMappingString =
|
||||
getChannelByNameForAppQuery.app?.byId.updateChannelByName.branchMapping;
|
||||
if (!branchMappingString) {
|
||||
throw new Error('Missing branch mapping.');
|
||||
}
|
||||
|
||||
const branchMapping: BranchMapping = JSON.parse(branchMappingString);
|
||||
if (branchMapping.version !== 0) {
|
||||
throw new Error('Branch mapping must be version 0.');
|
||||
}
|
||||
|
||||
const isRollout = branchMapping.data.length === 2;
|
||||
const rolloutPercent = branchMapping.data[0].branchMappingLogic.operand;
|
||||
|
||||
switch (branchMapping.data.length) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
if (branchMapping.data[0].branchMappingLogic !== 'true') {
|
||||
throw new Error('Branch mapping logic for a single branch must be "true"');
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
|
||||
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
|
||||
}
|
||||
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
|
||||
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
|
||||
}
|
||||
if (rolloutPercent == null) {
|
||||
throw new Error('Branch mapping is missing a "rolloutPercent"');
|
||||
}
|
||||
if (branchMapping.data[1].branchMappingLogic !== 'true') {
|
||||
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error('Branch mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
return { branchMapping, isRollout, rolloutPercent };
|
||||
}
|
||||
|
||||
export async function getUpdateChannelByNameForAppAsync({
|
||||
appId,
|
||||
channelName,
|
||||
}: GetChannelByNameForAppQueryVariables): Promise<GetChannelByNameForAppQuery> {
|
||||
return await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetChannelByNameForAppQuery, GetChannelByNameForAppQueryVariables>(
|
||||
gql`
|
||||
query GetChannelByNameForApp($appId: String!, $channelName: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateChannelByName(name: $channelName) {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
branchMapping
|
||||
updateBranches(offset: 0, limit: 1000) {
|
||||
id
|
||||
name
|
||||
updates(offset: 0, limit: 1) {
|
||||
id
|
||||
group
|
||||
message
|
||||
createdAt
|
||||
actor {
|
||||
id
|
||||
... on User {
|
||||
firstName
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, channelName }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
}
|
||||
|
||||
export default class ChannelView extends Command {
|
||||
static hidden = true;
|
||||
static description = 'View a channel on the current project.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: false,
|
||||
description: 'Name of the channel to view',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { name: channelName },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ChannelView);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
const { slug } = exp;
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
if (!channelName) {
|
||||
const validationMessage = 'A channel name is required to view a specific channel.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ name: channelName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Please name the channel:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const getUpdateChannelByNameForAppresult = await getUpdateChannelByNameForAppAsync({
|
||||
appId: projectId,
|
||||
channelName,
|
||||
});
|
||||
const channel = getUpdateChannelByNameForAppresult.app?.byId.updateChannelByName;
|
||||
if (!channel) {
|
||||
throw new Error(`Could not fine channel with name ${channelName}`);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
Log.log(JSON.stringify(channel));
|
||||
return;
|
||||
}
|
||||
|
||||
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(
|
||||
getUpdateChannelByNameForAppresult
|
||||
);
|
||||
|
||||
const table = new Table({
|
||||
head: [
|
||||
'branch',
|
||||
...(isRollout ? ['rollout percent'] : []),
|
||||
'active update group',
|
||||
'update message',
|
||||
'last publish',
|
||||
'actor',
|
||||
],
|
||||
wordWrap: true,
|
||||
});
|
||||
|
||||
for (const index in branchMapping.data) {
|
||||
if (parseInt(index, 10) > 1) {
|
||||
throw new Error('Branch Mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
const { branchId } = branchMapping.data[index];
|
||||
const branch = channel.updateBranches.filter(branch => branch.id === branchId)[0];
|
||||
if (!branch) {
|
||||
throw new Error('Branch mapping is pointing at a missing branch.');
|
||||
}
|
||||
const update = branch.updates[0];
|
||||
table.push([
|
||||
branch.name,
|
||||
...(isRollout
|
||||
? [
|
||||
parseInt(index, 10) === 0
|
||||
? `${rolloutPercent! * 100}%`
|
||||
: `${(1 - rolloutPercent!) * 100}%`,
|
||||
]
|
||||
: []),
|
||||
update?.group,
|
||||
update?.message,
|
||||
update?.createdAt && new Date(update.createdAt).toLocaleString(),
|
||||
update?.actor?.firstName,
|
||||
]);
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
chalk`Channel: {bold ${channel.name}} on project {bold ${accountName}/${slug}}. Channel ID: {bold ${channel.id}}`
|
||||
);
|
||||
Log.log(chalk`{bold Recent update groups published on this branch:}`);
|
||||
Log.log(table.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
@@ -21,7 +22,8 @@ export default class BuildList extends Command {
|
||||
let appleTeamIdentifier = this.parse(BuildList).flags['apple-team-id'];
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
let spinner: ora.Ora;
|
||||
|
||||
@@ -42,7 +44,9 @@ export default class BuildList extends Command {
|
||||
name: 'appleTeamIdentifier',
|
||||
message: 'What Apple Team would you like to list devices for?',
|
||||
choices: teams.map(team => ({
|
||||
title: `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`,
|
||||
title: team.appleTeamName
|
||||
? `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`
|
||||
: team.appleTeamIdentifier,
|
||||
value: team.appleTeamIdentifier,
|
||||
})),
|
||||
});
|
||||
@@ -71,7 +75,9 @@ export default class BuildList extends Command {
|
||||
if (result?.appleDevices.length) {
|
||||
const { appleTeamName, appleDevices } = result;
|
||||
|
||||
spinner.succeed(`Found ${appleDevices.length} devices for team ${appleTeamName}`);
|
||||
spinner.succeed(
|
||||
`Found ${appleDevices.length} devices for team ${appleTeamName ?? appleTeamIdentifier}`
|
||||
);
|
||||
|
||||
const list = appleDevices
|
||||
.map(device =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import ora from 'ora';
|
||||
|
||||
@@ -29,7 +30,8 @@ If you are not sure what is the UDID of the device you are looking for, run:
|
||||
}
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
const spinner = ora().start(`Fetching device details for ${UDID}…`);
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
|
||||
import Log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import {
|
||||
isEasEnabledForProjectAsync,
|
||||
warnEasUnavailable,
|
||||
} from '../../project/isEasEnabledForProject';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { findAccountByName } from '../../user/Account';
|
||||
import { getActorDisplayName } from '../../user/User';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
export enum EnvironmentSecretScope {
|
||||
ACCOUNT = 'account',
|
||||
PROJECT = 'project',
|
||||
}
|
||||
export default class EnvironmentSecretCreate extends Command {
|
||||
static description = 'Create an environment secret on the current project or owner account.';
|
||||
|
||||
static flags = {
|
||||
scope: flags.enum({
|
||||
description: 'Scope for the secret',
|
||||
options: [EnvironmentSecretScope.ACCOUNT, EnvironmentSecretScope.PROJECT],
|
||||
default: EnvironmentSecretScope.PROJECT,
|
||||
}),
|
||||
name: flags.string({
|
||||
description: 'Name of the secret',
|
||||
}),
|
||||
value: flags.string({
|
||||
description: 'Value of the secret',
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const actor = await ensureLoggedInAsync();
|
||||
let {
|
||||
flags: { name, value: secretValue, scope },
|
||||
} = this.parse(EnvironmentSecretCreate);
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
const { slug } = exp;
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scope) {
|
||||
const validationMessage = 'Secret scope may not be empty.';
|
||||
|
||||
({ scope } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'scope',
|
||||
message: 'Where should this secret be used:',
|
||||
choices: [
|
||||
{ title: 'Account-wide', value: EnvironmentSecretScope.ACCOUNT },
|
||||
{ title: 'Project-specific', value: EnvironmentSecretScope.PROJECT },
|
||||
],
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
({ name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: `Secret name:`,
|
||||
validate: value => {
|
||||
if (!value) {
|
||||
return 'Secret name may not be empty.';
|
||||
}
|
||||
|
||||
// this validation regex here is just to shorten the feedback loop
|
||||
// the source of truth is in www's EnvironmentSecretValidator class
|
||||
if (!value.match(/^\w+$/)) {
|
||||
return 'Names may contain only letters, numbers, and underscores.';
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
if (!name) throw new Error('Secret name may not be empty.');
|
||||
}
|
||||
|
||||
if (!secretValue) {
|
||||
const validationMessage = 'Secret value may not be empty.';
|
||||
|
||||
({ secretValue } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'secretValue',
|
||||
message: 'Secret value:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
|
||||
if (!secretValue) throw new Error(validationMessage);
|
||||
}
|
||||
|
||||
if (scope === EnvironmentSecretScope.PROJECT) {
|
||||
const secret = await EnvironmentSecretMutation.createForApp(
|
||||
{ name, value: secretValue },
|
||||
projectId
|
||||
);
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`Could not create secret with name ${name} on project with id ${projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Created a new secret ${chalk.bold(name)} on project ${chalk.bold(
|
||||
`@${accountName}/${slug}`
|
||||
)}.`
|
||||
);
|
||||
} else if (scope === EnvironmentSecretScope.ACCOUNT) {
|
||||
const ownerAccount = findAccountByName(actor.accounts, accountName);
|
||||
if (!ownerAccount) {
|
||||
Log.warn(
|
||||
`Your account (${getActorDisplayName(actor)}) doesn't have access to the ${chalk.bold(
|
||||
accountName
|
||||
)} account`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = await EnvironmentSecretMutation.createForAccount(
|
||||
{ name, value: secretValue },
|
||||
ownerAccount.id
|
||||
);
|
||||
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`Could not create secret with name ${name} on account with id ${ownerAccount.id}`
|
||||
);
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`️Created a new secret ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
|
||||
import {
|
||||
EnvironmentSecretWithScope,
|
||||
EnvironmentSecretsQuery,
|
||||
} from '../../graphql/queries/EnvironmentSecretsQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
isEasEnabledForProjectAsync,
|
||||
warnEasUnavailable,
|
||||
} from '../../project/isEasEnabledForProject';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../prompts';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { EnvironmentSecretScope } from './create';
|
||||
|
||||
export default class EnvironmentSecretDelete extends Command {
|
||||
static description = `Delete an environment secret by ID.
|
||||
Unsure where to find the secret's ID? Run ${chalk.bold('eas secrets:list')}`;
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'id',
|
||||
required: false,
|
||||
description: `ID of the secret to delete`,
|
||||
},
|
||||
];
|
||||
|
||||
async run() {
|
||||
await ensureLoggedInAsync();
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectFullName = await getProjectFullNameAsync(exp);
|
||||
const projectAccountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let {
|
||||
args: { id },
|
||||
} = this.parse(EnvironmentSecretDelete);
|
||||
let secret: EnvironmentSecretWithScope | undefined;
|
||||
|
||||
if (!id) {
|
||||
const secrets = await EnvironmentSecretsQuery.allAsync(projectAccountName, projectFullName);
|
||||
|
||||
({ secret } = await promptAsync({
|
||||
type: 'autocomplete',
|
||||
name: 'secret',
|
||||
message: 'Pick the secret to be deleted:',
|
||||
choices: secrets.map(secret => ({
|
||||
title: `${secret.name} (${secret.scope})`,
|
||||
value: secret,
|
||||
})),
|
||||
}));
|
||||
|
||||
id = secret?.id;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`You are about to permamently delete secret${
|
||||
secret?.name ? ` "${secret?.name}"` : ''
|
||||
} with id: "${id}".\nThis action is irreversible.`
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({
|
||||
message: `Are you sure you wish to proceed?${
|
||||
secret?.scope === EnvironmentSecretScope.ACCOUNT
|
||||
? ' This secret is applied across your whole account and may affect multiple apps.'
|
||||
: ''
|
||||
}`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.error(
|
||||
`Canceled deletion of secret${secret?.name ? ` "${secret?.name}"` : ''} with id: "${id}".`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await EnvironmentSecretMutation.delete(id);
|
||||
|
||||
Log.withTick(`️Deleted secret${secret?.name ? ` "${secret?.name}"` : ''} with id "${id}".`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
import { EnvironmentSecretsQuery } from '../../graphql/queries/EnvironmentSecretsQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
isEasEnabledForProjectAsync,
|
||||
warnEasUnavailable,
|
||||
} from '../../project/isEasEnabledForProject';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
export default class EnvironmentSecretsList extends Command {
|
||||
static description = 'Lists environment secrets available for your current app';
|
||||
static usage = 'secrets:list';
|
||||
|
||||
async run(): Promise<void> {
|
||||
await ensureLoggedInAsync();
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectFullName = await getProjectFullNameAsync(exp);
|
||||
const projectAccountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectDir) {
|
||||
throw new Error("Please run this command inside your project's directory");
|
||||
}
|
||||
|
||||
const secrets = await EnvironmentSecretsQuery.allAsync(projectAccountName, projectFullName);
|
||||
|
||||
const table = new Table({
|
||||
head: ['Name', 'Scope', 'ID', 'Updated at'],
|
||||
wordWrap: true,
|
||||
});
|
||||
|
||||
for (const secret of secrets) {
|
||||
const { name, createdAt: updatedAt, scope, id } = secret;
|
||||
table.push([name, scope, id, dateFormat(updatedAt, 'mmm dd HH:MM:ss')]);
|
||||
}
|
||||
|
||||
Log.log(chalk`{bold Secrets for this account and project:}`);
|
||||
Log.log(table.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
|
||||
import { learnMore } from '../log';
|
||||
@@ -168,7 +169,8 @@ export default class BuildSubmit extends Command {
|
||||
const platform = platformFromParams ?? (await promptForPlatformAsync());
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import ora from 'ora';
|
||||
|
||||
import { WebhookType } from '../../graphql/generated';
|
||||
import { WebhookMutation } from '../../graphql/mutations/WebhookMutation';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { prepareInputParamsAsync } from '../../webhooks/input';
|
||||
|
||||
export default class WebhookCreate extends Command {
|
||||
static description = 'Create a webhook on the current project.';
|
||||
|
||||
static flags = {
|
||||
event: flags.enum({
|
||||
description: 'Event type that triggers the webhook',
|
||||
options: [WebhookType.Build],
|
||||
default: WebhookType.Build,
|
||||
}),
|
||||
url: flags.string({
|
||||
description: 'Webhook URL',
|
||||
}),
|
||||
secret: flags.string({
|
||||
description:
|
||||
"Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature' header.",
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
await ensureLoggedInAsync();
|
||||
const { flags } = this.parse(WebhookCreate);
|
||||
const webhookInputParams = await prepareInputParamsAsync(flags);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
const spinner = ora('Creating a webhook').start();
|
||||
try {
|
||||
await WebhookMutation.createWebhookAsync(projectId, webhookInputParams);
|
||||
spinner.succeed('Successfully created a webhook');
|
||||
} catch (err) {
|
||||
spinner.fail('Failed to create a webhook');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
import ora from 'ora';
|
||||
|
||||
import { WebhookFragment } from '../../graphql/generated';
|
||||
import { WebhookMutation } from '../../graphql/mutations/WebhookMutation';
|
||||
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
|
||||
import Log from '../../log';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../prompts';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { formatWebhook } from '../../webhooks/formatWebhook';
|
||||
|
||||
export default class WebhookDelete extends Command {
|
||||
static description = 'Delete a webhook on the current project.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'ID',
|
||||
required: false,
|
||||
description: 'ID of the webhook to delete',
|
||||
},
|
||||
];
|
||||
|
||||
async run() {
|
||||
await ensureLoggedInAsync();
|
||||
let {
|
||||
args: { ID: webhookId },
|
||||
} = this.parse(WebhookDelete);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
let webhook: WebhookFragment | undefined =
|
||||
webhookId && (await WebhookQuery.byIdAsync(webhookId));
|
||||
if (!webhookId) {
|
||||
const webhooks = await fetchWebhooksByAppIdAsync(projectId);
|
||||
if (webhooks.length === 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
({ webhook } = await promptAsync({
|
||||
type: 'autocomplete',
|
||||
name: 'webhook',
|
||||
message: 'Pick the webhook to be deleted:',
|
||||
choices: webhooks.map(i => ({
|
||||
title: `${chalk.bold(i.url)} (Event: ${i.event}, ID: ${i.id})`,
|
||||
value: i,
|
||||
})),
|
||||
}));
|
||||
webhookId = nullthrows(webhook).id;
|
||||
}
|
||||
|
||||
assert(webhook, 'Webhook must be defined here');
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatWebhook(webhook));
|
||||
Log.newLine();
|
||||
Log.warn(`You are about to permamently delete this webhook.\nThis action is irreversible.`);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({
|
||||
message: 'Are you sure you wish to proceed?',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
Log.error(`Canceled deletion of the webhook`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spinner = ora('Deleting the webhook').start();
|
||||
try {
|
||||
await WebhookMutation.deleteWebhookAsync(webhookId);
|
||||
spinner.succeed('Successfully deleted the webhook');
|
||||
} catch (err) {
|
||||
spinner.fail('Failed to delete the webhook');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWebhooksByAppIdAsync(appId: string): Promise<WebhookFragment[]> {
|
||||
const spinner = ora('Fetching webhooks').start();
|
||||
try {
|
||||
const webhooks = await WebhookQuery.byAppIdAsync(appId);
|
||||
if (webhooks.length === 0) {
|
||||
spinner.fail('There are no webhooks on the project');
|
||||
return [];
|
||||
} else {
|
||||
spinner.succeed(`Successfully fetched ${webhooks.length} webhooks`);
|
||||
return webhooks;
|
||||
}
|
||||
} catch (err) {
|
||||
spinner.fail("Something went wrong and we couldn't fetch the webhook list");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
import { WebhookType } from '../../graphql/generated';
|
||||
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { formatWebhook } from '../../webhooks/formatWebhook';
|
||||
|
||||
export default class WebhookList extends Command {
|
||||
static description = 'List webhooks on the current project.';
|
||||
|
||||
static flags = {
|
||||
event: flags.enum({
|
||||
description: 'Event type that triggers the webhook',
|
||||
options: [WebhookType.Build],
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
await ensureLoggedInAsync();
|
||||
const {
|
||||
flags: { event },
|
||||
} = this.parse(WebhookList);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectFullName = await getProjectFullNameAsync(exp);
|
||||
|
||||
const spinner = ora(`Fetching the list of webhook on project ${projectFullName}`).start();
|
||||
try {
|
||||
const webhooks = await WebhookQuery.byAppIdAsync(projectId, event && { event });
|
||||
if (webhooks.length === 0) {
|
||||
spinner.fail(`There are no webhooks on project ${projectFullName}`);
|
||||
} else {
|
||||
spinner.succeed(`Found ${webhooks.length} webhooks on project ${projectFullName}`);
|
||||
const list = webhooks
|
||||
.map(webhook => formatWebhook(webhook))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`);
|
||||
Log.log(`\n${list}`);
|
||||
}
|
||||
} catch (err) {
|
||||
spinner.fail(
|
||||
`Something went wrong and we couldn't fetch the webhook list for the project ${projectFullName}`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import pick from 'lodash/pick';
|
||||
import ora from 'ora';
|
||||
|
||||
import { WebhookType } from '../../graphql/generated';
|
||||
import { WebhookMutation } from '../../graphql/mutations/WebhookMutation';
|
||||
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { prepareInputParamsAsync } from '../../webhooks/input';
|
||||
|
||||
export default class WebhookUpdate extends Command {
|
||||
static description = 'Create a webhook on the current project.';
|
||||
|
||||
static flags = {
|
||||
id: flags.string({
|
||||
description: 'Webhook ID',
|
||||
required: true,
|
||||
}),
|
||||
event: flags.enum({
|
||||
description: 'Event type that triggers the webhook',
|
||||
options: [WebhookType.Build],
|
||||
default: WebhookType.Build,
|
||||
}),
|
||||
url: flags.string({
|
||||
description: 'Webhook URL',
|
||||
}),
|
||||
secret: flags.string({
|
||||
description:
|
||||
"Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature' header.",
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
await ensureLoggedInAsync();
|
||||
const { flags } = this.parse(WebhookUpdate);
|
||||
|
||||
const webhookId = flags.id;
|
||||
|
||||
const webhook = await WebhookQuery.byIdAsync(webhookId);
|
||||
const webhookInputParams = await prepareInputParamsAsync(
|
||||
pick(flags, ['event', 'url', 'secret']),
|
||||
webhook
|
||||
);
|
||||
|
||||
const spinner = ora('Updating a webhook').start();
|
||||
try {
|
||||
await WebhookMutation.updateWebhookAsync(webhookId, webhookInputParams);
|
||||
spinner.succeed('Successfully updated a webhook');
|
||||
} catch (err) {
|
||||
spinner.fail('Failed to update a webhook');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Command } from '@oclif/command';
|
||||
import ora from 'ora';
|
||||
|
||||
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
|
||||
import Log from '../../log';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { formatWebhook } from '../../webhooks/formatWebhook';
|
||||
|
||||
export default class WebhookView extends Command {
|
||||
static description = 'View a webhook on the current project.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'ID',
|
||||
required: true,
|
||||
description: 'ID of the webhook to view',
|
||||
},
|
||||
];
|
||||
|
||||
async run() {
|
||||
await ensureLoggedInAsync();
|
||||
const {
|
||||
args: { ID: webhookId },
|
||||
} = this.parse(WebhookView);
|
||||
|
||||
const spinner = ora(`Fetching the webhook details for ID ${webhookId}`).start();
|
||||
try {
|
||||
const webhook = await WebhookQuery.byIdAsync(webhookId);
|
||||
spinner.succeed(`Found the webhook details`);
|
||||
Log.log(`\n${formatWebhook(webhook)}`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Couldn't find the webhook with ID ${webhookId}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
import { AuthCtx } from '../ios/appstore/authenticate';
|
||||
|
||||
export const testAuthCtx: AuthCtx = {
|
||||
appleId: 'test-apple-id',
|
||||
appleIdPassword: 'test-apple-password',
|
||||
team: { id: 'test-team-id', name: 'test-team-name', inHouse: false },
|
||||
};
|
||||
|
||||
export function getAppstoreMock() {
|
||||
return {
|
||||
ensureAuthenticatedAsync: jest.fn(),
|
||||
|
||||
@@ -2,8 +2,12 @@ import {
|
||||
AppFragment,
|
||||
AppleAppIdentifierFragment,
|
||||
AppleDistributionCertificateFragment,
|
||||
AppleProvisioningProfileFragment,
|
||||
AppleTeamFragment,
|
||||
IosAppBuildCredentialsFragment,
|
||||
IosDistributionType,
|
||||
} from '../../graphql/generated';
|
||||
import { IosAppCredentialsWithBuildCredentialsQueryResult } from '../ios/api/graphql/queries/IosAppCredentialsQuery';
|
||||
import {
|
||||
DistributionCertificate,
|
||||
DistributionCertificateStoreInfo,
|
||||
@@ -56,6 +60,17 @@ export const testAppFragment: AppFragment = {
|
||||
slug: 'testapp',
|
||||
};
|
||||
|
||||
export const testProvisioningProfileFragment: AppleProvisioningProfileFragment = {
|
||||
id: 'test-prov-prof-id-1',
|
||||
expiration: new Date(),
|
||||
developerPortalIdentifier: 'test-developer-identifier',
|
||||
provisioningProfile: 'test-provisioning-profile',
|
||||
updatedAt: new Date(),
|
||||
status: 'Active',
|
||||
appleTeam: { ...testAppleTeamFragment },
|
||||
appleDevices: [],
|
||||
};
|
||||
|
||||
export const testDistCertFragmentNoDependencies: AppleDistributionCertificateFragment = {
|
||||
id: 'test-dist-cert-id-1',
|
||||
certificateP12: 'Y2VydHAxMg==',
|
||||
@@ -95,6 +110,18 @@ export const testDistCertFragmentOneDependency: AppleDistributionCertificateFrag
|
||||
],
|
||||
};
|
||||
|
||||
export const testIosAppBuildCredentialsFragment: IosAppBuildCredentialsFragment = {
|
||||
id: 'test-ios-app-build-credentials-id',
|
||||
iosDistributionType: IosDistributionType.AppStore,
|
||||
distributionCertificate: testDistCertFragmentNoDependencies,
|
||||
provisioningProfile: testProvisioningProfileFragment,
|
||||
};
|
||||
|
||||
export const testIosAppCredentialsWithBuildCredentialsQueryResult: IosAppCredentialsWithBuildCredentialsQueryResult = {
|
||||
id: 'test-app-credential-id',
|
||||
iosAppBuildCredentialsArray: [testIosAppBuildCredentialsFragment],
|
||||
};
|
||||
|
||||
export const testDistCert: DistributionCertificate = {
|
||||
certP12: 'Y2VydHAxMg==',
|
||||
certPassword: 'test-password',
|
||||
|
||||
@@ -106,7 +106,7 @@ export function getNewIosApiMockWithoutCredentials() {
|
||||
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
|
||||
createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
|
||||
createOrGetExistingAppleTeamAsync: jest.fn(),
|
||||
createOrGetExistingAppleTeamAsync: () => testAppleTeam,
|
||||
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
|
||||
getDevicesForAppleTeamAsync: jest.fn(),
|
||||
createProvisioningProfileAsync: jest.fn(),
|
||||
|
||||
@@ -24,7 +24,7 @@ interface Options {
|
||||
}
|
||||
|
||||
export default class AndroidCredentialsProvider implements CredentialsProvider {
|
||||
public readonly platform = Platform.Android;
|
||||
public readonly platform = Platform.ANDROID;
|
||||
|
||||
constructor(private ctx: Context, private options: Options) {}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import pick from 'lodash/pick';
|
||||
import Log from '../log';
|
||||
import { getProjectAccountName } from '../project/projectUtils';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { Actor } from '../user/User';
|
||||
import { ensureLoggedInAsync, getActorDisplayName } from '../user/actions';
|
||||
import { Actor, getActorDisplayName } from '../user/User';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import AndroidApi from './android/api/Client';
|
||||
import iOSApi from './ios/api/Client';
|
||||
import * as IosGraphqlClient from './ios/api/GraphqlClient';
|
||||
|
||||
@@ -227,8 +227,8 @@ describe('credentialsJson', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('readSecretEnvsAsync', () => {
|
||||
it('should read secretEnvs field correctly', async () => {
|
||||
describe('readEnvironmentSecretsAsync', () => {
|
||||
it('should read environmentSecrets field correctly', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
@@ -245,7 +245,7 @@ describe('credentialsJson', () => {
|
||||
'./pprofile': 'somebinarycontent',
|
||||
'./cert.p12': 'somebinarycontent2',
|
||||
});
|
||||
const result = await credentialsJsonReader.readSecretEnvsAsync('.');
|
||||
const result = await credentialsJsonReader.readEnvironmentSecretsAsync('.');
|
||||
expect(result).toEqual({
|
||||
NPM_TOKEN: 'VALUE',
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ async function readCredentialsForTargetAsync(
|
||||
};
|
||||
}
|
||||
|
||||
export async function readSecretEnvsAsync(
|
||||
export async function readEnvironmentSecretsAsync(
|
||||
projectDir: string
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!(await fileExistsAsync(projectDir))) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { CredentialsSource, DistributionType } from '@expo/eas-json';
|
||||
import { CredentialsSource, iOSDistributionType } from '@expo/eas-json';
|
||||
|
||||
import { IosDistributionType } from '../../graphql/generated';
|
||||
import { AppleTeamFragment, IosDistributionType } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findAccountByName } from '../../user/Account';
|
||||
import { CredentialsManager } from '../CredentialsManager';
|
||||
@@ -10,6 +10,9 @@ import { Context } from '../context';
|
||||
import * as credentialsJsonReader from '../credentialsJson/read';
|
||||
import type { IosCredentials } from '../credentialsJson/read';
|
||||
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
|
||||
import { resolveAppleTeamIfAuthenticatedAsync } from './actions/new/AppleTeamUtils';
|
||||
import { AppleTeamMissingError, MissingCredentialsNonInteractiveError } from './errors';
|
||||
import { isAdHocProfile } from './utils/provisioningProfile';
|
||||
|
||||
export { IosCredentials };
|
||||
|
||||
@@ -23,7 +26,7 @@ interface PartialIosCredentials {
|
||||
|
||||
interface Options {
|
||||
app: AppLookupParams;
|
||||
distribution: DistributionType;
|
||||
distribution: iOSDistributionType;
|
||||
skipCredentialsCheck?: boolean;
|
||||
}
|
||||
|
||||
@@ -34,11 +37,16 @@ interface AppLookupParams {
|
||||
}
|
||||
|
||||
export default class IosCredentialsProvider implements CredentialsProvider {
|
||||
public readonly platform = Platform.iOS;
|
||||
public readonly platform = Platform.IOS;
|
||||
|
||||
constructor(private ctx: Context, private options: Options) {}
|
||||
|
||||
public async hasRemoteAsync(): Promise<boolean> {
|
||||
// TODO: this is temporary
|
||||
// remove this check when we implement syncing local credentials for internal distribution
|
||||
if (this.options.distribution === 'internal' && (await this.hasLocalAsync())) {
|
||||
return false;
|
||||
}
|
||||
const { distributionCertificate, provisioningProfile } = await this.fetchRemoteAsync();
|
||||
return !!(
|
||||
distributionCertificate?.certP12 ||
|
||||
@@ -48,10 +56,6 @@ export default class IosCredentialsProvider implements CredentialsProvider {
|
||||
}
|
||||
|
||||
public async hasLocalAsync(): Promise<boolean> {
|
||||
if (this.options.distribution === DistributionType.INTERNAL) {
|
||||
// TODO: add support for using credentials.json for internal distribution
|
||||
return false;
|
||||
}
|
||||
if (!(await credentialsJsonReader.fileExistsAsync(this.ctx.projectDir))) {
|
||||
return false;
|
||||
}
|
||||
@@ -102,11 +106,32 @@ export default class IosCredentialsProvider implements CredentialsProvider {
|
||||
}
|
||||
|
||||
private async getLocalAsync(): Promise<IosCredentials> {
|
||||
if (this.options.distribution === DistributionType.INTERNAL) {
|
||||
// TODO: add support for using credentials.json for internal distribution
|
||||
throw new Error('Using credentials.json for internal distribution is not supported yet.');
|
||||
const credentials = await credentialsJsonReader.readIosCredentialsAsync(this.ctx.projectDir);
|
||||
if (credentialsJsonReader.isCredentialsMap(credentials)) {
|
||||
for (const targetName of Object.keys(credentials)) {
|
||||
this.assertProvisioningProfileType(credentials[targetName].provisioningProfile, targetName);
|
||||
}
|
||||
} else {
|
||||
this.assertProvisioningProfileType(credentials.provisioningProfile);
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
private assertProvisioningProfileType(provisionigProfile: string, targetName?: string) {
|
||||
const isAdHoc = isAdHocProfile(provisionigProfile);
|
||||
if (this.options.distribution === 'internal' && !isAdHoc) {
|
||||
throw new Error(
|
||||
`You must use an adhoc provisioning profile${
|
||||
targetName ? ` (target '${targetName})'` : ''
|
||||
} for internal distribution`
|
||||
);
|
||||
} else if (this.options.distribution !== 'internal' && isAdHoc) {
|
||||
throw new Error(
|
||||
`You can't use an adhoc provisioning profile${
|
||||
targetName ? ` (target '${targetName}')` : ''
|
||||
} for app store distribution`
|
||||
);
|
||||
}
|
||||
return await credentialsJsonReader.readIosCredentialsAsync(this.ctx.projectDir);
|
||||
}
|
||||
|
||||
private async getRemoteAsync(): Promise<IosCredentials> {
|
||||
@@ -147,7 +172,7 @@ export default class IosCredentialsProvider implements CredentialsProvider {
|
||||
}
|
||||
|
||||
private async fetchRemoteAsync(): Promise<PartialIosCredentials> {
|
||||
if (this.options.distribution === DistributionType.INTERNAL) {
|
||||
if (this.options.distribution === 'internal') {
|
||||
const { app } = this.options;
|
||||
const account = findAccountByName(this.ctx.user.accounts, app.accountName);
|
||||
if (!account) {
|
||||
@@ -160,31 +185,36 @@ export default class IosCredentialsProvider implements CredentialsProvider {
|
||||
projectName: this.options.app.projectName,
|
||||
};
|
||||
|
||||
// for now, let's require the user to authenticate with Apple
|
||||
const { team } = await this.ctx.appStore.ensureAuthenticatedAsync();
|
||||
const appleTeam = await this.ctx.newIos.createOrGetExistingAppleTeamAsync(appLookupParams, {
|
||||
appleTeamIdentifier: team.id,
|
||||
appleTeamName: team.name,
|
||||
});
|
||||
const [distCert, provisioningProfile] = await Promise.all([
|
||||
this.ctx.newIos.getDistributionCertificateForAppAsync(
|
||||
appLookupParams,
|
||||
appleTeam,
|
||||
IosDistributionType.AdHoc
|
||||
),
|
||||
this.ctx.newIos.getProvisioningProfileAsync(
|
||||
appLookupParams,
|
||||
appleTeam,
|
||||
IosDistributionType.AdHoc
|
||||
),
|
||||
]);
|
||||
return {
|
||||
provisioningProfile: provisioningProfile?.provisioningProfile ?? undefined,
|
||||
distributionCertificate: {
|
||||
certP12: distCert?.certificateP12 ?? undefined,
|
||||
certPassword: distCert?.certificatePassword ?? undefined,
|
||||
},
|
||||
};
|
||||
let appleTeam: AppleTeamFragment | null = null;
|
||||
if (!this.ctx.nonInteractive) {
|
||||
await this.ctx.appStore.ensureAuthenticatedAsync();
|
||||
appleTeam = await resolveAppleTeamIfAuthenticatedAsync(this.ctx, appLookupParams);
|
||||
}
|
||||
|
||||
try {
|
||||
const [distCert, provisioningProfile] = await Promise.all([
|
||||
this.ctx.newIos.getDistributionCertificateForAppAsync(
|
||||
appLookupParams,
|
||||
IosDistributionType.AdHoc,
|
||||
{ appleTeam }
|
||||
),
|
||||
this.ctx.newIos.getProvisioningProfileAsync(appLookupParams, IosDistributionType.AdHoc, {
|
||||
appleTeam,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
provisioningProfile: provisioningProfile?.provisioningProfile ?? undefined,
|
||||
distributionCertificate: {
|
||||
certP12: distCert?.certificateP12 ?? undefined,
|
||||
certPassword: distCert?.certificatePassword ?? undefined,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AppleTeamMissingError && this.ctx.nonInteractive) {
|
||||
throw new MissingCredentialsNonInteractiveError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
const [distCert, provisioningProfile] = await Promise.all([
|
||||
this.ctx.ios.getDistributionCertificateAsync(this.options.app),
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import Log from '../../../log';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
import { Context } from '../../context';
|
||||
import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
|
||||
import { ProvisioningProfile } from '../appstore/Credentials.types';
|
||||
import { AppLookupParams, provisioningProfileSchema } from '../credentials';
|
||||
import { displayIosAppCredentials } from '../utils/printCredentials';
|
||||
import { generateProvisioningProfileAsync } from './ProvisioningProfileUtils';
|
||||
|
||||
export class CreateProvisioningProfileStandaloneManager implements Action {
|
||||
constructor(private app: AppLookupParams) {}
|
||||
|
||||
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
await manager.runActionAsync(new CreateProvisioningProfile(this.app));
|
||||
|
||||
const appCredentials = await ctx.ios.getAppCredentialsAsync(this.app);
|
||||
displayIosAppCredentials(appCredentials);
|
||||
Log.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateProvisioningProfile implements Action {
|
||||
constructor(private app: AppLookupParams) {}
|
||||
|
||||
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
const provisioningProfile = await this.provideOrGenerateAsync(ctx);
|
||||
await ctx.ios.updateProvisioningProfileAsync(this.app, provisioningProfile);
|
||||
|
||||
Log.succeed('Created provisioning profile');
|
||||
}
|
||||
|
||||
private async provideOrGenerateAsync(ctx: Context): Promise<ProvisioningProfile> {
|
||||
const userProvided = await askForUserProvidedAsync(provisioningProfileSchema);
|
||||
if (userProvided) {
|
||||
// userProvided profiles don't come with ProvisioningProfileId's (only accessible from Apple Portal API)
|
||||
Log.warn('Provisioning profile: Unable to validate specified profile.');
|
||||
return userProvided;
|
||||
}
|
||||
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
|
||||
assert(distCert, 'missing distribution certificate');
|
||||
return await generateProvisioningProfileAsync(ctx, this.app.bundleIdentifier, distCert);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DistributionType } from '@expo/eas-json';
|
||||
import { iOSDistributionType } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -12,8 +12,8 @@ import { isCredentialsMap, readIosCredentialsAsync } from '../../credentialsJson
|
||||
import { AppLookupParams, IosAppCredentials, IosDistCredentials } from '../credentials';
|
||||
import { displayProjectCredentials } from '../utils/printCredentials';
|
||||
import { readAppleTeam } from '../utils/provisioningProfile';
|
||||
import { SetupProvisioningProfile } from './SetupProvisioningProfile';
|
||||
import { SetupAdhocProvisioningProfile } from './new/SetupAdhocProvisioningProfile';
|
||||
import { SetupProvisioningProfile } from './new/SetupProvisioningProfile';
|
||||
|
||||
type AppCredentialsAndDistCert = {
|
||||
appCredentials: IosAppCredentials;
|
||||
@@ -21,7 +21,7 @@ type AppCredentialsAndDistCert = {
|
||||
};
|
||||
|
||||
export class SetupBuildCredentials implements Action {
|
||||
constructor(private app: AppLookupParams, private distribution: DistributionType) {}
|
||||
constructor(private app: AppLookupParams, private distribution: iOSDistributionType) {}
|
||||
|
||||
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
await ctx.bestEffortAppStoreAuthenticateAsync();
|
||||
@@ -32,16 +32,11 @@ export class SetupBuildCredentials implements Action {
|
||||
|
||||
let iosAppBuildCredentials: IosAppBuildCredentialsFragment | null = null;
|
||||
try {
|
||||
if (this.distribution === DistributionType.INTERNAL) {
|
||||
const account = findAccountByName(ctx.user.accounts, this.app.accountName);
|
||||
if (!account) {
|
||||
throw new Error(`You do not have access to the ${this.app.accountName} account`);
|
||||
}
|
||||
if (ctx.nonInteractive) {
|
||||
throw new Error('Internal distribution builds are not supported in non-interactive mode');
|
||||
}
|
||||
// for now, let's require the user to authenticate with Apple
|
||||
await ctx.appStore.ensureAuthenticatedAsync();
|
||||
const account = findAccountByName(ctx.user.accounts, this.app.accountName);
|
||||
if (!account) {
|
||||
throw new Error(`You do not have access to the ${this.app.accountName} account`);
|
||||
}
|
||||
if (this.distribution === 'internal') {
|
||||
const action = new SetupAdhocProvisioningProfile({
|
||||
account,
|
||||
projectName: this.app.projectName,
|
||||
@@ -50,7 +45,12 @@ export class SetupBuildCredentials implements Action {
|
||||
await manager.runActionAsync(action);
|
||||
iosAppBuildCredentials = action.iosAppBuildCredentials;
|
||||
} else {
|
||||
await manager.runActionAsync(new SetupProvisioningProfile(this.app));
|
||||
const setupProvisioningProfileAction = new SetupProvisioningProfile({
|
||||
account,
|
||||
projectName: this.app.projectName,
|
||||
bundleIdentifier: this.app.bundleIdentifier,
|
||||
});
|
||||
await setupProvisioningProfileAction.runAsync(manager, ctx);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error('Failed to setup credentials.');
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import Log from '../../../log';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
import { Context } from '../../context';
|
||||
import { AppLookupParams } from '../credentials';
|
||||
import { validateDistributionCertificateAsync } from '../validators/validateDistributionCertificate';
|
||||
import { CreateDistributionCertificate } from './CreateDistributionCertificate';
|
||||
import {
|
||||
formatDistributionCertificate,
|
||||
getValidDistCertsAsync,
|
||||
} from './DistributionCertificateUtils';
|
||||
import {
|
||||
UseExistingDistributionCertificate,
|
||||
UseSpecificDistributionCertificate,
|
||||
} from './UseDistributionCertificate';
|
||||
|
||||
export class SetupDistributionCertificateForApp implements Action {
|
||||
constructor(private app: AppLookupParams) {}
|
||||
|
||||
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
if (await this.isCurrentCertificateValidAsync(ctx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.nonInteractive) {
|
||||
throw new Error(
|
||||
'Distribution Certificate is not configured correctly. Please run this command again in interactive mode.'
|
||||
);
|
||||
}
|
||||
|
||||
const existingCertificates = await getValidDistCertsAsync(ctx, this.app.accountName);
|
||||
|
||||
if (existingCertificates.length === 0) {
|
||||
await this.createNewCertificateAsync(manager, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// autoselect credentials if we find valid certs
|
||||
const autoselectedCertificate = existingCertificates[0];
|
||||
|
||||
const credentials = await ctx.ios.getAllCredentialsAsync(this.app.accountName);
|
||||
const usedByApps = credentials.appCredentials.filter(
|
||||
cred => cred.distCredentialsId === autoselectedCertificate.id
|
||||
);
|
||||
const confirm = await confirmAsync({
|
||||
message: `${formatDistributionCertificate(
|
||||
autoselectedCertificate,
|
||||
usedByApps,
|
||||
ctx.appStore.authCtx ? 'VALID' : 'UNKNOWN'
|
||||
)} \n Would you like to use this certificate?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
await this.createOrReuseAsync(manager, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log(`Using Distribution Certificate: ${autoselectedCertificate.certId || '-----'}`);
|
||||
await manager.runActionAsync(
|
||||
new UseSpecificDistributionCertificate(this.app, autoselectedCertificate.id)
|
||||
);
|
||||
}
|
||||
|
||||
private async isCurrentCertificateValidAsync(ctx: Context): Promise<boolean> {
|
||||
const currentCertificate = await ctx.ios.getDistributionCertificateAsync(this.app);
|
||||
if (!currentCertificate) {
|
||||
return false;
|
||||
}
|
||||
if (ctx.appStore.authCtx) {
|
||||
const isValid = await validateDistributionCertificateAsync(ctx, currentCertificate);
|
||||
if (!isValid) {
|
||||
Log.warn("Current Distribution Certificate is no longer valid on Apple's server");
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async createNewCertificateAsync(
|
||||
manager: CredentialsManager,
|
||||
ctx: Context
|
||||
): Promise<void> {
|
||||
const action = new CreateDistributionCertificate(this.app.accountName);
|
||||
await manager.runActionAsync(action);
|
||||
await manager.runActionAsync(
|
||||
new UseSpecificDistributionCertificate(this.app, action.distCredentials.id)
|
||||
);
|
||||
}
|
||||
|
||||
private async createOrReuseAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
const { action } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'action',
|
||||
message: 'Select an iOS Distribution Certificate to use for code signing:',
|
||||
choices: [
|
||||
{
|
||||
title: '[Choose existing certificate] (Recommended)',
|
||||
value: 'CHOOSE_EXISTING',
|
||||
},
|
||||
{ title: '[Add a new certificate]', value: 'GENERATE' },
|
||||
],
|
||||
});
|
||||
|
||||
if (action === 'GENERATE') {
|
||||
await this.createNewCertificateAsync(manager, ctx);
|
||||
} else if (action === 'CHOOSE_EXISTING') {
|
||||
await manager.runActionAsync(new UseExistingDistributionCertificate(this.app));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import assert from 'assert';
|
||||
import pick from 'lodash/pick';
|
||||
|
||||
import Log from '../../../log';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
import { Context } from '../../context';
|
||||
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
|
||||
import { AppLookupParams, IosDistCredentials } from '../credentials';
|
||||
import {
|
||||
validateProvisioningProfileAsync,
|
||||
validateProvisioningProfileWithoutApple,
|
||||
} from '../validators/validateProvisioningProfile';
|
||||
import {
|
||||
ConfigureProvisioningProfile,
|
||||
UseExistingProvisioningProfile,
|
||||
} from './ConfigureProvisioningProfile';
|
||||
import { CreateProvisioningProfile } from './CreateProvisioningProfile';
|
||||
import { formatProvisioningProfileFromApple } from './ProvisioningProfileUtils';
|
||||
import { SetupDistributionCertificateForApp } from './SetupDistributionCertificate';
|
||||
|
||||
export class SetupProvisioningProfile implements Action {
|
||||
constructor(private app: AppLookupParams) {}
|
||||
|
||||
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
await manager.runActionAsync(new SetupDistributionCertificateForApp(this.app));
|
||||
if (await this.isCurrentProfileValidAsync(ctx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.nonInteractive) {
|
||||
throw new Error(
|
||||
'Provisioning profile is not configured correctly. Please run this command again in interactive mode.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!ctx.appStore.authCtx) {
|
||||
await manager.runActionAsync(new CreateProvisioningProfile(this.app));
|
||||
return;
|
||||
}
|
||||
|
||||
const existingProfiles = await ctx.appStore.listProvisioningProfilesAsync(
|
||||
this.app.bundleIdentifier
|
||||
);
|
||||
|
||||
if (existingProfiles.length === 0) {
|
||||
await manager.runActionAsync(new CreateProvisioningProfile(this.app));
|
||||
return;
|
||||
}
|
||||
|
||||
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
|
||||
assert(distCert, 'missing distribution certificate');
|
||||
|
||||
const autoselectedProfile = this.choosePreferred(existingProfiles, distCert);
|
||||
// autoselect credentials if we find valid certs
|
||||
|
||||
if (!ctx.nonInteractive) {
|
||||
const confirm = await confirmAsync({
|
||||
message: `${formatProvisioningProfileFromApple(
|
||||
autoselectedProfile
|
||||
)} \n Would you like to use this profile?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
await this.createOrReuseAsync(manager, ctx);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log.log(`Using provisioning profile: ${autoselectedProfile.provisioningProfileId}`);
|
||||
}
|
||||
|
||||
await ctx.ios.updateProvisioningProfileAsync(
|
||||
this.app,
|
||||
pick(autoselectedProfile, [
|
||||
'provisioningProfile',
|
||||
'provisioningProfileId',
|
||||
'teamId',
|
||||
'teamName',
|
||||
])
|
||||
);
|
||||
await manager.runActionAsync(new ConfigureProvisioningProfile(this.app));
|
||||
}
|
||||
|
||||
private async isCurrentProfileValidAsync(ctx: Context): Promise<boolean> {
|
||||
const currentProfile = await ctx.ios.getProvisioningProfileAsync(this.app);
|
||||
const currentCertificate = await ctx.ios.getDistributionCertificateAsync(this.app);
|
||||
if (!currentProfile || !currentCertificate) {
|
||||
return false;
|
||||
}
|
||||
if (!ctx.appStore.authCtx || !currentProfile.provisioningProfileId) {
|
||||
if (!currentProfile.provisioningProfileId) {
|
||||
Log.warn(
|
||||
"The provisioning profile we have on file cannot be validated on Apple's servers."
|
||||
);
|
||||
}
|
||||
const validationResult = validateProvisioningProfileWithoutApple(
|
||||
currentProfile,
|
||||
currentCertificate,
|
||||
this.app.bundleIdentifier
|
||||
);
|
||||
if (!validationResult.ok) {
|
||||
Log.warn(validationResult.error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const validationResult = await validateProvisioningProfileAsync(
|
||||
ctx,
|
||||
currentProfile,
|
||||
currentCertificate,
|
||||
this.app.bundleIdentifier
|
||||
);
|
||||
if (!validationResult.ok) {
|
||||
Log.warn(validationResult.error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private choosePreferred(
|
||||
profiles: ProvisioningProfileStoreInfo[],
|
||||
distCert: IosDistCredentials
|
||||
): ProvisioningProfileStoreInfo {
|
||||
// prefer the profile that already has the same dist cert associated with it
|
||||
const profileWithSameCert = profiles.find(profile =>
|
||||
profile.certificates.some(cert => cert.id === distCert.certId)
|
||||
);
|
||||
|
||||
// if not, just get an arbitrary profile
|
||||
return profileWithSameCert || profiles[0];
|
||||
}
|
||||
|
||||
private async createOrReuseAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
const { action } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'action',
|
||||
message: 'Select a provisioning profile:',
|
||||
choices: [
|
||||
{
|
||||
title: '[Choose existing provisioning profile] (Recommended)',
|
||||
value: 'CHOOSE_EXISTING',
|
||||
},
|
||||
{ title: '[Add a new provisioning profile]', value: 'GENERATE' },
|
||||
],
|
||||
});
|
||||
|
||||
if (action === 'GENERATE') {
|
||||
await manager.runActionAsync(new CreateProvisioningProfile(this.app));
|
||||
} else if (action === 'CHOOSE_EXISTING') {
|
||||
await manager.runActionAsync(new UseExistingProvisioningProfile(this.app));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,11 @@ export class UpdateCredentialsJson implements Action {
|
||||
constructor(private app: AppLookupParams) {}
|
||||
|
||||
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
const bundleIdentifer = await ensureAppIdentifierIsDefinedAsync(ctx.projectDir, Platform.iOS);
|
||||
const bundleIdentifer = await ensureAppIdentifierIsDefinedAsync({
|
||||
projectDir: ctx.projectDir,
|
||||
platform: Platform.IOS,
|
||||
exp: ctx.exp,
|
||||
});
|
||||
Log.log('Updating iOS credentials in credentials.json');
|
||||
await updateIosCredentialsAsync(ctx, bundleIdentifer);
|
||||
Log.succeed(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AppleTeamFragment } from '../../../../graphql/generated';
|
||||
import { Context } from '../../../context';
|
||||
import { AppLookupParams } from '../../api/GraphqlClient';
|
||||
|
||||
export async function resolveAppleTeamIfAuthenticatedAsync(
|
||||
ctx: Context,
|
||||
app: AppLookupParams
|
||||
): Promise<AppleTeamFragment | null> {
|
||||
if (!ctx.appStore.authCtx) {
|
||||
return null;
|
||||
}
|
||||
return await ctx.newIos.createOrGetExistingAppleTeamAsync(app, {
|
||||
appleTeamIdentifier: ctx.appStore.authCtx.team.id,
|
||||
appleTeamName: ctx.appStore.authCtx.team.name,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
import {
|
||||
AppleDistributionCertificateFragment,
|
||||
AppleProvisioningProfileFragment,
|
||||
} from '../../../../graphql/generated';
|
||||
import Log from '../../../../log';
|
||||
import { Context } from '../../../context';
|
||||
import { AppLookupParams } from '../../api/GraphqlClient';
|
||||
import { AppleProvisioningProfileMutationResult } from '../../api/graphql/mutations/AppleProvisioningProfileMutation';
|
||||
import { ProvisioningProfileStoreInfo } from '../../appstore/Credentials.types';
|
||||
import { AuthCtx } from '../../appstore/authenticate';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../errors';
|
||||
|
||||
export class ConfigureProvisioningProfile {
|
||||
constructor(
|
||||
private app: AppLookupParams,
|
||||
private distributionCertificate: AppleDistributionCertificateFragment,
|
||||
private originalProvisioningProfile: AppleProvisioningProfileFragment
|
||||
) {}
|
||||
|
||||
public async runAsync(ctx: Context): Promise<AppleProvisioningProfileMutationResult | null> {
|
||||
if (ctx.nonInteractive) {
|
||||
throw new MissingCredentialsNonInteractiveError(
|
||||
'Configuring Provisioning Profiles is only supported in interactive mode.'
|
||||
);
|
||||
}
|
||||
const { developerPortalIdentifier, provisioningProfile } = this.originalProvisioningProfile;
|
||||
if (!developerPortalIdentifier && !provisioningProfile) {
|
||||
Log.warn("The provisioning profile we have on file cannot be configured on Apple's servers.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ctx.appStore.authCtx) {
|
||||
Log.warn(
|
||||
"Without access to your Apple account we can't configure provisioning profiles for you."
|
||||
);
|
||||
Log.warn('Make sure to recreate the profile if you selected a new distribution certificate.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const profilesFromApple = await ctx.appStore.listProvisioningProfilesAsync(
|
||||
this.app.bundleIdentifier
|
||||
);
|
||||
const [matchingProfile] = profilesFromApple.filter(appleInfo =>
|
||||
developerPortalIdentifier
|
||||
? appleInfo.provisioningProfileId === developerPortalIdentifier
|
||||
: appleInfo.provisioningProfile === provisioningProfile
|
||||
);
|
||||
if (!matchingProfile) {
|
||||
Log.warn(
|
||||
`Profile ${
|
||||
developerPortalIdentifier ? `${developerPortalIdentifier} ` : ''
|
||||
}not found on Apple Developer Portal.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.configureAndUpdateAsync(ctx, ctx.appStore.authCtx, this.app, matchingProfile);
|
||||
}
|
||||
|
||||
private async configureAndUpdateAsync(
|
||||
ctx: Context,
|
||||
authCtx: AuthCtx,
|
||||
app: AppLookupParams,
|
||||
profileFromApple: ProvisioningProfileStoreInfo
|
||||
): Promise<AppleProvisioningProfileMutationResult> {
|
||||
const {
|
||||
developerPortalIdentifier,
|
||||
certificateP12,
|
||||
certificatePassword,
|
||||
serialNumber,
|
||||
} = this.distributionCertificate;
|
||||
assert(
|
||||
certificateP12 && certificatePassword,
|
||||
'Distribution Certificate P12 and Password is required'
|
||||
);
|
||||
// configure profile on Apple's Server to use our distCert
|
||||
const updatedProfile = await ctx.appStore.useExistingProvisioningProfileAsync(
|
||||
app.bundleIdentifier,
|
||||
profileFromApple,
|
||||
{
|
||||
certId: developerPortalIdentifier ?? undefined,
|
||||
certP12: certificateP12,
|
||||
certPassword: certificatePassword,
|
||||
distCertSerialNumber: serialNumber,
|
||||
teamId: authCtx.appleId,
|
||||
}
|
||||
);
|
||||
|
||||
const bundleIdTag = `(${app.bundleIdentifier})`;
|
||||
const slugTag = `@${app.account.name}/${app.projectName}`;
|
||||
const projectTag = `${chalk.bold(slugTag)} ${chalk.dim(bundleIdTag)}`;
|
||||
|
||||
const spinner = ora(`Updating Expo profile for ${projectTag}`).start();
|
||||
try {
|
||||
const configuredProvisioningProfile = await ctx.newIos.updateProvisioningProfileAsync(
|
||||
this.originalProvisioningProfile.id,
|
||||
{
|
||||
appleProvisioningProfile: updatedProfile.provisioningProfile,
|
||||
developerPortalIdentifier: updatedProfile.provisioningProfileId,
|
||||
}
|
||||
);
|
||||
spinner.succeed(`Updated Expo profile for ${projectTag}`);
|
||||
return configuredProvisioningProfile;
|
||||
} catch (error) {
|
||||
spinner.fail();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'assert';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { AppleDistributionCertificateFragment } from '../../../../graphql/generated';
|
||||
import Log from '../../../../log';
|
||||
import { Context } from '../../../context';
|
||||
import { askForUserProvidedAsync } from '../../../utils/promptForCredentials';
|
||||
import { AppLookupParams } from '../../api/GraphqlClient';
|
||||
import { AppleProvisioningProfileMutationResult } from '../../api/graphql/mutations/AppleProvisioningProfileMutation';
|
||||
import { ProvisioningProfile } from '../../appstore/Credentials.types';
|
||||
import { AuthCtx } from '../../appstore/authenticate';
|
||||
import { provisioningProfileSchema } from '../../credentials';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../errors';
|
||||
import { generateProvisioningProfileAsync } from '../ProvisioningProfileUtils';
|
||||
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
|
||||
|
||||
export class CreateProvisioningProfile {
|
||||
constructor(
|
||||
private app: AppLookupParams,
|
||||
private distributionCertificate: AppleDistributionCertificateFragment
|
||||
) {}
|
||||
|
||||
async runAsync(ctx: Context): Promise<AppleProvisioningProfileMutationResult> {
|
||||
if (ctx.nonInteractive) {
|
||||
throw new MissingCredentialsNonInteractiveError(
|
||||
'Creating Provisioning Profiles is only supported in interactive mode.'
|
||||
);
|
||||
}
|
||||
const appleAuthCtx = await ctx.appStore.ensureAuthenticatedAsync();
|
||||
const provisioningProfile = await this.provideOrGenerateAsync(ctx, appleAuthCtx);
|
||||
const appleTeam = nullthrows(await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app));
|
||||
const appleAppIdentifier = await ctx.newIos.createOrGetExistingAppleAppIdentifierAsync(
|
||||
this.app,
|
||||
appleTeam
|
||||
);
|
||||
const provisioningProfileMutationResult = await ctx.newIos.createProvisioningProfileAsync(
|
||||
this.app,
|
||||
appleAppIdentifier,
|
||||
{
|
||||
appleProvisioningProfile: provisioningProfile.provisioningProfile,
|
||||
developerPortalIdentifier: provisioningProfile.provisioningProfileId,
|
||||
}
|
||||
);
|
||||
Log.succeed('Created provisioning profile');
|
||||
return provisioningProfileMutationResult;
|
||||
}
|
||||
|
||||
private async provideOrGenerateAsync(
|
||||
ctx: Context,
|
||||
appleAuthCtx: AuthCtx
|
||||
): Promise<ProvisioningProfile> {
|
||||
const userProvided = await askForUserProvidedAsync(provisioningProfileSchema);
|
||||
if (userProvided) {
|
||||
// userProvided profiles don't come with ProvisioningProfileId's (only accessible from Apple Portal API)
|
||||
Log.warn('Provisioning profile: Unable to validate specified profile.');
|
||||
return userProvided;
|
||||
}
|
||||
assert(
|
||||
this.distributionCertificate.certificateP12,
|
||||
'Distribution Certificate must have p12 certificate'
|
||||
);
|
||||
assert(
|
||||
this.distributionCertificate.certificatePassword,
|
||||
'Distribution Certificate must have certificate password'
|
||||
);
|
||||
return await generateProvisioningProfileAsync(ctx, this.app.bundleIdentifier, {
|
||||
certId: this.distributionCertificate.developerPortalIdentifier ?? undefined,
|
||||
certP12: this.distributionCertificate.certificateP12,
|
||||
certPassword: this.distributionCertificate.certificatePassword,
|
||||
distCertSerialNumber: this.distributionCertificate.serialNumber,
|
||||
teamId: this.distributionCertificate.appleTeam?.appleTeamIdentifier ?? appleAuthCtx.appleId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IosAppBuildCredentialsFragment,
|
||||
IosDistributionType,
|
||||
} from '../../../../graphql/generated';
|
||||
import Log from '../../../../log';
|
||||
import { confirmAsync } from '../../../../prompts';
|
||||
import { Action, CredentialsManager } from '../../../CredentialsManager';
|
||||
import { Context } from '../../../context';
|
||||
@@ -15,6 +16,8 @@ import { AppleDeviceFragmentWithAppleTeam } from '../../api/graphql/queries/Appl
|
||||
import { AppleProvisioningProfileQueryResult } from '../../api/graphql/queries/AppleProvisioningProfileQuery';
|
||||
import { ProvisioningProfileStoreInfo } from '../../appstore/Credentials.types';
|
||||
import { ProfileClass } from '../../appstore/provisioningProfile';
|
||||
import { AppleTeamMissingError, MissingCredentialsNonInteractiveError } from '../../errors';
|
||||
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
|
||||
import { chooseDevices } from './DeviceUtils';
|
||||
import { doUDIDsMatch, isDevPortalAdhocProfileValid } from './ProvisioningProfileUtils';
|
||||
import { SetupDistributionCertificate } from './SetupDistributionCertificate';
|
||||
@@ -33,15 +36,55 @@ export class SetupAdhocProvisioningProfile implements Action {
|
||||
}
|
||||
|
||||
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
const authCtx = await ctx.appStore.ensureAuthenticatedAsync();
|
||||
if (ctx.nonInteractive) {
|
||||
try {
|
||||
await this.runNonInteractiveAsync(manager, ctx);
|
||||
} catch (err) {
|
||||
if (err instanceof AppleTeamMissingError) {
|
||||
throw new MissingCredentialsNonInteractiveError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
await this.runInteractiveAsync(manager, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// 0. Fetch apple team object
|
||||
const appleTeam = await ctx.newIos.createOrGetExistingAppleTeamAsync(this.app, {
|
||||
appleTeamIdentifier: authCtx.team.id,
|
||||
appleTeamName: authCtx.team.name,
|
||||
});
|
||||
private async runNonInteractiveAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
// 1. Setup Distribution Certificate
|
||||
const distCertAction = new SetupDistributionCertificate(this.app, IosDistributionType.AdHoc);
|
||||
await manager.runActionAsync(distCertAction);
|
||||
|
||||
// 1. Fetch devices registered on EAS servers.
|
||||
// 2. Fetch profile from EAS servers
|
||||
const currentProfile = await ctx.newIos.getProvisioningProfileAsync(
|
||||
this.app,
|
||||
IosDistributionType.AdHoc
|
||||
);
|
||||
|
||||
if (!currentProfile) {
|
||||
throw new MissingCredentialsNonInteractiveError();
|
||||
}
|
||||
|
||||
// TODO: implement validation
|
||||
Log.warn(
|
||||
'Provisioning Profile is not validated for non-interactive internal distribution builds.'
|
||||
);
|
||||
|
||||
// app credentials should exist here because the profile exists
|
||||
const appCredentials = nullthrows(
|
||||
await ctx.newIos.getIosAppCredentialsWithBuildCredentialsAsync(this.app, {
|
||||
iosDistributionType: IosDistributionType.AdHoc,
|
||||
})
|
||||
);
|
||||
this._iosAppBuildCredentials = appCredentials.iosAppBuildCredentialsArray[0];
|
||||
}
|
||||
|
||||
private async runInteractiveAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
// 0. Ensure the user is authenticated with Apple and resolve the Apple team object
|
||||
await ctx.appStore.ensureAuthenticatedAsync();
|
||||
const appleTeam = nullthrows(await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app));
|
||||
|
||||
// 1. Fetch devices registered on EAS servers
|
||||
const registeredAppleDevices = await ctx.newIos.getDevicesForAppleTeamAsync(
|
||||
this.app,
|
||||
appleTeam
|
||||
@@ -54,21 +97,21 @@ export class SetupAdhocProvisioningProfile implements Action {
|
||||
}
|
||||
|
||||
// 2. Setup Distribution Certificate
|
||||
const distCertAction = new SetupDistributionCertificate(this.app);
|
||||
const distCertAction = new SetupDistributionCertificate(this.app, IosDistributionType.AdHoc);
|
||||
await manager.runActionAsync(distCertAction);
|
||||
const distCert = distCertAction.distributionCertificate;
|
||||
|
||||
// 3. Fetch profile from EAS servers
|
||||
let currentProfileFromExpoServers = (await ctx.newIos.getProvisioningProfileAsync(
|
||||
this.app,
|
||||
let profileFromExpoServersToUse:
|
||||
| AppleProvisioningProfileQueryResult
|
||||
| AppleProvisioningProfileMutationResult
|
||||
| null = await ctx.newIos.getProvisioningProfileAsync(this.app, IosDistributionType.AdHoc, {
|
||||
appleTeam,
|
||||
IosDistributionType.AdHoc
|
||||
)) as AppleProvisioningProfileQueryResult | AppleProvisioningProfileMutationResult | null;
|
||||
});
|
||||
|
||||
// 4. Choose devices for internal distribution
|
||||
let chosenDevices: AppleDeviceFragmentWithAppleTeam[];
|
||||
if (currentProfileFromExpoServers) {
|
||||
const appleDeviceIdentifiersFromProfile = ((currentProfileFromExpoServers.appleDevices ??
|
||||
if (profileFromExpoServersToUse) {
|
||||
const appleDeviceIdentifiersFromProfile = ((profileFromExpoServersToUse.appleDevices ??
|
||||
[]) as AppleDevice[]).map(({ identifier }) => identifier);
|
||||
|
||||
let shouldAskToChooseDevices = true;
|
||||
@@ -110,10 +153,10 @@ export class SetupAdhocProvisioningProfile implements Action {
|
||||
this.app,
|
||||
appleTeam
|
||||
);
|
||||
if (currentProfileFromExpoServers) {
|
||||
if (profileFromExpoServersToUse) {
|
||||
if (profileWasRecreated) {
|
||||
currentProfileFromExpoServers = await ctx.newIos.updateProvisioningProfileAsync(
|
||||
currentProfileFromExpoServers.id,
|
||||
profileFromExpoServersToUse = await ctx.newIos.updateProvisioningProfileAsync(
|
||||
profileFromExpoServersToUse.id,
|
||||
{
|
||||
appleProvisioningProfile: profileToUse.provisioningProfile,
|
||||
developerPortalIdentifier: profileToUse.provisioningProfileId,
|
||||
@@ -121,7 +164,7 @@ export class SetupAdhocProvisioningProfile implements Action {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
currentProfileFromExpoServers = await ctx.newIos.createProvisioningProfileAsync(
|
||||
profileFromExpoServersToUse = await ctx.newIos.createProvisioningProfileAsync(
|
||||
this.app,
|
||||
appleAppIdentifier,
|
||||
{
|
||||
@@ -138,7 +181,7 @@ export class SetupAdhocProvisioningProfile implements Action {
|
||||
appleTeam,
|
||||
appleAppIdentifierId: appleAppIdentifier.id,
|
||||
appleDistributionCertificateId: distCert.id,
|
||||
appleProvisioningProfileId: currentProfileFromExpoServers.id,
|
||||
appleProvisioningProfileId: profileFromExpoServersToUse.id,
|
||||
iosDistributionType: IosDistributionType.AdHoc,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Context } from '../../../context';
|
||||
import { AppLookupParams } from '../../api/GraphqlClient';
|
||||
import { AppleDistributionCertificateMutationResult } from '../../api/graphql/mutations/AppleDistributionCertificateMutation';
|
||||
import { getValidCertSerialNumbers } from '../../appstore/CredentialsUtils';
|
||||
import { AppleTeamMissingError, MissingCredentialsNonInteractiveError } from '../../errors';
|
||||
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
|
||||
import { CreateDistributionCertificate } from './CreateDistributionCertificate';
|
||||
import { formatDistributionCertificate } from './DistributionCertificateUtils';
|
||||
|
||||
@@ -20,7 +22,7 @@ export class SetupDistributionCertificate implements Action {
|
||||
private validDistCerts?: AppleDistributionCertificateFragment[];
|
||||
private _distributionCertificate?: AppleDistributionCertificateFragment;
|
||||
|
||||
constructor(private app: AppLookupParams) {}
|
||||
constructor(private app: AppLookupParams, private distributionType: IosDistributionType) {}
|
||||
|
||||
public get distributionCertificate(): AppleDistributionCertificateFragment {
|
||||
assert(
|
||||
@@ -31,21 +33,46 @@ export class SetupDistributionCertificate implements Action {
|
||||
}
|
||||
|
||||
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
assert(
|
||||
ctx.appStore.authCtx,
|
||||
'authCtx is defined in this context - enforced by ensureAuthenticatedAsync call in SetupBuildCredentials'
|
||||
);
|
||||
const appleTeam = await ctx.newIos.createOrGetExistingAppleTeamAsync(this.app, {
|
||||
appleTeamIdentifier: ctx.appStore.authCtx.team.id,
|
||||
appleTeamName: ctx.appStore.authCtx.team.name,
|
||||
});
|
||||
const appleTeam = await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app);
|
||||
|
||||
const currentCertificate = await ctx.newIos.getDistributionCertificateForAppAsync(
|
||||
this.app,
|
||||
appleTeam,
|
||||
IosDistributionType.AdHoc
|
||||
);
|
||||
try {
|
||||
const currentCertificate = await ctx.newIos.getDistributionCertificateForAppAsync(
|
||||
this.app,
|
||||
this.distributionType,
|
||||
{ appleTeam }
|
||||
);
|
||||
|
||||
if (ctx.nonInteractive) {
|
||||
await this.runNonInteractiveAsync(ctx, currentCertificate);
|
||||
} else {
|
||||
await this.runInteractiveAsync(ctx, manager, currentCertificate);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AppleTeamMissingError && ctx.nonInteractive) {
|
||||
throw new MissingCredentialsNonInteractiveError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async runNonInteractiveAsync(
|
||||
ctx: Context,
|
||||
currentCertificate: AppleDistributionCertificateFragment | null
|
||||
): Promise<void> {
|
||||
// TODO: implement validation
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn('Distribution Certificate is not validated for non-interactive builds.');
|
||||
if (!currentCertificate) {
|
||||
throw new MissingCredentialsNonInteractiveError();
|
||||
}
|
||||
this._distributionCertificate = currentCertificate;
|
||||
}
|
||||
|
||||
private async runInteractiveAsync(
|
||||
ctx: Context,
|
||||
manager: CredentialsManager,
|
||||
currentCertificate: AppleDistributionCertificateFragment | null
|
||||
): Promise<void> {
|
||||
if (await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) {
|
||||
assert(currentCertificate, 'currentCertificate is defined here');
|
||||
this._distributionCertificate = currentCertificate;
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import {
|
||||
AppleDistributionCertificateFragment,
|
||||
AppleProvisioningProfileFragment,
|
||||
IosAppBuildCredentialsFragment,
|
||||
IosDistributionType,
|
||||
} from '../../../../graphql/generated';
|
||||
import Log from '../../../../log';
|
||||
import { confirmAsync } from '../../../../prompts';
|
||||
import { CredentialsManager } from '../../../CredentialsManager';
|
||||
import { Context } from '../../../context';
|
||||
import { AppLookupParams } from '../../api/GraphqlClient';
|
||||
import { ProvisioningProfileStoreInfo } from '../../appstore/Credentials.types';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../errors';
|
||||
import { validateProvisioningProfileAsync } from '../../validators/validateProvisioningProfile';
|
||||
import { formatProvisioningProfileFromApple } from '../ProvisioningProfileUtils';
|
||||
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
|
||||
import { ConfigureProvisioningProfile } from './ConfigureProvisioningProfile';
|
||||
import { CreateProvisioningProfile } from './CreateProvisioningProfile';
|
||||
import { SetupDistributionCertificate } from './SetupDistributionCertificate';
|
||||
|
||||
export class SetupProvisioningProfile {
|
||||
constructor(private app: AppLookupParams) {}
|
||||
|
||||
async areBuildCredentialsSetupAsync(ctx: Context): Promise<boolean> {
|
||||
const buildCredentials = await this.getBuildCredentialsAsync(ctx);
|
||||
if (!buildCredentials) {
|
||||
return false;
|
||||
}
|
||||
const { distributionCertificate, provisioningProfile } = buildCredentials;
|
||||
if (!distributionCertificate || !provisioningProfile) {
|
||||
return false;
|
||||
}
|
||||
return await this.isCurrentProfileValidAsync(ctx, provisioningProfile, distributionCertificate);
|
||||
}
|
||||
|
||||
async getBuildCredentialsAsync(ctx: Context): Promise<IosAppBuildCredentialsFragment | null> {
|
||||
const appCredentials = await ctx.newIos.getIosAppCredentialsWithBuildCredentialsAsync(
|
||||
this.app,
|
||||
{
|
||||
iosDistributionType: IosDistributionType.AppStore,
|
||||
}
|
||||
);
|
||||
if (!appCredentials || appCredentials.iosAppBuildCredentialsArray.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const [buildCredentials] = appCredentials.iosAppBuildCredentialsArray;
|
||||
return buildCredentials;
|
||||
}
|
||||
|
||||
async assignNewAndDeleteOldProfileAsync(
|
||||
ctx: Context,
|
||||
distCert: AppleDistributionCertificateFragment,
|
||||
currentProfile: AppleProvisioningProfileFragment
|
||||
): Promise<IosAppBuildCredentialsFragment> {
|
||||
const buildCredentials = await this.createAndAssignProfileAsync(ctx, distCert);
|
||||
// delete 'currentProfile' since its no longer valid
|
||||
await ctx.newIos.deleteProvisioningProfilesAsync([currentProfile.id]);
|
||||
return buildCredentials;
|
||||
}
|
||||
|
||||
async createAndAssignProfileAsync(
|
||||
ctx: Context,
|
||||
distCert: AppleDistributionCertificateFragment
|
||||
): Promise<IosAppBuildCredentialsFragment> {
|
||||
const provisioningProfile = await new CreateProvisioningProfile(this.app, distCert).runAsync(
|
||||
ctx
|
||||
);
|
||||
return await this.assignBuildCredentialsAsync(ctx, distCert, provisioningProfile);
|
||||
}
|
||||
|
||||
async configureAndAssignProfileAsync(
|
||||
ctx: Context,
|
||||
distCert: AppleDistributionCertificateFragment,
|
||||
originalProvisioningProfile: AppleProvisioningProfileFragment
|
||||
): Promise<IosAppBuildCredentialsFragment | null> {
|
||||
const profileConfigurator = new ConfigureProvisioningProfile(
|
||||
this.app,
|
||||
distCert,
|
||||
originalProvisioningProfile
|
||||
);
|
||||
const updatedProvisioningProfile = await profileConfigurator.runAsync(ctx);
|
||||
if (!updatedProvisioningProfile) {
|
||||
return null;
|
||||
}
|
||||
return await this.assignBuildCredentialsAsync(ctx, distCert, updatedProvisioningProfile);
|
||||
}
|
||||
|
||||
async assignBuildCredentialsAsync(
|
||||
ctx: Context,
|
||||
distCert: AppleDistributionCertificateFragment,
|
||||
provisioningProfile: AppleProvisioningProfileFragment
|
||||
): Promise<IosAppBuildCredentialsFragment> {
|
||||
const appleTeam = nullthrows(await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app));
|
||||
const appleAppIdentifier = await ctx.newIos.createOrGetExistingAppleAppIdentifierAsync(
|
||||
this.app,
|
||||
appleTeam
|
||||
);
|
||||
return await ctx.newIos.createOrUpdateIosAppBuildCredentialsAsync(this.app, {
|
||||
appleTeam,
|
||||
appleAppIdentifierId: appleAppIdentifier.id,
|
||||
appleDistributionCertificateId: distCert.id,
|
||||
appleProvisioningProfileId: provisioningProfile.id,
|
||||
iosDistributionType: IosDistributionType.AppStore,
|
||||
});
|
||||
}
|
||||
|
||||
async getProvisioningProfileAsync(
|
||||
ctx: Context
|
||||
): Promise<AppleProvisioningProfileFragment | null> {
|
||||
const buildCredentials = await this.getBuildCredentialsAsync(ctx);
|
||||
if (!buildCredentials) {
|
||||
return null;
|
||||
}
|
||||
return buildCredentials.provisioningProfile ?? null;
|
||||
}
|
||||
|
||||
async runAsync(
|
||||
manager: CredentialsManager,
|
||||
ctx: Context
|
||||
): Promise<IosAppBuildCredentialsFragment> {
|
||||
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
|
||||
if (areBuildCredentialsSetup) {
|
||||
return nullthrows(await this.getBuildCredentialsAsync(ctx));
|
||||
}
|
||||
if (ctx.nonInteractive) {
|
||||
throw new MissingCredentialsNonInteractiveError(
|
||||
'Provisioning profile is not configured correctly. Please run this command again in interactive mode.'
|
||||
);
|
||||
}
|
||||
|
||||
const distCertAction = new SetupDistributionCertificate(this.app, IosDistributionType.AppStore);
|
||||
await distCertAction.runAsync(manager, ctx);
|
||||
const distCert = distCertAction.distributionCertificate;
|
||||
|
||||
const currentProfile = await this.getProvisioningProfileAsync(ctx);
|
||||
if (!currentProfile) {
|
||||
return await this.createAndAssignProfileAsync(ctx, distCert);
|
||||
}
|
||||
|
||||
// See if the profile we have exists on the Apple Servers
|
||||
const existingProfiles = await ctx.appStore.listProvisioningProfilesAsync(
|
||||
this.app.bundleIdentifier
|
||||
);
|
||||
const currentProfileFromServer = this.getCurrentProfileStoreInfo(
|
||||
existingProfiles,
|
||||
currentProfile
|
||||
);
|
||||
if (!currentProfileFromServer) {
|
||||
return await this.assignNewAndDeleteOldProfileAsync(ctx, distCert, currentProfile);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `${formatProvisioningProfileFromApple(
|
||||
currentProfileFromServer
|
||||
)} \n Would you like to reuse the original profile?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
return await this.assignNewAndDeleteOldProfileAsync(ctx, distCert, currentProfile);
|
||||
}
|
||||
|
||||
// If we get here, we've verified the current profile still exists on Apple
|
||||
// But something wasn't quite right, so we want to fix and update it
|
||||
const updatedProfile = await this.configureAndAssignProfileAsync(ctx, distCert, currentProfile);
|
||||
if (!updatedProfile) {
|
||||
// Something went wrong, so just create a new profile instead
|
||||
return await this.assignNewAndDeleteOldProfileAsync(ctx, distCert, currentProfile);
|
||||
}
|
||||
return updatedProfile;
|
||||
}
|
||||
|
||||
private async isCurrentProfileValidAsync(
|
||||
ctx: Context,
|
||||
currentProfile: AppleProvisioningProfileFragment,
|
||||
currentCertificate: AppleDistributionCertificateFragment
|
||||
): Promise<boolean> {
|
||||
const { provisioningProfile, developerPortalIdentifier } = currentProfile;
|
||||
const { certificateP12, certificatePassword } = currentCertificate;
|
||||
if (!provisioningProfile || !certificateP12 || !certificatePassword) {
|
||||
return false;
|
||||
}
|
||||
const validationResult = await validateProvisioningProfileAsync(
|
||||
ctx,
|
||||
{
|
||||
provisioningProfile,
|
||||
...(developerPortalIdentifier
|
||||
? { provisioningProfileId: developerPortalIdentifier }
|
||||
: null),
|
||||
},
|
||||
{
|
||||
certP12: certificateP12,
|
||||
certPassword: certificatePassword,
|
||||
},
|
||||
this.app.bundleIdentifier
|
||||
);
|
||||
if (!validationResult.ok) {
|
||||
Log.warn(validationResult.error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private getCurrentProfileStoreInfo(
|
||||
profiles: ProvisioningProfileStoreInfo[],
|
||||
currentProfile: AppleProvisioningProfileFragment
|
||||
): ProvisioningProfileStoreInfo | null {
|
||||
return (
|
||||
profiles.find(profile =>
|
||||
currentProfile.developerPortalIdentifier
|
||||
? currentProfile.developerPortalIdentifier === profile.provisioningProfileId
|
||||
: currentProfile.provisioningProfile === profile.provisioningProfile
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
AppleDistributionCertificateFragment,
|
||||
AppleProvisioningProfileFragment,
|
||||
} from '../../../../../graphql/generated';
|
||||
import { testProvisioningProfileFragment } from '../../../../__tests__/fixtures-ios';
|
||||
import { Context } from '../../../../context';
|
||||
import { AppLookupParams } from '../../../api/GraphqlClient';
|
||||
import { AppleProvisioningProfileMutationResult } from '../../../api/graphql/mutations/AppleProvisioningProfileMutation';
|
||||
|
||||
export class ConfigureProvisioningProfile {
|
||||
constructor(
|
||||
private app: AppLookupParams,
|
||||
private distributionCertificate: AppleDistributionCertificateFragment,
|
||||
private originalProvisioningProfile: AppleProvisioningProfileFragment
|
||||
) {}
|
||||
|
||||
public async runAsync(ctx: Context): Promise<AppleProvisioningProfileMutationResult | null> {
|
||||
return testProvisioningProfileFragment;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { AppleDistributionCertificateFragment } from '../../../../../graphql/generated';
|
||||
import { testProvisioningProfileFragment } from '../../../../__tests__/fixtures-ios';
|
||||
import { Context } from '../../../../context';
|
||||
import { AppLookupParams } from '../../../api/GraphqlClient';
|
||||
import { AppleProvisioningProfileMutationResult } from '../../../api/graphql/mutations/AppleProvisioningProfileMutation';
|
||||
|
||||
export class CreateProvisioningProfile {
|
||||
constructor(
|
||||
private app: AppLookupParams,
|
||||
private distributionCertificate: AppleDistributionCertificateFragment
|
||||
) {}
|
||||
|
||||
async runAsync(ctx: Context): Promise<AppleProvisioningProfileMutationResult> {
|
||||
return testProvisioningProfileFragment;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
AppleDistributionCertificateFragment,
|
||||
IosDistributionType,
|
||||
} from '../../../../../graphql/generated';
|
||||
import { Action, CredentialsManager } from '../../../../CredentialsManager';
|
||||
import { testDistCertFragmentOneDependency } from '../../../../__tests__/fixtures-ios';
|
||||
import { Context } from '../../../../context';
|
||||
import { AppLookupParams } from '../../../api/GraphqlClient';
|
||||
export class SetupDistributionCertificate implements Action {
|
||||
constructor(private app: AppLookupParams, private distributionType: IosDistributionType) {}
|
||||
|
||||
public get distributionCertificate(): AppleDistributionCertificateFragment {
|
||||
return testDistCertFragmentOneDependency;
|
||||
}
|
||||
|
||||
public async runAsync(_manager: CredentialsManager, _ctx: Context): Promise<void> {}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { confirmAsync } from '../../../../../prompts';
|
||||
import { getAppstoreMock, testAuthCtx } from '../../../../__tests__/fixtures-appstore';
|
||||
import { createCtxMock } from '../../../../__tests__/fixtures-context';
|
||||
import {
|
||||
testDistCertFragmentNoDependencies,
|
||||
testProvisioningProfile,
|
||||
testProvisioningProfileFragment,
|
||||
} from '../../../../__tests__/fixtures-ios';
|
||||
import { ManageIosBeta } from '../../../../manager/ManageIosBeta';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../../errors';
|
||||
import { ConfigureProvisioningProfile } from '../ConfigureProvisioningProfile';
|
||||
jest.mock('../../../../../prompts');
|
||||
(confirmAsync as jest.Mock).mockImplementation(() => true);
|
||||
|
||||
describe('ConfigureProvisioningProfile', () => {
|
||||
it('configures a Provisioning Profile in Interactive Mode', async () => {
|
||||
const mockProvisioningProfileFromApple = {
|
||||
provisioningProfileId: testProvisioningProfileFragment.developerPortalIdentifier,
|
||||
provisioningProfile: testProvisioningProfileFragment.provisioningProfile,
|
||||
};
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
authCtx: testAuthCtx,
|
||||
createProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
|
||||
listProvisioningProfilesAsync: jest.fn(() => [mockProvisioningProfileFromApple]),
|
||||
useExistingProvisioningProfileAsync: jest.fn(() => mockProvisioningProfileFromApple),
|
||||
},
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const provProfConfigurator = new ConfigureProvisioningProfile(
|
||||
appLookupParams,
|
||||
testDistCertFragmentNoDependencies,
|
||||
testProvisioningProfileFragment
|
||||
);
|
||||
await provProfConfigurator.runAsync(ctx);
|
||||
|
||||
// expect provisioning profile not to be updated on expo servers
|
||||
expect((ctx.newIos.updateProvisioningProfileAsync as any).mock.calls.length).toBe(1);
|
||||
// expect provisioning profile not to be updated on apple portal
|
||||
expect((ctx.appStore.useExistingProvisioningProfileAsync as any).mock.calls.length).toBe(1);
|
||||
});
|
||||
it('errors in Non Interactive Mode', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const provProfConfigurator = new ConfigureProvisioningProfile(
|
||||
appLookupParams,
|
||||
testDistCertFragmentNoDependencies,
|
||||
testProvisioningProfileFragment
|
||||
);
|
||||
await expect(provProfConfigurator.runAsync(ctx)).rejects.toThrowError(
|
||||
MissingCredentialsNonInteractiveError
|
||||
);
|
||||
|
||||
// expect provisioning profile not to be updated on expo servers
|
||||
expect((ctx.newIos.updateProvisioningProfileAsync as any).mock.calls.length).toBe(0);
|
||||
// expect provisioning profile not to be updated on apple portal
|
||||
expect((ctx.appStore.useExistingProvisioningProfileAsync as any).mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { confirmAsync } from '../../../../../prompts';
|
||||
import { getAppstoreMock, testAuthCtx } from '../../../../__tests__/fixtures-appstore';
|
||||
import { createCtxMock } from '../../../../__tests__/fixtures-context';
|
||||
import {
|
||||
testDistCertFragmentNoDependencies,
|
||||
testProvisioningProfile,
|
||||
} from '../../../../__tests__/fixtures-ios';
|
||||
import { ManageIosBeta } from '../../../../manager/ManageIosBeta';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../../errors';
|
||||
import { CreateProvisioningProfile } from '../CreateProvisioningProfile';
|
||||
jest.mock('../../../../../prompts');
|
||||
(confirmAsync as jest.Mock).mockImplementation(() => true);
|
||||
|
||||
describe('CreateProvisioningProfile', () => {
|
||||
it('creates a Provisioning Profile in Interactive Mode', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
authCtx: testAuthCtx,
|
||||
createProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
|
||||
},
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const createProvProfAction = new CreateProvisioningProfile(
|
||||
appLookupParams,
|
||||
testDistCertFragmentNoDependencies
|
||||
);
|
||||
await createProvProfAction.runAsync(ctx);
|
||||
|
||||
// expect provisioning profile to be created on expo servers
|
||||
expect((ctx.newIos.createProvisioningProfileAsync as any).mock.calls.length).toBe(1);
|
||||
// expect provisioning profile to be created on apple portal
|
||||
expect((ctx.appStore.createProvisioningProfileAsync as any).mock.calls.length).toBe(1);
|
||||
});
|
||||
it('errors in Non Interactive Mode', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const createProvProfAction = new CreateProvisioningProfile(
|
||||
appLookupParams,
|
||||
testDistCertFragmentNoDependencies
|
||||
);
|
||||
await expect(createProvProfAction.runAsync(ctx)).rejects.toThrowError(
|
||||
MissingCredentialsNonInteractiveError
|
||||
);
|
||||
|
||||
// expect provisioning profile not to be created on expo servers
|
||||
expect((ctx.newIos.createProvisioningProfileAsync as any).mock.calls.length).toBe(0);
|
||||
// expect provisioning profile not to be created on apple portal
|
||||
expect((ctx.appStore.createProvisioningProfileAsync as any).mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { confirmAsync } from '../../../../../prompts';
|
||||
import { getAppstoreMock, testAuthCtx } from '../../../../__tests__/fixtures-appstore';
|
||||
import { createCtxMock, createManagerMock } from '../../../../__tests__/fixtures-context';
|
||||
import {
|
||||
testAppleAppIdentifierFragment,
|
||||
testIosAppBuildCredentialsFragment,
|
||||
testIosAppCredentialsWithBuildCredentialsQueryResult,
|
||||
} from '../../../../__tests__/fixtures-ios';
|
||||
import { getNewIosApiMockWithoutCredentials } from '../../../../__tests__/fixtures-new-ios';
|
||||
import { ManageIosBeta } from '../../../../manager/ManageIosBeta';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../../errors';
|
||||
import { validateProvisioningProfileAsync } from '../../../validators/validateProvisioningProfile';
|
||||
import { SetupProvisioningProfile } from '../SetupProvisioningProfile';
|
||||
jest.mock('../../../../../prompts');
|
||||
(confirmAsync as jest.Mock).mockImplementation(() => true);
|
||||
jest.mock('../SetupDistributionCertificate');
|
||||
jest.mock('../ConfigureProvisioningProfile');
|
||||
jest.mock('../CreateProvisioningProfile');
|
||||
jest.mock('../../../validators/validateProvisioningProfile');
|
||||
|
||||
describe('SetupProvisioningProfile', () => {
|
||||
it('repairs existing Provisioning Profile with bad build credentials in Interactive Mode', async () => {
|
||||
(validateProvisioningProfileAsync as jest.Mock).mockImplementation(() => ({
|
||||
error: 'testing: everything is fine, ignore this',
|
||||
ok: false,
|
||||
}));
|
||||
const manager = createManagerMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
authCtx: testAuthCtx,
|
||||
listProvisioningProfilesAsync: jest.fn(() => [
|
||||
{
|
||||
provisioningProfileId:
|
||||
testIosAppCredentialsWithBuildCredentialsQueryResult.iosAppBuildCredentialsArray[0]
|
||||
.provisioningProfile.developerPortalIdentifier,
|
||||
},
|
||||
]),
|
||||
},
|
||||
newIos: {
|
||||
...getNewIosApiMockWithoutCredentials(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testIosAppCredentialsWithBuildCredentialsQueryResult
|
||||
),
|
||||
createOrGetExistingAppleAppIdentifierAsync: jest.fn(() => testAppleAppIdentifierFragment),
|
||||
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(
|
||||
() => testIosAppBuildCredentialsFragment
|
||||
),
|
||||
},
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const setupProvisioningProfileAction = new SetupProvisioningProfile(appLookupParams);
|
||||
await setupProvisioningProfileAction.runAsync(manager, ctx);
|
||||
|
||||
// expect build credentials to be created or updated on expo servers
|
||||
expect((ctx.newIos.createOrUpdateIosAppBuildCredentialsAsync as any).mock.calls.length).toBe(1);
|
||||
// expect provisioning profile not to be deleted on expo servers
|
||||
expect((ctx.newIos.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(0);
|
||||
});
|
||||
it('sets up a new Provisioning Profile with bad build credentials in Interactive Mode', async () => {
|
||||
(validateProvisioningProfileAsync as jest.Mock).mockImplementation(() => ({
|
||||
error: 'testing: everything is fine, ignore this',
|
||||
ok: false,
|
||||
}));
|
||||
const manager = createManagerMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
authCtx: testAuthCtx,
|
||||
listProvisioningProfilesAsync: jest.fn(() => []),
|
||||
},
|
||||
newIos: {
|
||||
...getNewIosApiMockWithoutCredentials(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testIosAppCredentialsWithBuildCredentialsQueryResult
|
||||
),
|
||||
createOrGetExistingAppleAppIdentifierAsync: jest.fn(() => testAppleAppIdentifierFragment),
|
||||
},
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const setupProvisioningProfileAction = new SetupProvisioningProfile(appLookupParams);
|
||||
await setupProvisioningProfileAction.runAsync(manager, ctx);
|
||||
|
||||
// expect build credentials to be created or updated on expo servers
|
||||
expect((ctx.newIos.createOrUpdateIosAppBuildCredentialsAsync as any).mock.calls.length).toBe(1);
|
||||
// expect provisioning profile to be deleted on expo servers
|
||||
expect((ctx.newIos.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(1);
|
||||
});
|
||||
it('skips setting up a Provisioning Profile with prior build credentials configured properly in Interactive Mode', async () => {
|
||||
(validateProvisioningProfileAsync as jest.Mock).mockImplementation(() => ({
|
||||
ok: true,
|
||||
}));
|
||||
const manager = createManagerMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
authCtx: testAuthCtx,
|
||||
//listProvisioningProfilesAsync: jest.fn(() => []),
|
||||
},
|
||||
newIos: {
|
||||
...getNewIosApiMockWithoutCredentials(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testIosAppCredentialsWithBuildCredentialsQueryResult
|
||||
),
|
||||
createOrGetExistingAppleAppIdentifierAsync: jest.fn(() => testAppleAppIdentifierFragment),
|
||||
},
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const setupProvisioningProfileAction = new SetupProvisioningProfile(appLookupParams);
|
||||
await setupProvisioningProfileAction.runAsync(manager, ctx);
|
||||
|
||||
// expect build credentials not to be created or updated on expo servers
|
||||
expect((ctx.newIos.createOrUpdateIosAppBuildCredentialsAsync as any).mock.calls.length).toBe(0);
|
||||
// expect provisioning profile not to be deleted on expo servers
|
||||
expect((ctx.newIos.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(0);
|
||||
});
|
||||
it('sets up a Provisioning Profile with no prior build credentials configured in Interactive Mode', async () => {
|
||||
const manager = createManagerMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
authCtx: testAuthCtx,
|
||||
},
|
||||
newIos: {
|
||||
...getNewIosApiMockWithoutCredentials(),
|
||||
createOrGetExistingAppleAppIdentifierAsync: jest.fn(() => testAppleAppIdentifierFragment),
|
||||
},
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const setupProvisioningProfileAction = new SetupProvisioningProfile(appLookupParams);
|
||||
await setupProvisioningProfileAction.runAsync(manager, ctx);
|
||||
|
||||
// expect build credentials to be created or updated on expo servers
|
||||
expect((ctx.newIos.createOrUpdateIosAppBuildCredentialsAsync as any).mock.calls.length).toBe(1);
|
||||
// expect provisioning profile not to be deleted on expo servers
|
||||
expect((ctx.newIos.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(0);
|
||||
});
|
||||
it('errors in Non Interactive Mode', async () => {
|
||||
const manager = createManagerMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const setupProvisioningProfileAction = new SetupProvisioningProfile(appLookupParams);
|
||||
await expect(setupProvisioningProfileAction.runAsync(manager, ctx)).rejects.toThrowError(
|
||||
MissingCredentialsNonInteractiveError
|
||||
);
|
||||
|
||||
// expect build credentials not to be created or updated on expo servers
|
||||
expect((ctx.newIos.createOrUpdateIosAppBuildCredentialsAsync as any).mock.calls.length).toBe(0);
|
||||
// expect provisioning profile not to be deleted on expo servers
|
||||
expect((ctx.newIos.deleteProvisioningProfilesAsync as any).mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../../graphql/generated';
|
||||
import { Account } from '../../../user/Account';
|
||||
import { DistributionCertificate } from '../appstore/Credentials.types';
|
||||
import { AppleTeamMissingError } from '../errors';
|
||||
import { AppleAppIdentifierMutation } from './graphql/mutations/AppleAppIdentifierMutation';
|
||||
import {
|
||||
AppleDistributionCertificateMutation,
|
||||
@@ -98,6 +99,25 @@ export async function createOrUpdateIosAppBuildCredentialsAsync(
|
||||
}
|
||||
|
||||
export async function getIosAppCredentialsWithBuildCredentialsAsync(
|
||||
appLookupParams: AppLookupParams,
|
||||
{ iosDistributionType }: { iosDistributionType: IosDistributionType }
|
||||
): Promise<IosAppCredentialsWithBuildCredentialsQueryResult | null> {
|
||||
const { account, bundleIdentifier } = appLookupParams;
|
||||
const appleAppIdentifier = await AppleAppIdentifierQuery.byBundleIdentifierAsync(
|
||||
account.name,
|
||||
bundleIdentifier
|
||||
);
|
||||
if (!appleAppIdentifier) {
|
||||
return null;
|
||||
}
|
||||
const projectFullName = formatProjectFullName(appLookupParams);
|
||||
return await IosAppCredentialsQuery.withBuildCredentialsByAppIdentifierIdAsync(projectFullName, {
|
||||
appleAppIdentifierId: appleAppIdentifier.id,
|
||||
iosDistributionType,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getIosAppCredentialsWithCommonFieldsAsync(
|
||||
appLookupParams: AppLookupParams
|
||||
): Promise<CommonIosAppCredentialsFragment | null> {
|
||||
const { account, bundleIdentifier } = appLookupParams;
|
||||
@@ -126,12 +146,12 @@ export async function createOrGetExistingIosAppCredentialsWithBuildCredentialsAs
|
||||
iosDistributionType: IosDistributionType;
|
||||
}
|
||||
): Promise<IosAppCredentialsWithBuildCredentialsQueryResult> {
|
||||
const projectFullName = formatProjectFullName(appLookupParams);
|
||||
const maybeIosAppCredentials = await IosAppCredentialsQuery.withBuildCredentialsByAppIdentifierIdAsync(
|
||||
projectFullName,
|
||||
{ appleAppIdentifierId, iosDistributionType }
|
||||
const maybeIosAppCredentials = await getIosAppCredentialsWithBuildCredentialsAsync(
|
||||
appLookupParams,
|
||||
{
|
||||
iosDistributionType,
|
||||
}
|
||||
);
|
||||
|
||||
if (maybeIosAppCredentials) {
|
||||
return maybeIosAppCredentials;
|
||||
} else {
|
||||
@@ -144,6 +164,7 @@ export async function createOrGetExistingIosAppCredentialsWithBuildCredentialsAs
|
||||
app.id,
|
||||
appleAppIdentifier.id
|
||||
);
|
||||
const projectFullName = formatProjectFullName(appLookupParams);
|
||||
return nullthrows(
|
||||
await IosAppCredentialsQuery.withBuildCredentialsByAppIdentifierIdAsync(projectFullName, {
|
||||
appleAppIdentifierId,
|
||||
@@ -173,7 +194,7 @@ export async function createOrGetExistingAppleTeamAsync(
|
||||
|
||||
export async function createOrGetExistingAppleAppIdentifierAsync(
|
||||
{ account, bundleIdentifier }: AppLookupParams,
|
||||
appleTeam: AppleTeamFragment
|
||||
appleTeam: AppleTeamFragment | null
|
||||
): Promise<AppleAppIdentifierFragment> {
|
||||
const appleAppIdentifier = await AppleAppIdentifierQuery.byBundleIdentifierAsync(
|
||||
account.name,
|
||||
@@ -182,6 +203,9 @@ export async function createOrGetExistingAppleAppIdentifierAsync(
|
||||
if (appleAppIdentifier) {
|
||||
return appleAppIdentifier;
|
||||
} else {
|
||||
if (!appleTeam) {
|
||||
throw new AppleTeamMissingError();
|
||||
}
|
||||
return await AppleAppIdentifierMutation.createAppleAppIdentifierAsync(
|
||||
{ bundleIdentifier, appleTeamId: appleTeam.id },
|
||||
account.id
|
||||
@@ -213,8 +237,8 @@ export async function createProvisioningProfileAsync(
|
||||
|
||||
export async function getProvisioningProfileAsync(
|
||||
appLookupParams: AppLookupParams,
|
||||
appleTeam: AppleTeamFragment,
|
||||
iosDistributionType: IosDistributionType
|
||||
iosDistributionType: IosDistributionType,
|
||||
{ appleTeam }: { appleTeam: AppleTeamFragment | null } = { appleTeam: null }
|
||||
): Promise<AppleProvisioningProfileQueryResult | null> {
|
||||
const projectFullName = formatProjectFullName(appLookupParams);
|
||||
const appleAppIdentifier = await createOrGetExistingAppleAppIdentifierAsync(
|
||||
@@ -222,7 +246,7 @@ export async function getProvisioningProfileAsync(
|
||||
appleTeam
|
||||
);
|
||||
return await AppleProvisioningProfileQuery.getForAppAsync(projectFullName, {
|
||||
appleAppIdentifierId: appleAppIdentifier?.id,
|
||||
appleAppIdentifierId: appleAppIdentifier.id,
|
||||
iosDistributionType,
|
||||
});
|
||||
}
|
||||
@@ -250,8 +274,8 @@ export async function deleteProvisioningProfilesAsync(
|
||||
|
||||
export async function getDistributionCertificateForAppAsync(
|
||||
appLookupParams: AppLookupParams,
|
||||
appleTeam: AppleTeamFragment,
|
||||
iosDistributionType: IosDistributionType
|
||||
iosDistributionType: IosDistributionType,
|
||||
{ appleTeam }: { appleTeam: AppleTeamFragment | null } = { appleTeam: null }
|
||||
): Promise<AppleDistributionCertificateFragment | null> {
|
||||
const projectFullName = formatProjectFullName(appLookupParams);
|
||||
const appleAppIdentifier = await createOrGetExistingAppleAppIdentifierAsync(
|
||||
@@ -259,7 +283,7 @@ export async function getDistributionCertificateForAppAsync(
|
||||
appleTeam
|
||||
);
|
||||
return await AppleDistributionCertificateQuery.getForAppAsync(projectFullName, {
|
||||
appleAppIdentifierId: appleAppIdentifier?.id,
|
||||
appleAppIdentifierId: appleAppIdentifier.id,
|
||||
iosDistributionType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export class AppleTeamMissingError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message ?? 'Apple Team is necessary to create Apple App Identifier');
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingCredentialsNonInteractiveError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(
|
||||
message ?? 'Credentials are not set up. Please run this command again in interactive mode.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ function readProfileName(dataBase64: string): string {
|
||||
return profilePlist['Name'] as string;
|
||||
}
|
||||
|
||||
function isAdHocProfile(dataBase64: string): boolean {
|
||||
const profilePlist = parse(dataBase64);
|
||||
const provisionedDevices = profilePlist['ProvisionedDevices'] as string[] | undefined;
|
||||
return Array.isArray(provisionedDevices);
|
||||
}
|
||||
|
||||
function parse(dataBase64: string): PlistObject {
|
||||
try {
|
||||
const buffer = Buffer.from(dataBase64, 'base64');
|
||||
@@ -30,4 +36,4 @@ function parse(dataBase64: string): PlistObject {
|
||||
}
|
||||
}
|
||||
|
||||
export { readAppleTeam, readProfileName, parse };
|
||||
export { readAppleTeam, readProfileName, isAdHocProfile, parse };
|
||||
@@ -2,6 +2,7 @@ import { PlistArray, PlistObject } from '@expo/plist';
|
||||
import crypto from 'crypto';
|
||||
import minimatch from 'minimatch';
|
||||
|
||||
import Log from '../../../log';
|
||||
import { Context } from '../../context';
|
||||
import { DistributionCertificate, ProvisioningProfile } from '../appstore/Credentials.types';
|
||||
import { getP12CertFingerprint } from '../utils/p12Certificate';
|
||||
@@ -14,8 +15,8 @@ interface ValidationResult {
|
||||
|
||||
export async function validateProvisioningProfileAsync(
|
||||
ctx: Context,
|
||||
profile: ProvisioningProfile,
|
||||
distCert: DistributionCertificate,
|
||||
profile: Pick<ProvisioningProfile, 'provisioningProfile' | 'provisioningProfileId'>,
|
||||
distCert: Pick<DistributionCertificate, 'certP12' | 'certPassword'>,
|
||||
bundleIdentifier: string
|
||||
): Promise<ValidationResult> {
|
||||
const resultWithoutApple = validateProvisioningProfileWithoutApple(
|
||||
@@ -23,21 +24,33 @@ export async function validateProvisioningProfileAsync(
|
||||
distCert,
|
||||
bundleIdentifier
|
||||
);
|
||||
|
||||
// already failed, return early
|
||||
if (!resultWithoutApple.ok) {
|
||||
return resultWithoutApple;
|
||||
}
|
||||
|
||||
if (!ctx.appStore.authCtx) {
|
||||
Log.warn(
|
||||
"Skipping Provisioning Profile validation on Apple Servers because we aren't authenticated."
|
||||
);
|
||||
return resultWithoutApple;
|
||||
}
|
||||
|
||||
return await validateProvisioningProfileWithAppleAsync(ctx, profile, bundleIdentifier);
|
||||
}
|
||||
|
||||
async function validateProvisioningProfileWithAppleAsync(
|
||||
ctx: Context,
|
||||
profile: ProvisioningProfile,
|
||||
profile: Pick<ProvisioningProfile, 'provisioningProfile' | 'provisioningProfileId'>,
|
||||
bundleIdentifier: string
|
||||
): Promise<ValidationResult> {
|
||||
const profilesFromApple = await ctx.appStore.listProvisioningProfilesAsync(bundleIdentifier);
|
||||
|
||||
const configuredProfileFromApple = profilesFromApple.find(
|
||||
appleProfile => appleProfile.provisioningProfileId === profile.provisioningProfileId
|
||||
const configuredProfileFromApple = profilesFromApple.find(appleProfile =>
|
||||
profile.provisioningProfileId
|
||||
? appleProfile.provisioningProfileId === profile.provisioningProfileId
|
||||
: appleProfile.provisioningProfile === profile.provisioningProfile
|
||||
);
|
||||
if (!configuredProfileFromApple) {
|
||||
return {
|
||||
@@ -45,12 +58,18 @@ async function validateProvisioningProfileWithAppleAsync(
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
if (configuredProfileFromApple.status !== 'ACTIVE') {
|
||||
return {
|
||||
error: `Provisioning profile (id: ${profile.provisioningProfileId}) is no longer valid`,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateProvisioningProfileWithoutApple(
|
||||
provisioningProfile: ProvisioningProfile,
|
||||
distCert: DistributionCertificate,
|
||||
provisioningProfile: Pick<ProvisioningProfile, 'provisioningProfile' | 'provisioningProfileId'>,
|
||||
distCert: Pick<DistributionCertificate, 'certP12' | 'certPassword'>,
|
||||
bundleIdentifier: string
|
||||
): ValidationResult {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { DistributionType } from '@expo/eas-json';
|
||||
|
||||
import Log from '../../log';
|
||||
import { getProjectAccountName } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
@@ -135,7 +133,7 @@ export class ManageIos implements Action {
|
||||
}
|
||||
case ActionType.SetupBuildCredentials: {
|
||||
const app = this.getAppLookupParamsFromContext(ctx);
|
||||
return new SetupBuildCredentials(app, DistributionType.STORE);
|
||||
return new SetupBuildCredentials(app, 'store');
|
||||
}
|
||||
case ActionType.RemoveSpecificProvisioningProfile: {
|
||||
const app = this.getAppLookupParamsFromContext(ctx);
|
||||
|
||||
@@ -40,7 +40,7 @@ export class ManageIosBeta implements Action {
|
||||
}
|
||||
if (ctx.hasProjectContext) {
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const iosAppCredentials = await ctx.newIos.getIosAppCredentialsWithBuildCredentialsAsync(
|
||||
const iosAppCredentials = await ctx.newIos.getIosAppCredentialsWithCommonFieldsAsync(
|
||||
appLookupParams
|
||||
);
|
||||
if (!iosAppCredentials) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ExpoConfig, getConfig } from '@expo/config';
|
||||
|
||||
import AppStoreApi from '../credentials/ios/appstore/AppStoreApi';
|
||||
import { findProjectRootAsync } from '../project/projectUtils';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
export interface DeviceManagerContext {
|
||||
appStore: AppStoreApi;
|
||||
exp: ExpoConfig | null;
|
||||
projectDir: string | null;
|
||||
user: Actor;
|
||||
}
|
||||
@@ -17,9 +20,16 @@ export async function createContext({
|
||||
cwd?: string;
|
||||
user: Actor;
|
||||
}): Promise<DeviceManagerContext> {
|
||||
const projectDir = await findProjectRootAsync(cwd);
|
||||
let exp: ExpoConfig | null = null;
|
||||
if (projectDir) {
|
||||
const config = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
exp = config.exp;
|
||||
}
|
||||
return {
|
||||
appStore,
|
||||
projectDir: await findProjectRootAsync(cwd),
|
||||
projectDir,
|
||||
exp,
|
||||
user,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -8,8 +9,7 @@ import Log from '../log';
|
||||
import { getProjectAccountNameAsync } from '../project/projectUtils';
|
||||
import { Choice, confirmAsync, promptAsync } from '../prompts';
|
||||
import { Account, findAccountByName } from '../user/Account';
|
||||
import { Actor } from '../user/User';
|
||||
import { getActorDisplayName } from '../user/actions';
|
||||
import { Actor, getActorDisplayName } from '../user/User';
|
||||
import DeviceCreateAction from './actions/create/action';
|
||||
import { DeviceManagerContext } from './context';
|
||||
|
||||
@@ -39,16 +39,16 @@ export default class DeviceManager {
|
||||
}
|
||||
|
||||
private async resolveAccountAsync(): Promise<Account> {
|
||||
const resolver = new AccountResolver(this.ctx.projectDir, this.ctx.user);
|
||||
const resolver = new AccountResolver(this.ctx.exp, this.ctx.user);
|
||||
return await resolver.resolveAccountAsync();
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountResolver {
|
||||
constructor(private projectDir: string | null, private user: Actor) {}
|
||||
constructor(private exp: ExpoConfig | null, private user: Actor) {}
|
||||
|
||||
public async resolveAccountAsync(): Promise<Account> {
|
||||
if (this.projectDir) {
|
||||
if (this.exp) {
|
||||
const account = await this.resolveProjectAccountAsync();
|
||||
if (account) {
|
||||
return account;
|
||||
@@ -58,9 +58,9 @@ export class AccountResolver {
|
||||
}
|
||||
|
||||
private async resolveProjectAccountAsync(): Promise<Account | undefined> {
|
||||
assert(this.projectDir, 'project directory is not set ');
|
||||
assert(this.exp, 'expo config is not set');
|
||||
|
||||
const projectAccountName = await getProjectAccountNameAsync(this.projectDir);
|
||||
const projectAccountName = await getProjectAccountNameAsync(this.exp);
|
||||
const projectAccount = findAccountByName(this.user.accounts, projectAccountName);
|
||||
if (!projectAccount) {
|
||||
Log.warn(
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import {
|
||||
CombinedError as GraphqlError,
|
||||
OperationResult,
|
||||
cacheExchange,
|
||||
createClient as createUrqlClient,
|
||||
dedupExchange,
|
||||
fetchExchange,
|
||||
} from '@urql/core';
|
||||
import { retryExchange } from '@urql/exchange-retry';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { getExpoApiBaseUrl } from '../api';
|
||||
import Log from '../log';
|
||||
import { getAccessToken, getSessionSecret } from '../user/sessionStorage';
|
||||
|
||||
type AccessTokenHeaders = {
|
||||
@@ -18,6 +23,16 @@ type SessionHeaders = {
|
||||
|
||||
export const graphqlClient = createUrqlClient({
|
||||
url: getExpoApiBaseUrl() + '/--/graphql',
|
||||
exchanges: [
|
||||
dedupExchange,
|
||||
cacheExchange,
|
||||
retryExchange({
|
||||
maxDelayMs: 4000,
|
||||
retryIf: err =>
|
||||
!!(err && (err.networkError || err.graphQLErrors.some(e => e?.extensions?.isTransient))),
|
||||
}),
|
||||
fetchExchange,
|
||||
],
|
||||
// @ts-expect-error Type 'typeof fetch' is not assignable to type '(input: RequestInfo, init?: RequestInit | undefined) => Promise<Response>'.
|
||||
fetch,
|
||||
fetchOptions: (): { headers?: AccessTokenHeaders | SessionHeaders } => {
|
||||
@@ -45,6 +60,9 @@ export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult
|
||||
const { data, error } = await promise;
|
||||
|
||||
if (error) {
|
||||
if (error.graphQLErrors.some(e => e?.extensions?.isTransient)) {
|
||||
Log.error(`We've encountered a transient error, please try again shortly.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user