Compare commits

...
44 Commits
Author SHA1 Message Date
Dominik Sokal 7d6bdbb6ed v0.8.1 2021-04-06 12:30:31 +02:00
Dominik Sokal 403b5c237f don't use graphql cache for polling builds (#310)
* don't use graphql cache for polling builds

* update CHANGELOG
2021-04-06 12:29:41 +02:00
Dominik Sokal 2367ed7911 update CHANGELOG.md 2021-04-06 11:21:13 +02:00
Dominik Sokal b650f76979 v0.8.0 2021-04-06 11:12:43 +02:00
Dominik Sokal 96e59c198d update CHANGELOG.md 2021-04-06 11:11:47 +02:00
Dominik Sokal bf1704d268 use graphql for generating upload sessions (#307)
* use graphql for generating upload sessions

* address PR feedback

* fix tests
2021-04-06 11:06:47 +02:00
Dominik Sokal 279dfd00c8 eas build - use GraphQL instead of REST endpoints for build queries (#306)
* use graphql for printing pending builds

* use graphql for polling build status

* remove some unused code
2021-04-02 12:10:59 +02:00
Dominik Sokal 4d2577865a eas build - use GraphQL instead of REST endpoints (#303)
* regenerate graphql schema

* use graphql mutations for creating builds
2021-04-02 12:06:10 +02:00
Quinlan Jung 9d88fba40e [eas-cli] configure provisioning profile using new api (#299)
* [eas-cli] configure provisioning profile using new api

change positional args

* configure prov prof: pr feedback

* fix lint
2021-04-01 23:16:34 -07:00
Juwan Wheatley 9d2353b4f7 [eas-cli] use flags for secrets:create and rename target to scope (#300)
* use flags instead of args for secrets:create

* rename target to scope

* show secret ID before timestamp

* add changelog entries and note about name validation source of truth
2021-04-01 16:13:44 -04:00
Dominik Sokal 44b338d50a unhide secrets:create and secrets:delete (#302) 2021-04-01 19:06:23 +02:00
Bartłomiej Klocek 62114cab3b [Submit] Skip tar extraction logic in CLI (#297)
* [submit] Skip tar extraction logic for URL

* Remove tar extraction utils

* Update CHANGELOG

* Simplified path handling code

* New unit tests

* Add URL validation
2021-04-01 18:11:23 +02:00
Dominik Sokal d426fb0355 fix string interpolation 2021-04-01 11:34:32 +02:00
Quinlan Jung 2a8d6683ff [eas-cli] fix typo in tests 2021-03-31 17:57:46 -07:00
Juwan WheatleyandDominik Sokal 7b18165390 [eas-cli] fix secret name validation when prompted (#298)
* [eas-cli] fix secret name validation when prompted

* add changelog entry

* Update CHANGELOG.md

Co-authored-by: Dominik Sokal <[email protected]>

Co-authored-by: Dominik Sokal <[email protected]>
2021-03-31 19:43:46 -04:00
Dominik Sokal 814a44ac27 change cache object schema + rename secretEnvs -> environmentSecrets (#295)
* change cache object schema

* update CHANGELOG.md

* rename secretEnvs -> environmentSecrets

* update @expo/eas-build-job to 0.2.22
2021-03-31 10:20:04 +02:00
jkh f5483c8996 [eas-cli] add command eas channel:rollout (#290) 2021-03-30 13:43:37 -07:00
Juwan Wheatley acdbed4762 [eas-cli] fix "irreversible" typo (#282) 2021-03-30 16:24:19 +02:00
Dominik Sokal f09e4b54cf add distribution/release channel to graphql formatter 2021-03-30 16:23:41 +02:00
Vincent Thibault 859fdf5e46 Add distribution/channel informations in build:view/list (#284)
* Add new informations in build:view/list

* Update CHANGELOG.md

* Add nullish coalescing operator
2021-03-30 16:17:16 +02:00
Wojciech Kozyra 6aa72d5b7d [eas-cli] add analytics support (#162) 2021-03-29 11:58:05 +02:00
Quinlan Jung 886900df29 [eas-cli] make dist cert setup generic with graphql api (#294)
* [eas-cli] make dist cert setup generic

* fix tsc
2021-03-26 13:17:07 -07:00
Quinlan Jung a450fed1f8 [eas-cli] create provisioning profile using graphql api (#293) 2021-03-26 13:16:37 -07:00
Dominik Sokal 4c78100adb use graphql to create unpublished app (#288) 2021-03-26 09:01:42 +01:00
Dominik Sokal 0317e6fe71 refactor build:list and build:view to use graphql (#286) 2021-03-26 08:56:04 +01:00
jkh 6825877344 [eas-cli] add support for rollouts to channel:view command (#289) 2021-03-24 07:04:07 -07:00
Dominik Sokal a47ea88d28 Update CHANGELOG.md 2021-03-23 10:37:57 +01:00
Wojciech Kozyra a9e7f304ec Update CHANGELOG.md 2021-03-22 13:36:10 +01:00
Wojciech Kozyra d92a2ae3f4 v0.7.0 2021-03-22 13:33:34 +01:00
Dominik Sokal b7dea35da4 better message when build was canceled 2021-03-22 12:02:08 +01:00
Wojciech Kozyra 5a5f11752d Update @expo/eas-build-job 2021-03-22 12:02:08 +01:00
Wojciech Kozyra b6c300b5c2 [eas-json] Detect dependency cycle for build profiles (#283)
* [eas-json] Detect dependency cycle for build profiles

* Update CHANGELOG.md

* review feedback
2021-03-22 11:15:38 +01:00
Wojciech Kozyra 0117dbfa5f [eas-cli] verify that "name" in app.sjon can be used as iOS target name (#280) 2021-03-18 12:15:52 +01:00
Dominik Sokal c106f8a1a4 fix project utils calls after merge 2021-03-18 09:55:55 +01:00
Dominik Sokal 8b9741a70c refactor project utils (#278) 2021-03-18 09:51:30 +01:00
Juwan Wheatley 271b85fd74 [eas-cli] create commands for managing environment secrets (#275)
* set up graphql queries and mutations

* add secrets: list command

* add create command

* list all secrets

* add deletion command, add ID to list, add EAS check to create

* update changelog

* fix typo

* use target enum

* use US cancel

* move AccountResolver to generic place

* secret isn't reassigned

* check for eas in delete

* make mutation inputs required

* update generated graphql files

* use findAccountByName directly instead of AccountResolver

* update list format

* move accountresolver back into device manager file

* restore old devicemanager source

* lint
2021-03-17 14:50:40 -04:00
Dominik Sokal 21a481790b [ENG-438] allow for installing custom bundler version (#277)
* allow for installing custom bundler version

* update CHANGELOG.md

* bump @expo/eas-build-job to 0.2.20

* fix printing docs url
2021-03-17 13:52:52 +01:00
Wojciech Kozyra ab5d9237f1 [eas-cli] update external errors logic (#276) 2021-03-16 17:10:37 +01:00
Wojciech Kozyra 756853f2f3 [eas-cli] autmatically commit when EAS_BUILD_AUTOCOMMIT is set (#271) 2021-03-15 09:23:30 +01:00
Bartłomiej Klocek 5eb80d15f6 [ENG-509][submit] Fix local archive prompt (#273) 2021-03-12 11:34:31 +01:00
Dominik Sokal a98043b4ad print common error messages when builds fail (#272)
* print common error messages when builds fail

* update CHANGELOG

* address PR feedback

* address more PR feedback
2021-03-12 10:29:33 +01:00
jkh d0818932e7 [eas-cli] add --republish and --group flags to branch:publish command. (#253) 2021-03-11 05:37:18 -08:00
Dominik Sokal 23b60ed385 fix typo 2021-03-09 11:08:39 +01:00
Dominik Sokal 5d2811bf21 update CHANGELOG 2021-03-09 11:05:14 +01:00
100 changed files with 6729 additions and 1953 deletions

No files matched your search

+51 -2
View File
@@ -6,6 +6,57 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
### 🎉 New features
### 🐛 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))
### 🧹 Chores
## [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))
### 🎉 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
@@ -20,8 +71,6 @@ This is the log of notable changes to EAS CLI and related packages.
- 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))
### 🧹 Chores
## [0.5.0](https://github.com/expo/eas-cli/releases/tag/v0.5.0) - 2021-03-02
### 🎉 New features
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.6.0"
"version": "0.8.1"
}
+72 -13
View File
@@ -36,6 +36,7 @@ eas --help COMMAND
* [`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)
@@ -46,6 +47,9 @@ eas --help COMMAND
* [`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 account:login`
@@ -60,7 +64,7 @@ ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/account/login.ts)_
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/account/login.ts)_
## `eas account:logout`
@@ -74,7 +78,7 @@ ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/account/logout.ts)_
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/account/logout.ts)_
## `eas account:view`
@@ -88,7 +92,18 @@ ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/account/view.ts)_
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.8.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.8.1/build/commands/analytics.ts)_
## `eas build`
@@ -107,7 +122,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.6.0/build/commands/build/index.ts)_
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -118,7 +133,7 @@ USAGE
$ eas build:cancel [BUILD_ID]
```
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/build/cancel.ts)_
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/build/cancel.ts)_
## `eas build:configure`
@@ -133,7 +148,7 @@ OPTIONS
--allow-experimental Enable experimental configuration steps.
```
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/build/configure.ts)_
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/build/configure.ts)_
## `eas build:list`
@@ -149,7 +164,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.6.0/build/commands/build/list.ts)_
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/build/list.ts)_
## `eas build:view [BUILD_ID]`
@@ -160,7 +175,7 @@ USAGE
$ eas build:view [BUILD_ID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/build/view.ts)_
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/build/view.ts)_
## `eas credentials`
@@ -171,7 +186,7 @@ USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/credentials.ts)_
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/credentials.ts)_
## `eas device:create`
@@ -182,7 +197,7 @@ USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/device/create.ts)_
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/device/create.ts)_
## `eas device:list`
@@ -196,7 +211,7 @@ OPTIONS
--apple-team-id=apple-team-id
```
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/device/list.ts)_
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/device/list.ts)_
## `eas device:view [UDID]`
@@ -207,7 +222,7 @@ USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/device/view.ts)_
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -226,6 +241,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.8.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.8.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.8.1/build/commands/secrets/list.ts)_
## `eas submit --platform=(android|ios)`
Submits build artifact to app store
@@ -304,5 +363,5 @@ EXAMPLES
and provide its App ID
```
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.6.0/build/commands/submit.ts)_
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/submit.ts)_
<!-- commandsstop -->
File diff suppressed because it is too large. Load diff
+5 -4
View File
@@ -1,18 +1,20 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.6.0",
"version": "0.8.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.21-alpha.0",
"@expo/eas-build-job": "0.2.16",
"@expo/eas-json": "^0.6.0",
"@expo/eas-build-job": "0.2.22",
"@expo/eas-json": "^0.8.0",
"@expo/json-file": "^8.2.24",
"@expo/plist": "^0.0.11",
"@expo/plugin-warn-if-update-available": "^1.7.0",
@@ -39,7 +41,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",
+101
View File
@@ -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,
};
}
@@ -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(
+27 -2
View File
@@ -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,14 +8,17 @@ 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(
@@ -60,7 +63,7 @@ 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,
@@ -72,6 +75,28 @@ This means that it will most likely produce an AAB and you will not be able to i
}
},
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,
});
}
},
});
}
@@ -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,
};
}
@@ -11,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';
@@ -41,12 +41,12 @@ interface CommonJobProperties {
platform: Platform.ANDROID;
projectArchive: ArchiveSource;
builderEnvironment: Android.BuilderEnvironment;
cache: Cache | null;
cache: Cache;
secrets: {
buildCredentials?: {
keystore: Android.Keystore;
};
secretEnvs?: Record<string, string>;
environmentSecrets?: Record<string, string>;
};
}
@@ -54,7 +54,7 @@ async function prepareJobCommonAsync(
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
? {
@@ -84,7 +84,7 @@ async function prepareJobCommonAsync(
},
cache: ctx.buildProfile.cache,
secrets: {
...(secretEnvs ? { secretEnvs } : {}),
...(environmentSecrets ? { environmentSecrets } : {}),
...buildCredentials,
},
};
@@ -114,7 +114,7 @@ async function prepareManagedJobAsync(
): 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,
+68 -44
View File
@@ -1,20 +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,
@@ -26,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;
@@ -42,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,
});
}
@@ -68,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,
}
@@ -88,30 +96,7 @@ 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) {
if (error?.expoApiV2ErrorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli');
@@ -132,7 +117,7 @@ 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,
@@ -146,8 +131,8 @@ async function uploadProjectAsync<TPlatform extends Platform>(
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,
}
);
@@ -158,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> {
@@ -189,9 +208,14 @@ async function reviewAndCommitChangesAsync(
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({
+11 -5
View File
@@ -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]: '🤖',
};
+16 -9
View File
@@ -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,
@@ -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,
};
+37 -28
View File
@@ -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);
}
}
@@ -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);
}
}
+3 -3
View File
@@ -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,7 +19,7 @@ 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:
if (await provider.hasLocalAsync()) {
+72
View File
@@ -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;
}
}
+36 -3
View File
@@ -1,20 +1,23 @@
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,
@@ -45,9 +48,17 @@ export async function prepareIosBuildAsync(
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,6 +75,28 @@ 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,
});
}
},
});
}
@@ -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 -1
View File
@@ -28,7 +28,7 @@ export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
)?.experimental?.disableIosBundleIdentifierValidation;
if (!disableIosBundleIdentifierValidation) {
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
await ensureBundleIdentifierIsValidAsync(ctx.projectDir);
await ensureBundleIdentifierIsValidAsync(ctx.projectDir, ctx.exp);
}
if (isExpoUpdatesInstalled(ctx.projectDir)) {
+93
View File
@@ -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;
}
}
+9 -9
View File
@@ -15,7 +15,7 @@ import {
IosCredentials,
IosTargetCredentials,
isCredentialsMap,
readSecretEnvsAsync,
readEnvironmentSecretsAsync,
} from '../../credentials/credentialsJson/read';
import { getProjectAccountNameAsync } from '../../project/projectUtils';
import { gitRootDirectoryAsync } from '../../utils/git';
@@ -52,10 +52,10 @@ interface CommonJobProperties {
builderEnvironment: Ios.BuilderEnvironment;
releaseChannel?: string;
distribution?: Ios.DistributionType;
cache: Cache | null;
cache: Cache;
secrets: {
buildCredentials: Ios.BuildCredentials;
secretEnvs?: Record<string, string>;
environmentSecrets?: Record<string, string>;
};
}
@@ -67,7 +67,7 @@ async function prepareJobCommonAsync(
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)) {
@@ -96,13 +96,14 @@ async function prepareJobCommonAsync(
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,
},
};
@@ -150,13 +151,12 @@ async function prepareManagedJobAsync(
): 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,
buildType: buildProfile.buildType,
@@ -168,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')
+5 -1
View File
@@ -35,7 +35,11 @@ export async function collectMetadata<T extends Platform>(
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(),
};
+1 -43
View File
@@ -1,5 +1,4 @@
import { Metadata, Platform } from '@expo/eas-build-job';
import { AndroidBuildProfile, iOSBuildProfile } from '@expo/eas-json';
export { Platform } from '@expo/eas-build-job';
export enum RequestedPlatform {
Android = 'android',
@@ -7,8 +6,6 @@ export enum RequestedPlatform {
All = 'all',
}
export { Platform };
export enum BuildStatus {
IN_QUEUE = 'in-queue',
IN_PROGRESS = 'in-progress',
@@ -18,42 +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<Metadata>;
}
interface BuildArtifacts {
buildUrl?: string;
logsUrl: 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',
}
+25 -1
View File
@@ -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);
},
};
+42 -23
View File
@@ -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,16 +1,22 @@
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 }[]
@@ -28,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 === '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.'
@@ -87,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 }));
}
}
@@ -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.`
);
}
}
}
@@ -103,7 +103,7 @@ export default class BranchDelete 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,
@@ -129,7 +129,7 @@ export default class BranchDelete extends Command {
Log.addNewLineIfNone();
Log.warn(
`You are about to permamently delete branch: "${name}" and all of the updates published on it.` +
`\nThis action is irreversable.`
`\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
+49 -59
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import CliTable from 'cli-table3';
import gql from 'graphql-tag';
@@ -10,14 +11,56 @@ import {
Maybe,
Robot,
Update,
UpdateBranch,
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;
@@ -38,8 +81,9 @@ 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 {
@@ -51,63 +95,9 @@ export default class BranchList extends Command {
}
}
}
async listBranchesAsync({
fullName,
}: {
fullName: string;
}): Promise<
(Pick<UpdateBranch, 'id' | 'name'> & {
updates: (Pick<Update, 'id' | 'updatedAt' | 'message'> & {
actor?: Maybe<Pick<User, 'username' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
})[];
})[]
> {
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 ?? [];
}
}
function formatUpdate(
export function formatUpdate(
update: Pick<Update, 'id' | 'updatedAt' | 'message'> & {
actor?: Maybe<Pick<User, 'username' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
}
+157 -48
View File
@@ -1,51 +1,98 @@
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,
getBranchByNameAsync,
getProjectAccountNameAsync,
} from '../../project/projectUtils';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import {
buildBundlesAsync,
buildUpdateInfoGroupAsync,
collectAssets,
uploadAssetsAsync,
} from '../../project/publish';
import { promptAsync } from '../../prompts';
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,
}),
json: flags.boolean({
description: `return a json with the edited branch's ID and name.`,
default: 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 new update group.`,
default: false,
}),
};
async run() {
@@ -54,20 +101,23 @@ export default class BranchPublish extends Command {
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'"
@@ -83,12 +133,79 @@ export default class BranchPublish extends Command {
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
type: 'text',
name: 'name',
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 (!message) {
@@ -100,39 +217,20 @@ export default class BranchPublish extends Command {
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,
name: name!,
});
if (!skipBundler) {
await buildBundlesAsync({ projectDir, inputDir });
}
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,
runtimeVersion: republish ? oldRuntimeVersion! : runtimeVersion,
message,
});
publishSpinner.succeed('Published!');
@@ -176,3 +274,14 @@ export default class BranchPublish extends Command {
}
}
}
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}`;
}
@@ -75,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,
+12 -6
View File
@@ -24,12 +24,15 @@ const PAGE_LIMIT = 10_000;
type TruncatedUpdate = Pick<Update, 'group' | 'message' | 'createdAt' | 'actor'>;
async function viewUpdateBranchAsync({
export async function viewUpdateBranchAsync({
appId,
name,
}: Pick<ViewBranchQueryVariables, 'appId' | 'name'>): Promise<
Pick<UpdateBranch, 'id' | 'name'> & {
updates: (Pick<Update, 'id' | 'group' | 'message' | 'createdAt'> & {
updates: (Pick<
Update,
'id' | 'group' | 'message' | 'createdAt' | 'runtimeVersion' | 'platform' | 'manifestFragment'
> & {
actor?: Maybe<Pick<User, 'firstName' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
})[];
}
@@ -50,6 +53,9 @@ async function viewUpdateBranchAsync({
group
message
createdAt
runtimeVersion
platform
manifestFragment
actor {
id
... on User {
@@ -109,10 +115,10 @@ 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,
+6 -11
View File
@@ -1,13 +1,12 @@
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,
CancelBuildMutation,
@@ -43,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}`;
@@ -115,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> {
+57 -59
View File
@@ -1,16 +1,19 @@
import { 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();
const { 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;
}
await ensureProjectConfiguredAsync(projectDir);
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;
+59 -21
View File
@@ -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;
}
};
+24 -47
View File
@@ -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;
}
}
}
@@ -86,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,
@@ -60,7 +60,7 @@ async function getChannelByNameForAppAsync({
return updateChannelByNameResult;
}
async function updateChannelBranchMappingAsync({
export async function updateChannelBranchMappingAsync({
channelId,
branchMapping,
}: UpdateChannelBranchMappingMutationVariables): Promise<{
@@ -125,10 +125,9 @@ export default class ChannelEdit 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,
@@ -148,7 +147,11 @@ export default class ChannelEdit extends Command {
}
const existingChannel = await getChannelByNameForAppAsync({ appId: projectId, channelName });
// todo: refactor when multiple branches per channel are available
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();
@@ -171,7 +174,6 @@ export default class ChannelEdit extends Command {
}
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
const channel = await updateChannelBranchMappingAsync({
channelId: existingChannel.id,
// todo: move branch mapping logic to utility
@@ -81,10 +81,9 @@ export default class ChannelList 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,
@@ -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);
}
}
+97 -10
View File
@@ -14,7 +14,69 @@ import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
async function getUpdateChannelByNameForAppAsync({
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> {
@@ -30,7 +92,8 @@ async function getUpdateChannelByNameForAppAsync({
id
name
createdAt
updateBranches(offset: 0, limit: 1) {
branchMapping
updateBranches(offset: 0, limit: 1000) {
id
name
updates(offset: 0, limit: 1) {
@@ -89,10 +152,9 @@ export default class ChannelView 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,
@@ -125,17 +187,42 @@ export default class ChannelView extends Command {
return;
}
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(
getUpdateChannelByNameForAppresult
);
const table = new Table({
head: ['branch', 'update', 'message', 'created-at', 'actor'],
head: [
'branch',
...(isRollout ? ['rollout percent'] : []),
'active update group',
'update message',
'last publish',
'actor',
],
wordWrap: true,
});
for (const branch of channel.updateBranches) {
// TODO (cedric): refactor when multiple branches per channel are available
const update = branch.updates[0];
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(),
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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,59 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import Log from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { toggleConfirmAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
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: true,
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);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
const {
args: { id },
} = this.parse(EnvironmentSecretDelete);
Log.addNewLineIfNone();
Log.warn(
`You are about to permamently delete secret with id: "${id}".\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 secret with id: "${id}".`);
process.exit(1);
}
await EnvironmentSecretMutation.delete(id);
Log.withTick(`️Deleted secret with id "${id}".`);
}
}
@@ -0,0 +1,69 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import Table from 'cli-table3';
import dateFormat from 'dateformat';
import { EnvironmentSecretFragment } from '../../graphql/generated';
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';
import { EnvironmentSecretScope } from './create';
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 [accountSecrets, appSecrets] = await Promise.all([
EnvironmentSecretsQuery.byAcccountNameAsync(projectAccountName),
EnvironmentSecretsQuery.byAppFullNameAsync(projectFullName),
]);
const secrets = [
...appSecrets.map(s => ({ ...s, scope: EnvironmentSecretScope.PROJECT })),
...accountSecrets.map(s => ({ ...s, scope: EnvironmentSecretScope.ACCOUNT })),
] as (EnvironmentSecretFragment & { scope: EnvironmentSecretScope })[];
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());
}
}
+3 -1
View File
@@ -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();
@@ -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,6 +2,7 @@ import {
AppFragment,
AppleAppIdentifierFragment,
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
AppleTeamFragment,
} from '../../graphql/generated';
import {
@@ -56,6 +57,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==',
@@ -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(),
+2 -2
View File
@@ -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))) {
@@ -122,13 +122,13 @@ export default class IosCredentialsProvider implements CredentialsProvider {
if (this.options.distribution === 'internal' && !isAdHoc) {
throw new Error(
`You must use an adhoc provisioning profile${
targetName && ` (target '${targetName})'`
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})'`
targetName ? ` (target '${targetName}')` : ''
} for app store distribution`
);
}
@@ -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,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,83 @@
import assert from 'assert';
import nullthrows from 'nullthrows';
import { AppleDistributionCertificateFragment } from '../../../../graphql/generated';
import Log from '../../../../log';
import { Action, CredentialsManager } from '../../../CredentialsManager';
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 implements Action {
private _provisioningProfile?: AppleProvisioningProfileMutationResult;
constructor(
private app: AppLookupParams,
private distributionCertificate: AppleDistributionCertificateFragment
) {}
public get provisioningProfile(): AppleProvisioningProfileMutationResult {
assert(
this._provisioningProfile,
'provisioningProfile can be accessed only after calling .runAsync()'
);
return this._provisioningProfile;
}
async runAsync(_manager: CredentialsManager, ctx: Context): Promise<void> {
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
);
this._provisioningProfile = await ctx.newIos.createProvisioningProfileAsync(
this.app,
appleAppIdentifier,
{
appleProvisioningProfile: provisioningProfile.provisioningProfile,
developerPortalIdentifier: provisioningProfile.provisioningProfileId,
}
);
Log.succeed('Created provisioning profile');
}
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,
});
}
}
@@ -52,7 +52,7 @@ export class SetupAdhocProvisioningProfile implements Action {
private async runNonInteractiveAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
// 1. Setup Distribution Certificate
const distCertAction = new SetupDistributionCertificate(this.app);
const distCertAction = new SetupDistributionCertificate(this.app, IosDistributionType.AdHoc);
await manager.runActionAsync(distCertAction);
// 2. Fetch profile from EAS servers
@@ -97,7 +97,7 @@ 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;
@@ -22,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(
@@ -38,7 +38,7 @@ export class SetupDistributionCertificate implements Action {
try {
const currentCertificate = await ctx.newIos.getDistributionCertificateForAppAsync(
this.app,
IosDistributionType.AdHoc,
this.distributionType,
{ appleTeam }
);
@@ -61,9 +61,7 @@ export class SetupDistributionCertificate implements Action {
): Promise<void> {
// TODO: implement validation
Log.addNewLineIfNone();
Log.warn(
'Distribution Certificate is not validated for non-interactive internal distribution builds.'
);
Log.warn('Distribution Certificate is not validated for non-interactive builds.');
if (!currentCertificate) {
throw new MissingCredentialsNonInteractiveError();
}
@@ -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);
});
});
@@ -0,0 +1,57 @@
import { confirmAsync } from '../../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../../__tests__/fixtures-appstore';
import { createCtxMock, createManagerMock } 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 manager = createManagerMock();
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(manager, 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 manager = createManagerMock();
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const createProvProfAction = new CreateProvisioningProfile(
appLookupParams,
testDistCertFragmentNoDependencies
);
await expect(createProvProfAction.runAsync(manager, 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);
});
});
+11 -1
View File
@@ -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,
};
}
+7 -7
View File
@@ -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(
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,37 @@
import assert from 'assert';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import { AppPrivacy, CreateAppMutation, CreateAppMutationVariables } from '../generated';
const AppMutation = {
async createAppAsync(appInput: {
accountId: string;
projectName: string;
privacy: AppPrivacy;
}): Promise<string> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateAppMutation, CreateAppMutationVariables>(
gql`
mutation CreateAppMutation($appInput: AppInput!) {
app {
createApp(appInput: $appInput) {
id
}
}
}
`,
{
appInput,
}
)
.toPromise()
);
const appId = data.app?.createApp.id;
assert(appId, 'App ID must be defined');
return appId;
},
};
export { AppMutation };
@@ -0,0 +1,162 @@
import gql from 'graphql-tag';
import nullthrows from 'nullthrows';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
AndroidGenericJobInput,
AndroidManagedJobInput,
BuildMetadataInput,
CreateAndroidGenericBuildMutation,
CreateAndroidGenericBuildMutationVariables,
CreateAndroidManagedBuildMutation,
CreateAndroidManagedBuildMutationVariables,
CreateIosGenericBuildMutation,
CreateIosGenericBuildMutationVariables,
CreateIosManagedBuildMutation,
CreateIosManagedBuildMutationVariables,
EasBuildDeprecationInfo,
IosGenericJobInput,
IosManagedJobInput,
} from '../generated';
export interface BuildResult {
build: { id: string };
deprecationInfo?: EasBuildDeprecationInfo | null;
}
const BuildMutation = {
async createAndroidGenericBuildAsync(input: {
appId: string;
job: AndroidGenericJobInput;
metadata: BuildMetadataInput;
}): Promise<BuildResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateAndroidGenericBuildMutation, CreateAndroidGenericBuildMutationVariables>(
gql`
mutation CreateAndroidGenericBuildMutation(
$appId: ID!
$job: AndroidGenericJobInput!
$metadata: BuildMetadataInput
) {
build {
createAndroidGenericBuild(appId: $appId, job: $job, metadata: $metadata) {
build {
id
}
deprecationInfo {
type
message
}
}
}
}
`,
input
)
.toPromise()
);
return nullthrows(data.build?.createAndroidGenericBuild);
},
async createAndroidManagedBuildAsync(input: {
appId: string;
job: AndroidManagedJobInput;
metadata: BuildMetadataInput;
}): Promise<BuildResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateAndroidManagedBuildMutation, CreateAndroidManagedBuildMutationVariables>(
gql`
mutation CreateAndroidManagedBuildMutation(
$appId: ID!
$job: AndroidManagedJobInput!
$metadata: BuildMetadataInput
) {
build {
createAndroidManagedBuild(appId: $appId, job: $job, metadata: $metadata) {
build {
id
}
deprecationInfo {
type
message
}
}
}
}
`,
input
)
.toPromise()
);
return nullthrows(data.build?.createAndroidManagedBuild);
},
async createIosGenericBuildAsync(input: {
appId: string;
job: IosGenericJobInput;
metadata: BuildMetadataInput;
}): Promise<BuildResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateIosGenericBuildMutation, CreateIosGenericBuildMutationVariables>(
gql`
mutation CreateIosGenericBuildMutation(
$appId: ID!
$job: IosGenericJobInput!
$metadata: BuildMetadataInput
) {
build {
createIosGenericBuild(appId: $appId, job: $job, metadata: $metadata) {
build {
id
}
deprecationInfo {
type
message
}
}
}
}
`,
input
)
.toPromise()
);
return nullthrows(data.build?.createIosGenericBuild);
},
async createIosManagedBuildAsync(input: {
appId: string;
job: IosManagedJobInput;
metadata: BuildMetadataInput;
}): Promise<BuildResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateIosManagedBuildMutation, CreateIosManagedBuildMutationVariables>(
gql`
mutation CreateIosManagedBuildMutation(
$appId: ID!
$job: IosManagedJobInput!
$metadata: BuildMetadataInput
) {
build {
createIosManagedBuild(appId: $appId, job: $job, metadata: $metadata) {
build {
id
}
deprecationInfo {
type
message
}
}
}
}
`,
input
)
.toPromise()
);
return nullthrows(data.build?.createIosManagedBuild);
},
};
export { BuildMutation };
@@ -0,0 +1,93 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
CreateEnvironmentSecretForAccountMutation,
CreateEnvironmentSecretForAppMutation,
DeleteEnvironmentSecretMutation,
EnvironmentSecretFragment,
} from '../generated';
import { EnvironmentSecretFragmentNode } from '../types/EnvironmentSecret';
export const EnvironmentSecretMutation = {
async createForAccount(
input: { name: string; value: string },
accountId: string
): Promise<EnvironmentSecretFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateEnvironmentSecretForAccountMutation>(
gql`
mutation CreateEnvironmentSecretForAccount(
$input: CreateEnvironmentSecretInput!
$accountId: String!
) {
environmentSecret {
createEnvironmentSecretForAccount(
environmentSecretData: $input
accountId: $accountId
) {
id
...EnvironmentSecretFragment
}
}
}
${print(EnvironmentSecretFragmentNode)}
`,
{ input, accountId }
)
.toPromise()
);
return data.environmentSecret.createEnvironmentSecretForAccount;
},
async createForApp(
input: { name: string; value: string },
appId: string
): Promise<EnvironmentSecretFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<CreateEnvironmentSecretForAppMutation>(
gql`
mutation CreateEnvironmentSecretForApp(
$input: CreateEnvironmentSecretInput!
$appId: String!
) {
environmentSecret {
createEnvironmentSecretForApp(environmentSecretData: $input, appId: $appId) {
id
...EnvironmentSecretFragment
}
}
}
${print(EnvironmentSecretFragmentNode)}
`,
{ input, appId }
)
.toPromise()
);
return data.environmentSecret.createEnvironmentSecretForApp;
},
async delete(id: string): Promise<{ id: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<DeleteEnvironmentSecretMutation>(
gql`
mutation DeleteEnvironmentSecret($id: String!) {
environmentSecret {
deleteEnvironmentSecret(id: $id) {
id
}
}
}
`,
{ id }
)
.toPromise()
);
return data.environmentSecret.deleteEnvironmentSecret;
},
};
@@ -31,7 +31,7 @@ const PublishMutation = {
async publishUpdateGroupAsync(
publishUpdateGroupInput: PublishUpdateGroupInput
): Promise<Pick<Update, 'id' | 'group'>> {
): Promise<Pick<Update, 'group'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UpdatePublishMutation>(
@@ -49,7 +49,8 @@ const PublishMutation = {
)
.toPromise()
);
return data.updateBranch.publishUpdateGroup[0]!;
const { group } = data.updateBranch.publishUpdateGroup[0]!;
return { group };
},
};
@@ -0,0 +1,35 @@
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
CreateUploadSessionMutation,
CreateUploadSessionMutationVariables,
UploadSessionType,
} from '../generated';
export interface PresignedPost {
url: string;
fields: Record<string, string>;
}
export const UploadSessionMutation = {
async createUploadSession(type: UploadSessionType): Promise<PresignedPost> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateUploadSessionMutation, CreateUploadSessionMutationVariables>(
gql`
mutation CreateUploadSessionMutation($type: UploadSessionType!) {
uploadSession {
createUploadSession(type: $type)
}
}
`,
{
type,
}
)
.toPromise()
);
return data.uploadSession.createUploadSession;
},
};
@@ -1,43 +1,53 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
AppPlatform,
Build,
BuildFragment,
BuildStatus,
BuildsByIdQuery,
BuildsForAppQuery,
BuildsByIdQueryVariables,
GetAllBuildsForAppQuery,
GetAllBuildsForAppQueryVariables,
PendingBuildsForAccountAndPlatformQuery,
PendingBuildsForAccountAndPlatformQueryVariables,
} from '../generated';
import { BuildFragmentNode } from '../types/Build';
type Filters = Partial<Pick<Build, 'platform' | 'status'>> & {
type Filters = {
platform?: AppPlatform;
status?: BuildStatus;
offset?: number;
limit?: number;
};
type BuildQueryResult = Pick<Build, 'id' | 'platform' | 'artifacts' | 'status' | 'createdAt'>;
type PendingBuildQueryResult = Pick<Build, 'id' | 'platform'>;
const BuildQuery = {
async byIdAsync(buildId: string): Promise<BuildQueryResult> {
export const BuildQuery = {
async byIdAsync(
buildId: string,
{ useCache = true }: { useCache?: boolean } = {}
): Promise<BuildFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<BuildsByIdQuery>(
.query<BuildsByIdQuery, BuildsByIdQueryVariables>(
gql`
query BuildsByIdQuery($buildId: ID!) {
builds {
byId(buildId: $buildId) {
id
platform
status
createdAt
artifacts {
buildUrl
}
...BuildFragment
}
}
}
${print(BuildFragmentNode)}
`,
{ buildId }
{ buildId },
{
requestPolicy: useCache ? 'cache-first' : 'network-only',
}
)
.toPromise()
);
@@ -45,44 +55,40 @@ const BuildQuery = {
return data.builds.byId;
},
async allForAppAsync(appId: string, filters?: Filters): Promise<BuildQueryResult[]> {
async allForAppAsync(
appId: string,
{ limit = 10, offset = 0, status, platform }: Filters
): Promise<BuildFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<BuildsForAppQuery>(
.query<GetAllBuildsForAppQuery, GetAllBuildsForAppQueryVariables>(
// TODO: Change $appId: String! to ID! when fixed server-side schema
gql`
query BuildsForAppQuery(
query GetAllBuildsForApp(
$appId: String!
$limit: Int
$offset: Int
$platform: AppPlatform
$offset: Int!
$limit: Int!
$status: BuildStatus
$platform: AppPlatform
) {
builds {
allForApp(
appId: $appId
limit: $limit
offset: $offset
platform: $platform
status: $status
) {
app {
byId(appId: $appId) {
id
platform
status
createdAt
artifacts {
buildUrl
builds(offset: $offset, limit: $limit, status: $status, platform: $platform) {
id
...BuildFragment
}
}
}
}
${print(BuildFragmentNode)}
`,
{ ...filters, appId }
{ appId, offset, limit, status, platform }
)
.toPromise()
);
return data.builds.allForApp as BuildQueryResult[];
return data.app?.byId.builds ?? [];
},
async getPendingBuildIdAsync(
@@ -91,7 +97,10 @@ const BuildQuery = {
): Promise<PendingBuildQueryResult | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<PendingBuildsForAccountAndPlatformQuery>(
.query<
PendingBuildsForAccountAndPlatformQuery,
PendingBuildsForAccountAndPlatformQueryVariables
>(
gql`
query PendingBuildsForAccountAndPlatform(
$accountName: String!
@@ -133,5 +142,3 @@ const BuildQuery = {
return pendingBuilds.length > 0 ? pendingBuilds[0] : null;
},
};
export { BuildQuery };
@@ -0,0 +1,63 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
EnvironmentSecretFragment,
EnvironmentSecretsByAccountNameQuery,
EnvironmentSecretsByAppFullNameQuery,
} from '../generated';
import { EnvironmentSecretFragmentNode } from '../types/EnvironmentSecret';
export const EnvironmentSecretsQuery = {
async byAcccountNameAsync(accountName: string): Promise<EnvironmentSecretFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<EnvironmentSecretsByAccountNameQuery>(
gql`
query EnvironmentSecretsByAccountName($accountName: String!) {
account {
byName(accountName: $accountName) {
id
environmentSecrets {
id
...EnvironmentSecretFragment
}
}
}
}
${print(EnvironmentSecretFragmentNode)}
`,
{ accountName }
)
.toPromise()
);
return data.account.byName.environmentSecrets;
},
async byAppFullNameAsync(fullName: string): Promise<EnvironmentSecretFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<EnvironmentSecretsByAppFullNameQuery>(
gql`
query EnvironmentSecretsByAppFullName($fullName: String!) {
app {
byFullName(fullName: $fullName) {
id
environmentSecrets {
id
...EnvironmentSecretFragment
}
}
}
}
${print(EnvironmentSecretFragmentNode)}
`,
{ fullName }
)
.toPromise()
);
return data.app?.byFullName.environmentSecrets ?? [];
},
};
@@ -0,0 +1,42 @@
import gql from 'graphql-tag';
export const BuildFragmentNode = gql`
fragment BuildFragment on Build {
id
status
platform
error {
errorCode
message
docsUrl
}
artifacts {
buildUrl
xcodeBuildLogsUrl
}
initiatingActor {
__typename
id
... on User {
username
}
... on Robot {
firstName
}
}
project {
__typename
id
... on App {
ownerAccount {
id
name
}
}
}
releaseChannel
distribution
createdAt
updatedAt
}
`;
@@ -0,0 +1,9 @@
import gql from 'graphql-tag';
export const EnvironmentSecretFragmentNode = gql`
fragment EnvironmentSecretFragment on EnvironmentSecret {
id
name
createdAt
}
`;
+9 -4
View File
@@ -78,15 +78,20 @@ export default class Log {
}
/**
* Format links as dim with an underline.
* Format links as dim (unless disabled) with an underline.
*
* @example Learn more: https://expo.io
* @param url
*/
export function learnMore(url: string, learnMoreMessage?: string): string {
export function learnMore(
url: string,
{ learnMoreMessage, dim = true }: { learnMoreMessage?: string; dim?: boolean } = {}
): string {
// Links can be disabled via env variables https://github.com/jamestalmage/supports-hyperlinks/blob/master/index.js
if (terminalLink.isSupported) {
return chalk.dim(terminalLink(learnMoreMessage ?? 'Learn more.', url));
const text = terminalLink(learnMoreMessage ?? 'Learn more.', url);
return dim ? chalk.dim(text) : text;
}
return chalk.dim(`${learnMoreMessage ?? 'Learn more'}: ${chalk.underline(url)}`);
const text = `${learnMoreMessage ?? 'Learn more'}: ${chalk.underline(url)}`;
return dim ? chalk.dim(text) : text;
}
@@ -1,4 +1,3 @@
import { getConfig } from '@expo/config';
import { vol } from 'memfs';
import { asMock } from '../../__tests__/utils';
@@ -64,6 +63,7 @@ describe(getProjectAccountName, () => {
__typename: 'User',
id: 'userId',
username: 'notbrent',
accounts: [],
});
expect(projectAccountName).toBe(expWithOwner.owner);
});
@@ -73,6 +73,7 @@ describe(getProjectAccountName, () => {
__typename: 'Robot',
id: 'userId',
firstName: 'notauser',
accounts: [],
});
expect(projectAccountName).toBe(expWithOwner.owner);
});
@@ -82,6 +83,7 @@ describe(getProjectAccountName, () => {
__typename: 'User',
id: 'userId',
username: 'dominik',
accounts: [],
});
expect(projectAccountName).toBe('dominik');
});
@@ -92,23 +94,21 @@ describe(getProjectAccountName, () => {
__typename: 'Robot',
id: 'userId',
firstName: 'notauser',
accounts: [],
});
expect(resolveProjectAccountName).toThrow('manifest property is required');
});
});
describe(getProjectAccountNameAsync, () => {
const expWithOwner: any = { owner: 'dominik' };
const expWithoutOwner: any = {};
beforeEach(() => {
asMock(getConfig).mockReset();
asMock(getUserAsync).mockReset();
});
it(`returns the owner field's value from app.json / app.config.js`, async () => {
asMock(getConfig).mockImplementation(() => ({
exp: {
owner: 'dominik',
},
}));
asMock(getUserAsync).mockImplementation((): Actor | undefined => ({
__typename: 'User',
id: 'user_id',
@@ -119,14 +119,11 @@ describe(getProjectAccountNameAsync, () => {
],
}));
const projectAccountName = await getProjectAccountNameAsync('/app');
const projectAccountName = await getProjectAccountNameAsync(expWithOwner);
expect(projectAccountName).toBe('dominik');
});
it(`returns the username if owner field is not set in app.json / app.config.js`, async () => {
asMock(getConfig).mockImplementation(() => ({
exp: {},
}));
asMock(getUserAsync).mockImplementation((): Actor | undefined => ({
__typename: 'User',
id: 'user_id',
@@ -137,25 +134,19 @@ describe(getProjectAccountNameAsync, () => {
],
}));
const projectAccountName = await getProjectAccountNameAsync('/app');
const projectAccountName = await getProjectAccountNameAsync(expWithoutOwner);
expect(projectAccountName).toBe('notnotbrent');
});
it(`throws an error if the user is not logged in`, async () => {
asMock(getConfig).mockImplementation(() => ({
exp: {
owner: 'dominik',
},
}));
asMock(getUserAsync).mockImplementation((): Actor | undefined => undefined);
await expect(getProjectAccountNameAsync('/app')).rejects.toThrow(/Failed to access user data/);
await expect(getProjectAccountNameAsync(expWithOwner)).rejects.toThrow(
/Failed to access user data/
);
});
it(`throws when project owner is undefined for robot actors`, async () => {
asMock(getConfig).mockImplementation(() => ({
exp: {},
}));
asMock(getUserAsync).mockImplementation((): Actor | undefined => ({
__typename: 'Robot',
id: 'user_id',
@@ -165,7 +156,7 @@ describe(getProjectAccountNameAsync, () => {
{ id: 'account_id_2', name: 'dominik' },
],
}));
await expect(getProjectAccountNameAsync('/app')).rejects.toThrow(
await expect(getProjectAccountNameAsync(expWithoutOwner)).rejects.toThrow(
'manifest property is required'
);
});
@@ -1,14 +1,17 @@
import { ExpoConfig } from '@expo/config';
import assert from 'assert';
import chalk from 'chalk';
import ora from 'ora';
import { apiClient } from '../api';
import { AppPrivacy } from '../graphql/generated';
import { AppMutation } from '../graphql/mutations/AppMutation';
import { ProjectQuery } from '../graphql/queries/ProjectQuery';
import { findAccountByName } from '../user/Account';
import { ensureLoggedInAsync } from '../user/actions';
interface ProjectInfo {
accountName: string;
projectName: string;
privacy?: ExpoConfig['privacy'];
privacy?: AppPrivacy;
}
/**
@@ -17,6 +20,11 @@ interface ProjectInfo {
*/
export async function ensureProjectExistsAsync(projectInfo: ProjectInfo): Promise<string> {
const { accountName, projectName } = projectInfo;
const actor = await ensureLoggedInAsync();
const account = findAccountByName(actor.accounts, accountName);
assert(account, `You must have access to the ${accountName} account to run this command`);
const projectFullName = `@${accountName}/${projectName}`;
const spinner = ora(`Linking to project ${chalk.bold(projectFullName)}`).start();
@@ -34,7 +42,11 @@ export async function ensureProjectExistsAsync(projectInfo: ProjectInfo): Promis
try {
spinner.text = `Creating ${chalk.bold(projectFullName)} on Expo`;
const id = await registerNewProjectAsync(projectInfo);
const id = await registerNewProjectAsync({
accountId: account.id,
projectName,
privacy: projectInfo.privacy,
});
spinner.succeed(`Created ${chalk.bold(projectFullName)} on Expo`);
return id;
} catch (err) {
@@ -62,18 +74,17 @@ async function findProjectIdByAccountNameAndSlugAsync(
* @returns Created project's ID
*/
async function registerNewProjectAsync({
accountName,
accountId,
projectName,
privacy,
}: ProjectInfo): Promise<string> {
const { data } = await apiClient
.post('projects', {
json: {
accountName,
projectName,
privacy: privacy ?? 'public',
},
})
.json();
return data.id;
}: {
accountId: string;
projectName: string;
privacy?: AppPrivacy;
}): Promise<string> {
return AppMutation.createAppAsync({
accountId,
projectName,
privacy: privacy ?? AppPrivacy.Public,
});
}
+36 -20
View File
@@ -1,4 +1,4 @@
import { ExpoConfig, getConfig, getConfigFilePaths } from '@expo/config';
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { AndroidConfig, IOSConfig } from '@expo/config-plugins';
import { Platform } from '@expo/eas-build-job';
import fs from 'fs-extra';
@@ -7,7 +7,7 @@ import path from 'path';
import pkgDir from 'pkg-dir';
import { graphqlClient, withErrorHandlingAsync } from '../graphql/client';
import { UpdateBranch } from '../graphql/generated';
import { AppPrivacy, UpdateBranch } from '../graphql/generated';
import { Actor } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { ensureProjectExistsAsync } from './ensureProjectExists';
@@ -26,8 +26,7 @@ export function getProjectAccountName(exp: ExpoConfig, user: Actor): string {
}
}
export async function getProjectAccountNameAsync(projectDir: string): Promise<string> {
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
export async function getProjectAccountNameAsync(exp: ExpoConfig): Promise<string> {
const user = await ensureLoggedInAsync();
return getProjectAccountName(exp, user);
}
@@ -37,19 +36,27 @@ export async function findProjectRootAsync(cwd?: string): Promise<string | null>
return projectRootDir ?? null;
}
export async function getProjectIdAsync(projectDir: string): Promise<string> {
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
export async function getProjectIdAsync(exp: ExpoConfig): Promise<string> {
const privacy = toAppPrivacy(exp.privacy);
return await ensureProjectExistsAsync({
accountName: getProjectAccountName(exp, await ensureLoggedInAsync()),
projectName: exp.slug,
privacy: exp.privacy,
privacy,
});
}
export async function getProjectFullNameAsync(projectDir: string): Promise<string> {
const accountName = await getProjectAccountNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const toAppPrivacy = (privacy: ExpoConfig['privacy']) => {
if (privacy === 'public') {
return AppPrivacy.Public;
} else if (privacy === 'hidden') {
return AppPrivacy.Hidden;
} else {
return AppPrivacy.Unlisted;
}
};
export async function getProjectFullNameAsync(exp: ExpoConfig): Promise<string> {
const accountName = await getProjectAccountNameAsync(exp);
return `@${accountName}/${exp.slug}`;
}
@@ -65,11 +72,15 @@ export async function getAndroidApplicationIdAsync(projectDir: string): Promise<
return matchResult?.[1] ?? null;
}
export async function getAppIdentifierAsync(
projectDir: string,
platform: Platform
): Promise<string | null> {
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
export async function getAppIdentifierAsync({
projectDir,
platform,
exp,
}: {
projectDir: string;
platform: Platform;
exp: ExpoConfig;
}): Promise<string | null> {
switch (platform) {
case Platform.ANDROID: {
const packageNameFromConfig = AndroidConfig.Package.getPackage(exp);
@@ -89,11 +100,16 @@ export async function getAppIdentifierAsync(
}
}
export async function ensureAppIdentifierIsDefinedAsync(
projectDir: string,
platform: Platform
): Promise<string> {
const appIdentifier = await getAppIdentifierAsync(projectDir, platform);
export async function ensureAppIdentifierIsDefinedAsync({
projectDir,
platform,
exp,
}: {
projectDir: string;
platform: Platform;
exp: ExpoConfig;
}): Promise<string> {
const appIdentifier = await getAppIdentifierAsync({ projectDir, platform, exp });
if (!appIdentifier) {
const desc = getProjectConfigDescription(projectDir);
const fieldStr = platform === Platform.ANDROID ? 'android.package' : 'ios.bundleIdentifier';
+2 -1
View File
@@ -10,9 +10,10 @@ import path from 'path';
import { AssetMetadataStatus, PartialManifestAsset } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import { PresignedPost } from '../graphql/mutations/UploadSessionMutation';
import { PublishQuery } from '../graphql/queries/PublishQuery';
import Log from '../log';
import { PresignedPost, uploadWithPresignedPostAsync } from '../uploads';
import { uploadWithPresignedPostAsync } from '../uploads';
export const TIMEOUT_LIMIT = 60_000; // 1 minute
const STORAGE_BUCKET = getStorageBucket();
@@ -6,13 +6,7 @@ import Log from '../../log';
import { promptAsync } from '../../prompts';
import { SubmissionPlatform } from '../types';
import { getBuildArtifactUrlByIdAsync, getLatestBuildArtifactUrlAsync } from '../utils/builds';
import {
downloadAppArchiveAsync,
extractLocalArchiveAsync,
isExistingFile,
pathIsTar,
uploadAppArchiveAsync,
} from '../utils/files';
import { isExistingFile, uploadAppArchiveAsync } from '../utils/files';
export enum ArchiveFileSourceType {
url,
@@ -50,10 +44,9 @@ interface ArchiveFileBuildIdSource extends ArchiveFileSourceBase {
interface ArchiveFilePromptSource extends ArchiveFileSourceBase {
sourceType: ArchiveFileSourceType.prompt;
projectDir: string;
}
interface ResolvedArchive {
export interface ResolvedArchive {
location: string;
realSource: ArchiveFileSource;
}
@@ -72,48 +65,29 @@ export async function getArchiveFileLocationAsync(
case ArchiveFileSourceType.prompt:
return await handlePromptSourceAsync(source);
case ArchiveFileSourceType.url: {
const urlArchive = await handleUrlSourceAsync(source);
const resolvedUrl = await getArchiveLocationForUrlAsync(urlArchive.location);
return { ...urlArchive, location: resolvedUrl };
return await handleUrlSourceAsync(source);
}
case ArchiveFileSourceType.latest: {
const urlArchive = await handleLatestSourceAsync(source);
const resolvedUrl = await getArchiveLocationForUrlAsync(urlArchive.location);
return { ...urlArchive, location: resolvedUrl };
return await handleLatestSourceAsync(source);
}
case ArchiveFileSourceType.path: {
const pathArchive = await handlePathSourceAsync(source);
const resolvedUrl = await getArchiveLocationForPathAsync(pathArchive.location);
return { ...pathArchive, location: resolvedUrl };
return await handlePathSourceAsync(source);
}
case ArchiveFileSourceType.buildId: {
const urlArchive = await handleBuildIdSourceAsync(source);
const resolvedUrl = await getArchiveLocationForUrlAsync(urlArchive.location);
return { ...urlArchive, location: resolvedUrl };
return await handleBuildIdSourceAsync(source);
}
}
}
async function getArchiveLocationForUrlAsync(url: string): Promise<string> {
// When a URL points to a tar file, download it and extract using unified logic.
// Otherwise send it directly to the server in online mode.
if (!pathIsTar(url)) {
return url;
} else {
Log.log('Downloading your app archive');
const localPath = await downloadAppArchiveAsync(url);
return await getArchiveLocationForPathAsync(localPath);
}
}
async function getArchiveLocationForPathAsync(path: string): Promise<string> {
const resolvedPath = await extractLocalArchiveAsync(path);
Log.log('Uploading your app archive to the Expo Submission Service');
return await uploadAppArchiveAsync(resolvedPath);
}
async function handleUrlSourceAsync(source: ArchiveFileUrlSource): Promise<ResolvedArchive> {
if (!validateUrl(source.url)) {
Log.error(chalk.bold(`The URL you provided is invalid: ${source.url}`));
return getArchiveFileLocationAsync({
...source,
sourceType: ArchiveFileSourceType.prompt,
});
}
return {
location: source.url,
realSource: source,
@@ -154,8 +128,11 @@ async function handlePathSourceAsync(source: ArchiveFilePathSource): Promise<Res
sourceType: ArchiveFileSourceType.prompt,
});
}
Log.log('Uploading your app archive to the Expo Submission Service');
const uploadUrl = await uploadAppArchiveAsync(source.path);
return {
location: source.path,
location: uploadUrl,
realSource: source,
};
}
@@ -210,7 +187,7 @@ async function handlePromptSourceAsync(source: ArchiveFilePromptSource): Promise
});
}
case ArchiveFileSourceType.path: {
const path = await askForArchivePathAsync();
const path = await askForArchivePathAsync(source.platform);
return getArchiveFileLocationAsync({
...source,
sourceType: ArchiveFileSourceType.path,
@@ -258,11 +235,12 @@ async function askForArchiveUrlAsync(): Promise<string> {
return url;
}
async function askForArchivePathAsync(): Promise<string> {
const defaultArchivePath = '/path/to/your/archive.aab';
async function askForArchivePathAsync(platform: SubmissionPlatform): Promise<string> {
const isIos = platform === SubmissionPlatform.iOS;
const defaultArchivePath = `/path/to/your/archive.${isIos ? 'ipa' : 'aab'}`;
const { path } = await promptAsync({
name: 'path',
message: 'Path to the app archive file (aab or apk):',
message: `Path to the app archive file (${isIos ? 'ipa' : 'aab or apk'}):`,
initial: defaultArchivePath,
type: 'text',
validate: async (path: string): Promise<boolean | string> => {
@@ -0,0 +1,177 @@
import fs from 'fs-extra';
import { vol } from 'memfs';
import { v4 as uuidv4 } from 'uuid';
import { asMock } from '../../../__tests__/utils';
import { UploadSessionType } from '../../../graphql/generated';
import { promptAsync } from '../../../prompts';
import { uploadAsync } from '../../../uploads';
import { SubmissionPlatform } from '../../types';
import { getBuildArtifactUrlByIdAsync, getLatestBuildArtifactUrlAsync } from '../../utils/builds';
import {
ArchiveFileSourceType,
ResolvedArchive,
getArchiveFileLocationAsync,
} from '../ArchiveFileSource';
jest.mock('fs');
jest.mock('../../../log');
jest.mock('../../../prompts');
jest.mock('../../utils/builds');
jest.mock('../../../uploads');
const ARCHIVE_URL = 'https://url.to/archive.tar.gz';
const SOURCE_STUB_INPUT = {
projectId: uuidv4(),
platform: SubmissionPlatform.Android,
projectDir: '.',
};
describe(getArchiveFileLocationAsync, () => {
beforeEach(() => {
vol.reset();
asMock(promptAsync).mockReset();
asMock(promptAsync).mockImplementation(() => {
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
});
});
it('handles URL source', async () => {
const resolvedArchive = await getArchiveFileLocationAsync({
...SOURCE_STUB_INPUT,
sourceType: ArchiveFileSourceType.url,
url: ARCHIVE_URL,
});
assertArchiveResult(resolvedArchive, ArchiveFileSourceType.url);
});
it('prompts again if provided URL is invalid', async () => {
asMock(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveFileSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
const resolvedArchive = await getArchiveFileLocationAsync({
...SOURCE_STUB_INPUT,
sourceType: ArchiveFileSourceType.url,
url: 'invalid',
});
expect(promptAsync).toHaveBeenCalledTimes(2);
assertArchiveResult(resolvedArchive, ArchiveFileSourceType.url);
});
it('handles prompt source', async () => {
asMock(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveFileSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
const resolvedArchive = await getArchiveFileLocationAsync({
...SOURCE_STUB_INPUT,
sourceType: ArchiveFileSourceType.prompt,
});
assertArchiveResult(resolvedArchive, ArchiveFileSourceType.url);
});
it('handles Build ID source', async () => {
const buildId = uuidv4();
asMock(getBuildArtifactUrlByIdAsync).mockResolvedValueOnce(ARCHIVE_URL);
const resolvedArchive = await getArchiveFileLocationAsync({
...SOURCE_STUB_INPUT,
sourceType: ArchiveFileSourceType.buildId,
id: buildId,
});
expect(getBuildArtifactUrlByIdAsync).toBeCalledWith(SOURCE_STUB_INPUT.platform, buildId);
assertArchiveResult(resolvedArchive, ArchiveFileSourceType.buildId);
});
it('prompts again if build with provided ID doesnt exist', async () => {
asMock(getBuildArtifactUrlByIdAsync).mockRejectedValue(new Error('Build doesnt exist'));
asMock(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveFileSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
const resolvedArchive = await getArchiveFileLocationAsync({
...SOURCE_STUB_INPUT,
sourceType: ArchiveFileSourceType.buildId,
id: uuidv4(),
});
assertArchiveResult(resolvedArchive, ArchiveFileSourceType.url);
});
it('handles latest build source', async () => {
const projectId = uuidv4();
asMock(getLatestBuildArtifactUrlAsync).mockResolvedValueOnce(ARCHIVE_URL);
const resolvedArchive = await getArchiveFileLocationAsync({
...SOURCE_STUB_INPUT,
projectId,
sourceType: ArchiveFileSourceType.latest,
});
expect(getLatestBuildArtifactUrlAsync).toBeCalledWith(SOURCE_STUB_INPUT.platform, projectId);
assertArchiveResult(resolvedArchive, ArchiveFileSourceType.latest);
});
it('prompts again if no builds exists when selected latest', async () => {
asMock(getLatestBuildArtifactUrlAsync).mockResolvedValueOnce(null);
asMock(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveFileSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
const resolvedArchive = await getArchiveFileLocationAsync({
...SOURCE_STUB_INPUT,
sourceType: ArchiveFileSourceType.latest,
});
assertArchiveResult(resolvedArchive, ArchiveFileSourceType.url);
});
it('handles path source', async () => {
const path = '/archive.apk';
await fs.writeFile(path, 'some content');
asMock(uploadAsync).mockResolvedValueOnce({ url: ARCHIVE_URL });
const resolvedArchive = await getArchiveFileLocationAsync({
...SOURCE_STUB_INPUT,
sourceType: ArchiveFileSourceType.path,
path,
});
expect(uploadAsync).toBeCalledWith(
UploadSessionType.EasSubmitAppArchive,
path,
expect.anything()
);
assertArchiveResult(resolvedArchive, ArchiveFileSourceType.path);
});
it('prompts again if provided path doesnt exist', async () => {
asMock(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveFileSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
const resolvedArchive = await getArchiveFileLocationAsync({
...SOURCE_STUB_INPUT,
sourceType: ArchiveFileSourceType.path,
path: './doesnt/exist.aab',
});
assertArchiveResult(resolvedArchive, ArchiveFileSourceType.url);
});
});
function assertArchiveResult(
resolvedArchive: ResolvedArchive,
expectedSourceType: ArchiveFileSourceType,
expectedLocation: string = ARCHIVE_URL
) {
expect(resolvedArchive.realSource.sourceType).toBe(expectedSourceType);
expect(resolvedArchive.location).toBe(expectedLocation);
}
@@ -39,7 +39,8 @@ export async function ensureAppStoreConnectAppExistsAsync(
const { bundleIdentifier, appName, language } = ctx.commandFlags;
const resolvedBundleId =
bundleIdentifier ?? (await getAppIdentifierAsync(ctx.projectDir, Platform.IOS));
bundleIdentifier ??
(await getAppIdentifierAsync({ projectDir: ctx.projectDir, platform: Platform.IOS, exp }));
if (!resolvedBundleId) {
throw new Error(
`Please define "expo.ios.bundleIdentifier" in your app config or provide it using --bundle-identifier param.
@@ -60,7 +60,7 @@ class IosSubmitter extends BaseSubmitter<IosSubmissionContext, IosSubmissionOpti
'- When it’s done, you can see your build here' +
learnMore(
`https://appstoreconnect.apple.com/apps/${this.options.ascAppId}/appstore/ios`,
''
{ learnMoreMessage: '' }
)
);
}
@@ -1,63 +0,0 @@
import { vol } from 'memfs';
import * as path from 'path';
import { downloadAppArchiveAsync, extractLocalArchiveAsync } from '../files';
jest.mock('fs');
const originalFs = jest.requireActual('fs');
jest.mock('got', () => {
return {
stream() {
return {
on() {
const fs = require('fs');
return fs.createReadStream('/apk-archive.tar.gz');
},
};
},
};
});
jest.mock('../files', () => {
const filesModule = jest.requireActual('../files');
return {
...filesModule,
createDownloadStream() {
const fs = require('fs');
const path = require('path');
return fs.createReadStream(path.resolve('fixtures/apk-archive.tar.gz'));
},
};
});
beforeAll(async () => {
vol.writeFileSync(
'/apk-archive.tar.gz',
originalFs.readFileSync(path.join(__dirname, 'fixtures/apk-archive.tar.gz'))
);
});
afterAll(() => {
vol.reset();
});
describe('downloadAppArchiveAsync', () => {
it(`downloads an apk to a file with the same name in a temporary directory`, async () => {
const path = await downloadAppArchiveAsync('http://fake.expo.io/app.apk');
expect(path).toMatch(/app.apk$/);
});
it(`downloads and extracts a tar to a file with the correct extension in a temporary directory`, async () => {
const path = await downloadAppArchiveAsync('http://fake.expo.io/app.tar.gz');
expect(path).toMatch(/app.tar.gz.apk$/);
});
});
describe('extractLocalArchiveAsync', () => {
it(`returns the input path if the file is not a tar`, async () => {
expect(await extractLocalArchiveAsync('/App.apk')).toBe('/App.apk');
});
it(`returns a temporary file from an extracted tar`, async () => {
expect(await extractLocalArchiveAsync('/apk-archive.tar.gz')).toMatch(
/apk-archive.tar.gz.apk$/
);
});
});
+3 -111
View File
@@ -1,17 +1,9 @@
import glob from 'fast-glob';
import fs from 'fs-extra';
import got from 'got';
import os from 'os';
import { basename, extname, join } from 'path';
import stream from 'stream';
import tar from 'tar';
import { promisify } from 'util';
import { UploadType, uploadAsync } from '../../uploads';
import { UploadSessionType } from '../../graphql/generated';
import { uploadAsync } from '../../uploads';
import { createProgressTracker } from '../../utils/progress';
const pipeline = promisify(stream.pipeline);
export async function isExistingFile(filePath: string) {
try {
const stats = await fs.stat(filePath);
@@ -21,74 +13,10 @@ export async function isExistingFile(filePath: string) {
}
}
async function moveFileOfTypeAsync(
directory: string,
extension: string,
dest: string
): Promise<string> {
const [matching] = await glob(`**/*.${extension}`, {
absolute: true,
cwd: directory,
});
if (!matching) {
throw new Error(`No .${extension} files found in directory: ${directory}`);
}
// The incoming destination may be tar.gz because it wasn't clear what type of app file was included.
// Compare the app file extension with the destination extension and if they don't match,
// append the app file extension to the destination. Ex. App.tar.gz.ipa
const matchingExtension = extname(matching).toLowerCase();
const destExtension = extname(dest).toLowerCase();
if (matchingExtension !== destExtension) {
dest = `${dest}${matchingExtension}`;
}
// Ensure we actually need to move the file.
if (matching !== dest) {
await fs.move(matching, dest);
}
return dest;
}
export function createDownloadStream(url: string) {
return got
.stream(url)
.on(
'downloadProgress',
createProgressTracker({ message: 'Downloading app', completedMessage: 'Downloaded app' })
);
}
export function pathIsTar(path: string): boolean {
return path.endsWith('tar.gz');
}
export async function downloadAppArchiveAsync(url: string): Promise<string> {
const filename = basename(url);
// Since we may need to rename the destination path,
// add everything to a folder which can be nuked to ensure we don't accidentally use an old build with the same name.
const destinationFolder = await createTemporaryDirectoryForExtractionAsync();
const destinationPath = join(destinationFolder, filename);
const downloadStream = createDownloadStream(url);
// Special use-case for downloading an EAS tar.gz file and unpackaging it.
if (pathIsTar(url)) {
await pipeline(downloadStream, tar.extract({ cwd: destinationFolder }, []));
// Move the folder contents matching .ipa, .apk, or .aab
return await moveFileOfTypeAsync(destinationFolder, '{ipa,apk,aab}', destinationPath);
} else {
await pipeline(downloadStream, fs.createWriteStream(destinationPath));
}
return destinationPath;
}
export async function uploadAppArchiveAsync(path: string): Promise<string> {
const fileSize = (await fs.stat(path)).size;
const { url } = await uploadAsync(
UploadType.SUBMISSION_APP_ARCHIVE,
UploadSessionType.EasSubmitAppArchive,
path,
createProgressTracker({
total: fileSize,
@@ -98,39 +26,3 @@ export async function uploadAppArchiveAsync(path: string): Promise<string> {
);
return url;
}
async function createTemporaryDirectoryForExtractionAsync(): Promise<string> {
// Since we may need to rename the destination path,
// add everything to a folder which can be nuked to ensure we don't accidentally use an old build with the same name.
const destinationFolder = join(os.tmpdir(), 'expo-submission-service');
if (await fs.pathExists(destinationFolder)) {
await fs.remove(destinationFolder);
}
await fs.ensureDir(destinationFolder);
return destinationFolder;
}
async function decompressTarAsync(src: string, destination: string): Promise<void> {
await pipeline(fs.createReadStream(src), tar.extract({ cwd: destination }, []));
}
export async function extractLocalArchiveAsync(filePath: string): Promise<string> {
if (!pathIsTar(filePath)) {
// No need to extract, copy, or rename the file.
// Leave it in place and return the path.
return filePath;
}
const filename = basename(filePath);
// Since we may need to rename the destination path,
// add everything to a folder which can be nuked to ensure we don't accidentally use an old build with the same name.
const destinationFolder = await createTemporaryDirectoryForExtractionAsync();
const destinationPath = join(destinationFolder, filename);
// Special use-case for downloading an EAS tar.gz file and unpackaging it.
await decompressTarAsync(filePath, destinationFolder);
// Move the folder contents matching .ipa, .apk, or .aab
return await moveFileOfTypeAsync(destinationFolder, '{ipa,apk,aab}', destinationPath);
}
+5 -31
View File
@@ -1,16 +1,11 @@
import assert from 'assert';
import FormData from 'form-data';
import fs from 'fs';
import fs from 'fs-extra';
import got, { Progress } from 'got';
import md5File from 'md5-file';
import { Readable } from 'stream';
import { apiClient } from './api';
export enum UploadType {
TURTLE_PROJECT_SOURCES = 'turtle-project-sources',
SUBMISSION_APP_ARCHIVE = 'submission-app-archive',
}
import { UploadSessionType } from './graphql/generated';
import { PresignedPost, UploadSessionMutation } from './graphql/mutations/UploadSessionMutation';
type ProgressHandler = (props: {
progress?: Progress;
@@ -19,11 +14,11 @@ type ProgressHandler = (props: {
}) => void;
export async function uploadAsync(
uploadType: UploadType,
type: UploadSessionType,
path: string,
handleProgressEvent: ProgressHandler
): Promise<{ url: string; bucketKey: string }> {
const presignedPost = await obtainS3PresignedPostAsync(uploadType, path);
const presignedPost = await UploadSessionMutation.createUploadSession(type);
const url = await uploadWithPresignedPostAsync(
fs.createReadStream(path),
presignedPost,
@@ -33,27 +28,6 @@ export async function uploadAsync(
return { url, bucketKey: presignedPost.fields.key };
}
export interface PresignedPost {
url: string;
fields: Record<string, string>;
}
async function obtainS3PresignedPostAsync(
uploadType: UploadType,
filePath: string
): Promise<PresignedPost> {
const fileHash = await md5File(filePath);
const { data } = await apiClient
.post('upload-sessions', {
json: {
type: uploadType,
checksum: fileHash,
},
})
.json();
return data.presignedUrl;
}
export async function uploadWithPresignedPostAsync(
stream: Readable | Buffer,
presignedPost: PresignedPost,
+24
View File
@@ -1,5 +1,6 @@
import gql from 'graphql-tag';
import * as Analytics from '../analytics';
import { apiClient } from '../api';
import { graphqlClient } from '../graphql/client';
import { CurrentUserQuery } from '../graphql/generated';
@@ -13,10 +14,33 @@ export type Actor = NonNullable<CurrentUserQuery['meActor']>;
let currentUser: Actor | undefined;
/**
* Resolve the name of the actor, either normal user or robot user.
* This should be used whenever the "current user" needs to be displayed.
* The display name CANNOT be used as project owner.
*/
export function getActorDisplayName(user?: Actor): string {
switch (user?.__typename) {
case 'User':
return user.username;
case 'Robot':
return user.firstName ? `${user.firstName} (robot)` : 'robot';
default:
return 'anonymous';
}
}
export async function getUserAsync(): Promise<Actor | undefined> {
if (!currentUser && (getAccessToken() || getSessionSecret())) {
const user = await UserQuery.currentUserAsync();
currentUser = user ?? undefined;
if (user) {
await Analytics.setUserDataAsync(user.id, {
username: getActorDisplayName(user),
user_id: user.id,
user_type: user.__typename,
});
}
}
return currentUser;
}
@@ -7,6 +7,8 @@ const SETTINGS_FILE_PATH = path.join(getConfigDirectory(), 'user-settings.json')
export type UserSettingsData = {
appleId?: string;
amplitudeDeviceId?: string;
amplitudeEnabled?: boolean;
};
const UserSettings: JsonFile<UserSettingsData> = new JsonFile<UserSettingsData>(
@@ -2,7 +2,14 @@ import fs from 'fs-extra';
import { vol } from 'memfs';
import { getStateJsonPath } from '../../utils/paths';
import { getSessionSecret, getUserAsync, loginAsync, logoutAsync } from '../User';
import {
Actor,
getActorDisplayName,
getSessionSecret,
getUserAsync,
loginAsync,
logoutAsync,
} from '../User';
jest.mock('fs');
jest.mock('../../api', () => ({
@@ -34,6 +41,20 @@ beforeEach(() => {
vol.reset();
});
const userStub: Actor = {
__typename: 'User',
id: 'userId',
username: 'username',
accounts: [],
};
const robotStub: Actor = {
__typename: 'Robot',
id: 'userId',
firstName: 'GLaDOS',
accounts: [],
};
describe('getUserAsync', () => {
it('skips fetching user without access token or session secret', async () => {
expect(await getUserAsync()).toBeUndefined();
@@ -77,3 +98,21 @@ describe('logoutAsync', () => {
expect(getSessionSecret()).toBe(null);
});
});
describe('getActorDisplayName', () => {
it('returns anonymous for unauthenticated users', () => {
expect(getActorDisplayName()).toBe('anonymous');
});
it('returns username for user actors', () => {
expect(getActorDisplayName(userStub)).toBe(userStub.username);
});
it('returns firstName with robot prefix for robot actors', () => {
expect(getActorDisplayName(robotStub)).toBe(`${robotStub.firstName} (robot)`);
});
it('returns robot prefix only for robot actors without firstName', () => {
expect(getActorDisplayName({ ...robotStub, firstName: undefined })).toBe('robot');
});
});
@@ -1,7 +1,7 @@
import ApiV2Error from '../../ApiV2Error';
import { promptAsync } from '../../prompts';
import { Actor, loginAsync } from '../User';
import { ensureActorHasUsername, getActorDisplayName, showLoginPromptAsync } from '../actions';
import { ensureActorHasUsername, showLoginPromptAsync } from '../actions';
import { UserSecondFactorDeviceMethod, retryUsernamePasswordAuthWithOTPAsync } from '../otp';
jest.mock('../../prompts');
@@ -34,24 +34,6 @@ const robotStub: Actor = {
accounts: [],
};
describe('getActorDisplayName', () => {
it('returns anonymous for unauthenticated users', () => {
expect(getActorDisplayName()).toBe('anonymous');
});
it('returns username for user actors', () => {
expect(getActorDisplayName(userStub)).toBe(userStub.username);
});
it('returns firstName with robot prefix for robot actors', () => {
expect(getActorDisplayName(robotStub)).toBe(`${robotStub.firstName} (robot)`);
});
it('returns robot prefix only for robot actors without firstName', () => {
expect(getActorDisplayName({ ...robotStub, firstName: undefined })).toBe('robot');
});
});
describe('ensureActorHasUsername', () => {
it('returns username for user actors', () => {
expect(ensureActorHasUsername(userStub)).toBe(userStub.username);
-16
View File
@@ -55,22 +55,6 @@ export async function ensureLoggedInAsync(): Promise<Actor> {
return user;
}
/**
* Resolve the name of the actor, either normal user or robot user.
* This should be used whenever the "current user" needs to be displayed.
* The display name CANNOT be used as project owner.
*/
export function getActorDisplayName(user?: Actor): string {
switch (user?.__typename) {
case 'User':
return user.username;
case 'Robot':
return user.firstName ? `${user.firstName} (robot)` : 'robot';
default:
return 'anonymous';
}
}
export function ensureActorHasUsername(user: Actor): string {
if (user.__typename === 'User') {
return user.username;
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with the eas.json",
"version": "0.6.0",
"version": "0.8.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/eas-build-job": "0.2.16",
"@expo/eas-build-job": "0.2.22",
"@hapi/joi": "^17.1.1",
"fs-extra": "^9.0.1",
"tslib": "^1"
+4 -4
View File
@@ -18,7 +18,7 @@ export interface AndroidManagedBuildProfile extends Android.BuilderEnvironment {
buildType?: Android.ManagedBuildType;
releaseChannel?: string;
distribution?: AndroidDistributionType;
cache: Cache | null;
cache: Cache;
}
export interface AndroidGenericBuildProfile extends Android.BuilderEnvironment {
@@ -29,7 +29,7 @@ export interface AndroidGenericBuildProfile extends Android.BuilderEnvironment {
artifactPath?: string;
withoutCredentials?: boolean;
distribution?: AndroidDistributionType;
cache: Cache | null;
cache: Cache;
}
export interface iOSManagedBuildProfile extends Ios.BuilderEnvironment {
@@ -39,7 +39,7 @@ export interface iOSManagedBuildProfile extends Ios.BuilderEnvironment {
releaseChannel?: string;
distribution?: iOSDistributionType;
autoIncrement: VersionAutoIncrement;
cache: Cache | null;
cache: Cache;
}
export interface iOSGenericBuildProfile extends Ios.BuilderEnvironment {
@@ -51,7 +51,7 @@ export interface iOSGenericBuildProfile extends Ios.BuilderEnvironment {
artifactPath?: string;
distribution?: iOSDistributionType;
autoIncrement: VersionAutoIncrement;
cache: Cache | null;
cache: Cache;
disableIosBundleIdentifierValidation?: boolean;
}
+3 -4
View File
@@ -150,15 +150,14 @@ export class EasJsonReader {
if (!buildProfile) {
throw new Error(`There is no profile named ${buildProfileName} for platform ${platform}`);
}
const baseProfileName = buildProfile.extends;
delete buildProfile.extends;
const { extends: baseProfileName, ...buildProfileRest } = buildProfile;
if (baseProfileName) {
return deepMerge(
this.resolveBuildProfile(platform, baseProfileName, buildProfiles, depth + 1),
buildProfile
buildProfileRest
);
} else {
return buildProfile;
return buildProfileRest;
}
}
}
+7 -5
View File
@@ -25,13 +25,15 @@ const IosBuilderEnvironmentSchema = Joi.object({
.default('default'),
node: Joi.string().empty(null).custom(semverSchemaCheck),
yarn: Joi.string().empty(null).custom(semverSchemaCheck),
bundler: Joi.string().empty(null).custom(semverSchemaCheck),
fastlane: Joi.string().empty(null).custom(semverSchemaCheck),
cocoapods: Joi.string().empty(null).custom(semverSchemaCheck),
env: Joi.object().pattern(Joi.string(), Joi.string().empty(null)).default({}),
});
const CacheSchema = Joi.object({
key: Joi.string().allow('').default('').max(128),
disabled: Joi.boolean().default(false),
key: Joi.string().max(128),
cacheDefaultPaths: Joi.boolean().default(true),
customPaths: Joi.array().items(Joi.string()).default([]),
});
@@ -48,7 +50,7 @@ const AndroidGenericSchema = Joi.object({
artifactPath: Joi.string(),
withoutCredentials: Joi.boolean().default(false),
distribution: Joi.string().valid('store', 'internal').default('store'),
cache: CacheSchema.allow(null).default({ key: '', cacheDefaultPaths: true, customPaths: [] }),
cache: CacheSchema.default(),
}).concat(AndroidBuilderEnvironmentSchema);
const AndroidManagedSchema = Joi.object({
@@ -61,7 +63,7 @@ const AndroidManagedSchema = Joi.object({
otherwise: Joi.string().valid('apk', 'app-bundle', 'development-client').default('app-bundle'),
}),
distribution: Joi.string().valid('store', 'internal').default('store'),
cache: CacheSchema.allow(null).default({ key: '', cacheDefaultPaths: true, customPaths: [] }),
cache: CacheSchema.default(),
}).concat(AndroidBuilderEnvironmentSchema);
const iOSGenericSchema = Joi.object({
@@ -75,7 +77,7 @@ const iOSGenericSchema = Joi.object({
autoIncrement: Joi.alternatives()
.try(Joi.boolean(), Joi.string().valid('version', 'buildNumber'))
.default(false),
cache: CacheSchema.allow(null).default({ key: '', cacheDefaultPaths: true, customPaths: [] }),
cache: CacheSchema.default(),
}).concat(IosBuilderEnvironmentSchema);
const iOSManagedSchema = Joi.object({
@@ -87,7 +89,7 @@ const iOSManagedSchema = Joi.object({
autoIncrement: Joi.alternatives()
.try(Joi.boolean(), Joi.string().valid('version', 'buildNumber'))
.default(false),
cache: CacheSchema.allow(null).default({ key: '', cacheDefaultPaths: true, customPaths: [] }),
cache: CacheSchema.default(),
}).concat(IosBuilderEnvironmentSchema);
const schemaBuildProfileMap: Record<string, Record<string, Joi.Schema>> = {
@@ -29,7 +29,7 @@ test('minimal valid android eas.json', async () => {
credentialsSource: 'auto',
env: {},
withoutCredentials: false,
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
},
},
@@ -55,7 +55,7 @@ test('minimal valid ios eas.json', async () => {
workflow: 'generic',
autoIncrement: false,
env: {},
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
schemeBuildConfiguration: 'Release',
},
@@ -85,7 +85,7 @@ test('minimal valid eas.json for both platforms', async () => {
credentialsSource: 'auto',
env: {},
withoutCredentials: false,
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
},
ios: {
@@ -94,7 +94,7 @@ test('minimal valid eas.json for both platforms', async () => {
credentialsSource: 'auto',
autoIncrement: false,
env: {},
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
schemeBuildConfiguration: 'Release',
},
@@ -124,7 +124,7 @@ test('valid eas.json with both platform, but reading only android', async () =>
credentialsSource: 'auto',
env: {},
withoutCredentials: false,
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
},
},
@@ -158,7 +158,7 @@ test('valid eas.json for development client builds', async () => {
distribution: 'store',
env: {},
image: 'default',
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
buildType: 'development-client',
},
ios: {
@@ -168,7 +168,7 @@ test('valid eas.json for development client builds', async () => {
autoIncrement: false,
env: {},
image: 'default',
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
buildType: 'development-client',
},
},
@@ -198,7 +198,7 @@ test('valid generic profile for internal distribution on Android', async () => {
gradleCommand: ':app:assembleRelease',
env: {},
withoutCredentials: false,
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
},
},
@@ -227,7 +227,7 @@ test('valid managed profile for internal distribution on Android', async () => {
distribution: 'internal',
credentialsSource: 'auto',
env: {},
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
},
},
+36 -9
View File
@@ -2,6 +2,38 @@
# yarn lockfile v1
"@amplitude/identify@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@amplitude/identify/-/identify-1.5.0.tgz#6d31aaae615393867328abc6fcad037e9d1639e8"
integrity sha512-GCDxvwZvfFInZQ6m5LYnb9sVmJP+n+Re6vkBdtclp9uTNVPpdsXtdw2ann5Ts5pV10fivDpgdZ3OHehSxe742A==
dependencies:
"@amplitude/types" "^1.5.0"
"@amplitude/utils" "^1.5.0"
tslib "^1.9.3"
"@amplitude/node@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@amplitude/node/-/node-1.5.0.tgz#23e1650fed6302696b669dd8ceea0164b73f220e"
integrity sha512-/jfmYHanhln/OAuL8QG76EjThnaRDnfxLxO4oYstMFZKRktnUahE5i0sH02+tJ7I2nzq+4KTYY2hc8Rvi+Vt4Q==
dependencies:
"@amplitude/identify" "^1.5.0"
"@amplitude/types" "^1.5.0"
"@amplitude/utils" "^1.5.0"
tslib "^1.9.3"
"@amplitude/types@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@amplitude/types/-/types-1.5.0.tgz#cf34d0f304558d8b0d847a6217d506e6226e653c"
integrity sha512-XspuOsUzUcxwAptHeGiIn4giuLWs285xTJa7h8kAEEynxtEI3/krWCoDYZSB9PekaPXB6phxiO/tMd9t5V9LgQ==
"@amplitude/utils@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@amplitude/utils/-/utils-1.5.0.tgz#b48c3233e12f2c04880dd98f3c2628fd0976d3cb"
integrity sha512-1DrDJkb4dVX+FiBXhGpO2Dn2cRKdP+gtrVR8vZcE8wz/V2XxUI3DDx7uQbIS6WbQf6swv6Uo2eMHYtrwebostw==
dependencies:
"@amplitude/types" "^1.5.0"
tslib "^1.9.3"
"@ardatan/[email protected]":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@ardatan/aggregate-error/-/aggregate-error-0.0.6.tgz#fe6924771ea40fc98dc7a7045c2e872dc8527609"
@@ -1151,10 +1183,10 @@
xcode "^3.0.0"
xml-js "^1.6.11"
"@expo/[email protected].16":
version "0.2.16"
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-0.2.16.tgz#490be96d32f4b9ef4501d117796f523680ce5a00"
integrity sha512-PJRYwk3phLDpqtIKTEBgyqvJsxCoa55CHYRJguRJ9lYHxqxL/litUruxu1Y5cjF+iGXw/ElpN2C75wuboUdjcw==
"@expo/[email protected].22":
version "0.2.22"
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-0.2.22.tgz#8432a8da25e8b8860ff7fec756821c660bcaef13"
integrity sha512-a+922tmi9xxA3WclDhvnz8sascqjSO8wX78MOqgLVDW2iyZaT7C7MvUv6Z8Vig3zdKoUsQaTg8QWJyQl1biU8A==
dependencies:
"@hapi/joi" "^17.1.1"
@@ -8680,11 +8712,6 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
md5-file@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/md5-file/-/md5-file-5.0.0.tgz#e519f631feca9c39e7f9ea1780b63c4745012e20"
integrity sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw==
memfs@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.2.0.tgz#f9438e622b5acd1daa8a4ae160c496fdd1325b26"