mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b650f76979 | ||
|
|
96e59c198d | ||
|
|
bf1704d268 | ||
|
|
279dfd00c8 | ||
|
|
4d2577865a | ||
|
|
9d88fba40e | ||
|
|
9d2353b4f7 | ||
|
|
44b338d50a | ||
|
|
62114cab3b | ||
|
|
d426fb0355 | ||
|
|
2a8d6683ff | ||
|
|
7b18165390 | ||
|
|
814a44ac27 | ||
|
|
f5483c8996 | ||
|
|
acdbed4762 | ||
|
|
f09e4b54cf | ||
|
|
859fdf5e46 | ||
|
|
6aa72d5b7d | ||
|
|
886900df29 | ||
|
|
a450fed1f8 | ||
|
|
4c78100adb | ||
|
|
0317e6fe71 | ||
|
|
6825877344 | ||
|
|
a47ea88d28 | ||
|
|
a9e7f304ec | ||
|
|
d92a2ae3f4 | ||
|
|
b7dea35da4 | ||
|
|
5a5f11752d | ||
|
|
b6c300b5c2 | ||
|
|
0117dbfa5f | ||
|
|
c106f8a1a4 | ||
|
|
8b9741a70c | ||
|
|
271b85fd74 | ||
|
|
21a481790b | ||
|
|
ab5d9237f1 | ||
|
|
756853f2f3 | ||
|
|
5eb80d15f6 | ||
|
|
a98043b4ad | ||
|
|
d0818932e7 | ||
|
|
23b60ed385 | ||
|
|
5d2811bf21 |
No files matched your search
+39
-2
@@ -6,6 +6,45 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 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 +59,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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.6.0"
|
||||
"version": "0.8.0"
|
||||
}
|
||||
+72
-13
@@ -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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/build/commands/submit.ts)_
|
||||
<!-- commandsstop -->
|
||||
+2751
-849
File diff suppressed because it is too large.
Load diff
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Platform, RequestedPlatform } from './types';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { RequestedPlatform } from './types';
|
||||
|
||||
export const platformDisplayNames = {
|
||||
export const requestedPlatformDisplayNames: Record<RequestedPlatform, string> = {
|
||||
[RequestedPlatform.iOS]: 'iOS',
|
||||
[RequestedPlatform.Android]: 'Android',
|
||||
[RequestedPlatform.All]: 'Android and iOS',
|
||||
};
|
||||
|
||||
export const platformEmojis = {
|
||||
[Platform.IOS]: '🍎',
|
||||
[Platform.ANDROID]: '🤖',
|
||||
export const appPlatformDisplayNames: Record<AppPlatform, string> = {
|
||||
[AppPlatform.Android]: 'Android',
|
||||
[AppPlatform.Ios]: 'iOS',
|
||||
};
|
||||
|
||||
export const appPlatformEmojis = {
|
||||
[AppPlatform.Ios]: '🍎',
|
||||
[AppPlatform.Android]: '🤖',
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import { ExpoConfig, getConfig } from '@expo/config';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidBuildProfile, EasConfig, iOSBuildProfile } from '@expo/eas-json';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getProjectAccountName } from '../project/projectUtils';
|
||||
import { findAccountByName } from '../user/Account';
|
||||
import { Actor } from '../user/User';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import { Platform, RequestedPlatform, TrackingContext } from './types';
|
||||
import Analytics, { Event } from './utils/analytics';
|
||||
|
||||
export interface CommandContext {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
@@ -15,7 +18,6 @@ export interface CommandContext {
|
||||
accountName: string;
|
||||
projectName: string;
|
||||
exp: ExpoConfig;
|
||||
trackingCtx: TrackingContext;
|
||||
nonInteractive: boolean;
|
||||
skipCredentialsCheck: boolean;
|
||||
skipProjectConfiguration: boolean;
|
||||
@@ -25,9 +27,9 @@ export interface CommandContext {
|
||||
export async function createCommandContextAsync({
|
||||
requestedPlatform,
|
||||
profile,
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
trackingCtx,
|
||||
nonInteractive = false,
|
||||
skipCredentialsCheck = false,
|
||||
skipProjectConfiguration = false,
|
||||
@@ -35,16 +37,15 @@ export async function createCommandContextAsync({
|
||||
}: {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
profile: string;
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
projectDir: string;
|
||||
trackingCtx: TrackingContext;
|
||||
nonInteractive: boolean;
|
||||
skipCredentialsCheck: boolean;
|
||||
skipProjectConfiguration: boolean;
|
||||
waitForBuildEnd: boolean;
|
||||
}): Promise<CommandContext> {
|
||||
const user = await ensureLoggedInAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = getProjectAccountName(exp, user);
|
||||
const projectName = exp.slug;
|
||||
|
||||
@@ -57,7 +58,6 @@ export async function createCommandContextAsync({
|
||||
accountName,
|
||||
projectName,
|
||||
exp,
|
||||
trackingCtx,
|
||||
nonInteractive,
|
||||
skipCredentialsCheck,
|
||||
skipProjectConfiguration,
|
||||
@@ -101,13 +101,20 @@ export function createBuildContext<T extends Platform>({
|
||||
if (!buildProfile) {
|
||||
throw new Error(`${platform} build profile does not exist`);
|
||||
}
|
||||
const builderTrackingCtx = {
|
||||
...commandCtx.trackingCtx,
|
||||
|
||||
const accountId = findAccountByName(commandCtx.user.accounts, commandCtx.accountName)?.id;
|
||||
const trackingCtx = {
|
||||
tracking_id: uuidv4(),
|
||||
platform,
|
||||
...(accountId && { account_id: accountId }),
|
||||
account_name: commandCtx.accountName,
|
||||
project_id: commandCtx.projectId,
|
||||
project_type: buildProfile.workflow,
|
||||
};
|
||||
Analytics.logEvent(Event.BUILD_COMMAND, trackingCtx);
|
||||
return {
|
||||
commandCtx,
|
||||
trackingCtx: builderTrackingCtx,
|
||||
trackingCtx,
|
||||
platform,
|
||||
buildProfile,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { EasJsonReader } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../api';
|
||||
import { BuildFragment, BuildStatus } from '../graphql/generated';
|
||||
import { BuildQuery } from '../graphql/queries/BuildQuery';
|
||||
import Log from '../log';
|
||||
import { sleep } from '../utils/promise';
|
||||
import { prepareAndroidBuildAsync } from './android/build';
|
||||
import { CommandContext } from './context';
|
||||
import { prepareIosBuildAsync } from './ios/build';
|
||||
import { Build, BuildStatus, Platform, RequestedPlatform } from './types';
|
||||
import { Platform, RequestedPlatform } from './types';
|
||||
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
|
||||
import { ensureGitRepoExistsAsync, ensureGitStatusIsCleanAsync } from './utils/repository';
|
||||
|
||||
@@ -22,12 +24,9 @@ export async function buildAsync(commandCtx: CommandContext): Promise<void> {
|
||||
Log.newLine();
|
||||
|
||||
if (commandCtx.waitForBuildEnd) {
|
||||
const builds = await waitForBuildEndAsync(
|
||||
commandCtx,
|
||||
scheduledBuilds.map(i => i.buildId)
|
||||
);
|
||||
Log.newLine();
|
||||
const builds = await waitForBuildEndAsync(scheduledBuilds.map(i => i.buildId));
|
||||
printBuildResults(commandCtx.accountName, builds);
|
||||
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
if (builds.length === 1) {
|
||||
switch (builds[0]?.status) {
|
||||
case BuildStatus.FINISHED:
|
||||
const build = nullthrows(builds[0]);
|
||||
switch (build.status) {
|
||||
case BuildStatus.Finished:
|
||||
spinner.succeed('Build finished');
|
||||
return builds;
|
||||
case BuildStatus.IN_QUEUE:
|
||||
case BuildStatus.InQueue:
|
||||
spinner.text = 'Build queued...';
|
||||
break;
|
||||
case BuildStatus.CANCELED:
|
||||
case BuildStatus.Canceled:
|
||||
spinner.text = 'Build canceled';
|
||||
spinner.stopAndPersist();
|
||||
return builds;
|
||||
case BuildStatus.IN_PROGRESS:
|
||||
case BuildStatus.InProgress:
|
||||
spinner.text = 'Build in progress...';
|
||||
break;
|
||||
case BuildStatus.ERRORED:
|
||||
case BuildStatus.Errored:
|
||||
spinner.fail('Build failed');
|
||||
throw new Error(`Standalone build failed!`);
|
||||
if (build.error) {
|
||||
return builds;
|
||||
} else {
|
||||
throw new Error(`Standalone build failed!`);
|
||||
}
|
||||
default:
|
||||
spinner.warn('Unknown status.');
|
||||
throw new Error(`Unknown status: ${builds} - aborting!`);
|
||||
}
|
||||
} else {
|
||||
if (builds.filter(build => build?.status === BuildStatus.FINISHED).length === builds.length) {
|
||||
if (builds.filter(build => build?.status === BuildStatus.Finished).length === builds.length) {
|
||||
spinner.succeed('All builds have finished');
|
||||
return builds;
|
||||
} else if (
|
||||
builds.filter(build =>
|
||||
build?.status
|
||||
? [BuildStatus.FINISHED, BuildStatus.ERRORED, BuildStatus.CANCELED].includes(
|
||||
? [BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(
|
||||
build.status
|
||||
)
|
||||
: false
|
||||
@@ -125,11 +125,11 @@ async function waitForBuildEndAsync(
|
||||
spinner.fail('Some of the builds were canceled or failed.');
|
||||
return builds;
|
||||
} else {
|
||||
const inQueue = builds.filter(build => build?.status === BuildStatus.IN_QUEUE).length;
|
||||
const inProgress = builds.filter(build => build?.status === BuildStatus.IN_PROGRESS).length;
|
||||
const errored = builds.filter(build => build?.status === BuildStatus.ERRORED).length;
|
||||
const finished = builds.filter(build => build?.status === BuildStatus.FINISHED).length;
|
||||
const canceled = builds.filter(build => build?.status === BuildStatus.CANCELED).length;
|
||||
const inQueue = builds.filter(build => build?.status === BuildStatus.InQueue).length;
|
||||
const inProgress = builds.filter(build => build?.status === BuildStatus.InProgress).length;
|
||||
const errored = builds.filter(build => build?.status === BuildStatus.Errored).length;
|
||||
const finished = builds.filter(build => build?.status === BuildStatus.Finished).length;
|
||||
const canceled = builds.filter(build => build?.status === BuildStatus.Canceled).length;
|
||||
const unknownState = builds.length - inQueue - inProgress - errored - finished;
|
||||
spinner.text = [
|
||||
inQueue && `Builds in queue: ${inQueue}`,
|
||||
@@ -151,3 +151,12 @@ async function waitForBuildEndAsync(
|
||||
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
|
||||
);
|
||||
}
|
||||
|
||||
function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | null)[]): void {
|
||||
const failedBuilds = (maybeBuilds.filter(i => i) as BuildFragment[]).filter(
|
||||
i => i.status === BuildStatus.Errored
|
||||
);
|
||||
if (failedBuilds.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import chalk from 'chalk';
|
||||
import { CredentialsProvider } from '../credentials/CredentialsProvider';
|
||||
import Log from '../log';
|
||||
import { confirmAsync, promptAsync } from '../prompts';
|
||||
import { platformDisplayNames } from './constants';
|
||||
import { requestedPlatformDisplayNames } from './constants';
|
||||
|
||||
function logCredentials(target: 'local' | 'remote', platform: Platform) {
|
||||
let message = `Using ${target} ${platformDisplayNames[platform]} credentials`;
|
||||
let message = `Using ${target} ${requestedPlatformDisplayNames[platform]} credentials`;
|
||||
if (target === 'local') message += ` ${chalk.dim('(credentials.json)')}`;
|
||||
if (target === 'remote') message += ` ${chalk.dim('(Expo server)')}`;
|
||||
Log.succeed(message);
|
||||
@@ -19,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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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',
|
||||
}
|
||||
@@ -1,3 +1,27 @@
|
||||
import { logEvent } from '../../analytics';
|
||||
|
||||
export enum Event {
|
||||
BUILD_COMMAND = 'build cli build command',
|
||||
PROJECT_UPLOAD_SUCCESS = 'build cli project upload success',
|
||||
PROJECT_UPLOAD_FAIL = 'build cli project upload fail',
|
||||
GATHER_CREDENTIALS_SUCCESS = 'build cli gather credentials success',
|
||||
GATHER_CREDENTIALS_FAIL = 'build cli gather credentials fail',
|
||||
CONFIGURE_PROJECT_SUCCESS = 'build cli configure project success',
|
||||
CONFIGURE_PROJECT_FAIL = 'build cli configure project fail',
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
CREDENTIALS_SYNC_COMMAND = 'build cli credentials sync command',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'build cli credentials sync update local success',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'build cli credentials sync update local fail',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'build cli credentials sync update remote success',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',
|
||||
}
|
||||
|
||||
export default {
|
||||
logEvent: (a: any, b: any) => {},
|
||||
logEvent(name: Event, properties: Record<string, any>) {
|
||||
logEvent(name, properties);
|
||||
},
|
||||
};
|
||||
@@ -1,57 +1,66 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { BuildFragment, BuildStatus as GraphQLBuildStatus } from '../../graphql/generated';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { platformDisplayNames } from '../constants';
|
||||
import { Build, BuildStatus } from '../types';
|
||||
import { appPlatformDisplayNames } from '../constants';
|
||||
import { getBuildLogsUrl } from './url';
|
||||
|
||||
interface Options {
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
export default function formatBuild(build: Build, { accountName }: Options) {
|
||||
export function formatGraphQLBuild(build: BuildFragment) {
|
||||
const actor = getActorName(build);
|
||||
const account = build.project.__typename === 'App' ? build.project.ownerAccount.name : 'unknown';
|
||||
const fields: { label: string; value: string }[] = [
|
||||
{ label: 'ID', value: build.id },
|
||||
{
|
||||
label: 'Platform',
|
||||
value: platformDisplayNames[build.platform],
|
||||
value: appPlatformDisplayNames[build.platform],
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case BuildStatus.IN_QUEUE:
|
||||
case GraphQLBuildStatus.InQueue:
|
||||
return chalk.blue('in queue');
|
||||
case BuildStatus.IN_PROGRESS:
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return chalk.blue('in progress');
|
||||
case BuildStatus.CANCELED:
|
||||
case GraphQLBuildStatus.Canceled:
|
||||
return chalk.gray('canceled');
|
||||
case BuildStatus.FINISHED:
|
||||
case GraphQLBuildStatus.Finished:
|
||||
return chalk.green('finished');
|
||||
case BuildStatus.ERRORED:
|
||||
case GraphQLBuildStatus.Errored:
|
||||
return chalk.red('errored');
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Distribution',
|
||||
value: build.distribution?.toLowerCase() ?? chalk.gray('unknown'),
|
||||
},
|
||||
{
|
||||
label: 'Release Channel',
|
||||
value: build.releaseChannel ?? chalk.gray('unknown'),
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
value: getBuildLogsUrl({ buildId: build.id, account: accountName }),
|
||||
value: getBuildLogsUrl({ buildId: build.id, account }),
|
||||
},
|
||||
{
|
||||
label: 'Artifact',
|
||||
get value() {
|
||||
if (build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS) {
|
||||
if (
|
||||
build.status === GraphQLBuildStatus.InQueue ||
|
||||
build.status === GraphQLBuildStatus.InProgress
|
||||
) {
|
||||
}
|
||||
switch (build.status) {
|
||||
case BuildStatus.IN_QUEUE:
|
||||
case BuildStatus.IN_PROGRESS:
|
||||
case GraphQLBuildStatus.InQueue:
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return '<in progress>';
|
||||
case BuildStatus.CANCELED:
|
||||
case BuildStatus.ERRORED:
|
||||
case GraphQLBuildStatus.Canceled:
|
||||
case GraphQLBuildStatus.Errored:
|
||||
return '---------';
|
||||
case BuildStatus.FINISHED: {
|
||||
case GraphQLBuildStatus.Finished: {
|
||||
const url = build.artifacts?.buildUrl;
|
||||
return url ? url : chalk.red('not found');
|
||||
}
|
||||
@@ -64,13 +73,23 @@ export default function formatBuild(build: Build, { accountName }: Options) {
|
||||
{
|
||||
label: 'Finished at',
|
||||
value:
|
||||
build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS
|
||||
build.status === GraphQLBuildStatus.InQueue ||
|
||||
build.status === GraphQLBuildStatus.InProgress
|
||||
? '<in progress>'
|
||||
: new Date(build.updatedAt).toLocaleString(),
|
||||
},
|
||||
// TODO: we can do it once we migrate to GraphQL
|
||||
// { label: 'Started by', get: build => 'TODO' },
|
||||
{ label: 'Started by', value: actor ?? 'unknown' },
|
||||
];
|
||||
|
||||
return formatFields(fields);
|
||||
}
|
||||
|
||||
const getActorName = (build: BuildFragment): string => {
|
||||
if (!build.initiatingActor) {
|
||||
return 'unknown';
|
||||
} else if (build.initiatingActor.__typename === 'User') {
|
||||
return build.initiatingActor.username;
|
||||
} else {
|
||||
return build.initiatingActor.firstName ?? 'unknown';
|
||||
}
|
||||
};
|
||||
@@ -1,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?' });
|
||||
|
||||
@@ -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'>>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import { Build } from '../../build/types';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import { BuildStatus, RequestedPlatform } from '../../build/types';
|
||||
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
|
||||
import { AppPlatform, BuildStatus as GraphQLBuildStatus } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
@@ -17,34 +18,45 @@ export default class BuildList extends Command {
|
||||
static description = 'list all builds for your project';
|
||||
|
||||
static flags = {
|
||||
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
|
||||
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished', 'canceled'] }),
|
||||
platform: flags.enum({
|
||||
options: [RequestedPlatform.All, RequestedPlatform.Android, RequestedPlatform.iOS],
|
||||
}),
|
||||
status: flags.enum({
|
||||
options: [
|
||||
BuildStatus.IN_QUEUE,
|
||||
BuildStatus.IN_PROGRESS,
|
||||
BuildStatus.ERRORED,
|
||||
BuildStatus.FINISHED,
|
||||
BuildStatus.CANCELED,
|
||||
],
|
||||
}),
|
||||
limit: flags.integer(),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { platform, status, limit = 10 } = this.parse(BuildList).flags;
|
||||
const { platform: requestedPlatform, status: buildStatus, limit = 10 } = this.parse(
|
||||
BuildList
|
||||
).flags;
|
||||
|
||||
const platform = toAppPlatform(requestedPlatform);
|
||||
const graphqlBuildStatus = toGraphQLBuildStatus(buildStatus);
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const projectName = await getProjectFullNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectName = await getProjectFullNameAsync(exp);
|
||||
|
||||
const spinner = ora().start('Fetching the build list for the project…');
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<{ data: { builds: Build[] } }>(
|
||||
`projects/${projectId}/builds`,
|
||||
{
|
||||
searchParams: { platform, status, limit },
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
const { builds } = response.body.data;
|
||||
const builds = await BuildQuery.allForAppAsync(projectId, {
|
||||
limit,
|
||||
platform,
|
||||
status: graphqlBuildStatus,
|
||||
});
|
||||
|
||||
if (builds.length) {
|
||||
if (platform || status) {
|
||||
if (platform || graphqlBuildStatus) {
|
||||
spinner.succeed(
|
||||
`Showing ${builds.length} matching builds for the project ${projectName}`
|
||||
);
|
||||
@@ -53,7 +65,7 @@ export default class BuildList extends Command {
|
||||
}
|
||||
|
||||
const list = builds
|
||||
.map(build => formatBuild(build, { accountName }))
|
||||
.map(build => formatGraphQLBuild(build))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`);
|
||||
|
||||
Log.log(`\n${list}`);
|
||||
@@ -66,3 +78,29 @@ export default class BuildList extends Command {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toAppPlatform = (requestedPlatform?: RequestedPlatform): AppPlatform | undefined => {
|
||||
if (!requestedPlatform || requestedPlatform === RequestedPlatform.All) {
|
||||
return undefined;
|
||||
} else if (requestedPlatform === RequestedPlatform.Android) {
|
||||
return AppPlatform.Android;
|
||||
} else {
|
||||
return AppPlatform.Ios;
|
||||
}
|
||||
};
|
||||
|
||||
const toGraphQLBuildStatus = (buildStatus: BuildStatus): GraphQLBuildStatus | undefined => {
|
||||
if (!buildStatus) {
|
||||
return undefined;
|
||||
} else if (buildStatus === BuildStatus.IN_QUEUE) {
|
||||
return GraphQLBuildStatus.InQueue;
|
||||
} else if (buildStatus === BuildStatus.IN_PROGRESS) {
|
||||
return GraphQLBuildStatus.InProgress;
|
||||
} else if (buildStatus === BuildStatus.ERRORED) {
|
||||
return GraphQLBuildStatus.Errored;
|
||||
} else if (buildStatus === BuildStatus.FINISHED) {
|
||||
return GraphQLBuildStatus.Finished;
|
||||
} else {
|
||||
return GraphQLBuildStatus.Canceled;
|
||||
}
|
||||
};
|
||||
@@ -1,14 +1,13 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command } from '@oclif/command';
|
||||
import { HTTPError } from 'got';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import { Build } from '../../build/types';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
|
||||
import { BuildFragment } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
@@ -22,34 +21,24 @@ export default class BuildView extends Command {
|
||||
const { BUILD_ID: buildId } = this.parse(BuildView).args;
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const projectName = await getProjectFullNameAsync(projectDir);
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectName = await getProjectFullNameAsync(exp);
|
||||
|
||||
const spinner = ora().start('Fetching the build…');
|
||||
|
||||
try {
|
||||
let build: Build;
|
||||
let build: BuildFragment;
|
||||
|
||||
if (buildId == null) {
|
||||
const response = await apiClient.get<{ data: { builds: Build[] } }>(
|
||||
`projects/${projectId}/builds`,
|
||||
{
|
||||
searchParams: { limit: 1 },
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
build = response.body.data.builds[0];
|
||||
if (buildId) {
|
||||
build = await BuildQuery.byIdAsync(buildId);
|
||||
} else {
|
||||
const response = await apiClient.get<{ data: Build }>(
|
||||
`projects/${projectId}/builds/${buildId}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
build = response.body.data;
|
||||
const builds = await BuildQuery.allForAppAsync(projectId, { limit: 1 });
|
||||
if (builds.length === 0) {
|
||||
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
|
||||
return;
|
||||
}
|
||||
build = builds[0];
|
||||
}
|
||||
|
||||
if (buildId) {
|
||||
@@ -58,29 +47,17 @@ export default class BuildView extends Command {
|
||||
spinner.succeed(`Showing the last build for the project ${projectName}`);
|
||||
}
|
||||
|
||||
Log.log(`\n${formatBuild(build, { accountName })}`);
|
||||
} catch (e) {
|
||||
const error = e as HTTPError;
|
||||
|
||||
if (error.response.statusCode === 400) {
|
||||
if (buildId) {
|
||||
spinner.fail(
|
||||
`Couldn't find a build for the project ${projectName} matching the id ${buildId}`
|
||||
);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
|
||||
}
|
||||
Log.log(`\n${formatGraphQLBuild(build)}`);
|
||||
} catch (err) {
|
||||
if (buildId) {
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
|
||||
} else {
|
||||
if (buildId) {
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
|
||||
);
|
||||
}
|
||||
spinner.fail(
|
||||
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { confirmAsync } from '../../../../../prompts';
|
||||
import { getAppstoreMock, testAuthCtx } from '../../../../__tests__/fixtures-appstore';
|
||||
import { createCtxMock } from '../../../../__tests__/fixtures-context';
|
||||
import {
|
||||
testDistCertFragmentNoDependencies,
|
||||
testProvisioningProfile,
|
||||
testProvisioningProfileFragment,
|
||||
} from '../../../../__tests__/fixtures-ios';
|
||||
import { ManageIosBeta } from '../../../../manager/ManageIosBeta';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../../errors';
|
||||
import { ConfigureProvisioningProfile } from '../ConfigureProvisioningProfile';
|
||||
jest.mock('../../../../../prompts');
|
||||
(confirmAsync as jest.Mock).mockImplementation(() => true);
|
||||
|
||||
describe('ConfigureProvisioningProfile', () => {
|
||||
it('configures a Provisioning Profile in Interactive Mode', async () => {
|
||||
const mockProvisioningProfileFromApple = {
|
||||
provisioningProfileId: testProvisioningProfileFragment.developerPortalIdentifier,
|
||||
provisioningProfile: testProvisioningProfileFragment.provisioningProfile,
|
||||
};
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
authCtx: testAuthCtx,
|
||||
createProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
|
||||
listProvisioningProfilesAsync: jest.fn(() => [mockProvisioningProfileFromApple]),
|
||||
useExistingProvisioningProfileAsync: jest.fn(() => mockProvisioningProfileFromApple),
|
||||
},
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const provProfConfigurator = new ConfigureProvisioningProfile(
|
||||
appLookupParams,
|
||||
testDistCertFragmentNoDependencies,
|
||||
testProvisioningProfileFragment
|
||||
);
|
||||
await provProfConfigurator.runAsync(ctx);
|
||||
|
||||
// expect provisioning profile not to be updated on expo servers
|
||||
expect((ctx.newIos.updateProvisioningProfileAsync as any).mock.calls.length).toBe(1);
|
||||
// expect provisioning profile not to be updated on apple portal
|
||||
expect((ctx.appStore.useExistingProvisioningProfileAsync as any).mock.calls.length).toBe(1);
|
||||
});
|
||||
it('errors in Non Interactive Mode', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
|
||||
const provProfConfigurator = new ConfigureProvisioningProfile(
|
||||
appLookupParams,
|
||||
testDistCertFragmentNoDependencies,
|
||||
testProvisioningProfileFragment
|
||||
);
|
||||
await expect(provProfConfigurator.runAsync(ctx)).rejects.toThrowError(
|
||||
MissingCredentialsNonInteractiveError
|
||||
);
|
||||
|
||||
// expect provisioning profile not to be updated on expo servers
|
||||
expect((ctx.newIos.updateProvisioningProfileAsync as any).mock.calls.length).toBe(0);
|
||||
// expect provisioning profile not to be updated on apple portal
|
||||
expect((ctx.appStore.useExistingProvisioningProfileAsync as any).mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
+57
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ExpoConfig, getConfig } from '@expo/config';
|
||||
|
||||
import AppStoreApi from '../credentials/ios/appstore/AppStoreApi';
|
||||
import { findProjectRootAsync } from '../project/projectUtils';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
export interface DeviceManagerContext {
|
||||
appStore: AppStoreApi;
|
||||
exp: ExpoConfig | null;
|
||||
projectDir: string | null;
|
||||
user: Actor;
|
||||
}
|
||||
@@ -17,9 +20,16 @@ export async function createContext({
|
||||
cwd?: string;
|
||||
user: Actor;
|
||||
}): Promise<DeviceManagerContext> {
|
||||
const projectDir = await findProjectRootAsync(cwd);
|
||||
let exp: ExpoConfig | null = null;
|
||||
if (projectDir) {
|
||||
const config = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
exp = config.exp;
|
||||
}
|
||||
return {
|
||||
appStore,
|
||||
projectDir: await findProjectRootAsync(cwd),
|
||||
projectDir,
|
||||
exp,
|
||||
user,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -8,8 +9,7 @@ import Log from '../log';
|
||||
import { getProjectAccountNameAsync } from '../project/projectUtils';
|
||||
import { Choice, confirmAsync, promptAsync } from '../prompts';
|
||||
import { Account, findAccountByName } from '../user/Account';
|
||||
import { Actor } from '../user/User';
|
||||
import { getActorDisplayName } from '../user/actions';
|
||||
import { Actor, getActorDisplayName } from '../user/User';
|
||||
import DeviceCreateAction from './actions/create/action';
|
||||
import { DeviceManagerContext } from './context';
|
||||
|
||||
@@ -39,16 +39,16 @@ export default class DeviceManager {
|
||||
}
|
||||
|
||||
private async resolveAccountAsync(): Promise<Account> {
|
||||
const resolver = new AccountResolver(this.ctx.projectDir, this.ctx.user);
|
||||
const resolver = new AccountResolver(this.ctx.exp, this.ctx.user);
|
||||
return await resolver.resolveAccountAsync();
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountResolver {
|
||||
constructor(private projectDir: string | null, private user: Actor) {}
|
||||
constructor(private exp: ExpoConfig | null, private user: Actor) {}
|
||||
|
||||
public async resolveAccountAsync(): Promise<Account> {
|
||||
if (this.projectDir) {
|
||||
if (this.exp) {
|
||||
const account = await this.resolveProjectAccountAsync();
|
||||
if (account) {
|
||||
return account;
|
||||
@@ -58,9 +58,9 @@ export class AccountResolver {
|
||||
}
|
||||
|
||||
private async resolveProjectAccountAsync(): Promise<Account | undefined> {
|
||||
assert(this.projectDir, 'project directory is not set ');
|
||||
assert(this.exp, 'expo config is not set');
|
||||
|
||||
const projectAccountName = await getProjectAccountNameAsync(this.projectDir);
|
||||
const projectAccountName = await getProjectAccountNameAsync(this.exp);
|
||||
const projectAccount = findAccountByName(this.user.accounts, projectAccountName);
|
||||
if (!projectAccount) {
|
||||
Log.warn(
|
||||
|
||||
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,41 +1,45 @@
|
||||
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): 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 }
|
||||
)
|
||||
@@ -45,44 +49,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 +91,10 @@ const BuildQuery = {
|
||||
): Promise<PendingBuildQueryResult | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<PendingBuildsForAccountAndPlatformQuery>(
|
||||
.query<
|
||||
PendingBuildsForAccountAndPlatformQuery,
|
||||
PendingBuildsForAccountAndPlatformQueryVariables
|
||||
>(
|
||||
gql`
|
||||
query PendingBuildsForAccountAndPlatform(
|
||||
$accountName: String!
|
||||
@@ -133,5 +136,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
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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$/
|
||||
);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user