Compare commits

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

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

* address PR feedback

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

* use graphql for polling build status

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

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

change positional args

* configure prov prof: pr feedback

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

* rename target to scope

* show secret ID before timestamp

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

* Remove tar extraction utils

* Update CHANGELOG

* Simplified path handling code

* New unit tests

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

* add changelog entry

* Update CHANGELOG.md

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

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

* update CHANGELOG.md

* rename secretEnvs -> environmentSecrets

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

* Update CHANGELOG.md

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

* fix tsc
2021-03-26 13:17:07 -07:00
Quinlan Jung a450fed1f8 [eas-cli] create provisioning profile using graphql api (#293) 2021-03-26 13:16:37 -07:00
Dominik Sokal 4c78100adb use graphql to create unpublished app (#288) 2021-03-26 09:01:42 +01:00
Dominik Sokal 0317e6fe71 refactor build:list and build:view to use graphql (#286) 2021-03-26 08:56:04 +01:00
jkh 6825877344 [eas-cli] add support for rollouts to channel:view command (#289) 2021-03-24 07:04:07 -07:00
Dominik Sokal a47ea88d28 Update CHANGELOG.md 2021-03-23 10:37:57 +01:00
Wojciech Kozyra a9e7f304ec Update CHANGELOG.md 2021-03-22 13:36:10 +01:00
76 changed files with 4844 additions and 847 deletions

No files matched your search

+34 -2
View File
@@ -8,6 +8,40 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
- Fix the issue where the build status never got updated when running `eas build`. ([#310](https://github.com/expo/eas-cli/pull/310) by [@dsokal](https://github.com/dsokal))
### 🧹 Chores
## [0.8.0](https://github.com/expo/eas-cli/releases/tag/v0.8.0) - 2021-04-06
### 🛠 Breaking changes
- Change the way of disabling cache - add `cache.disabled` field. ([#295](https://github.com/expo/eas-cli/pull/295) by [@dsokal](https://github.com/dsokal))
- `secrets:create` now uses flags rather than positional arguments ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
- `secrets:create`'s `target` arg is now called `scope` ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
- `secrets:list`'s `target` property is now called `scope` ([#300](https://github.com/expo/eas-cli/pull/300) by [@fiberjw](https://github.com/fiberjw))
### 🎉 New features
- `build:view` and `build:list` now showing the distribution type (store / internal) and release channel. ([#284](https://github.com/expo/eas-cli/pull/284) by [@vthibault](https://github.com/vthibault))
- Add analytics to EAS Build. ([#162](https://github.com/expo/eas-cli/pull/162) by [@wkozyra95](https://github.com/wkozyra95))
- Improve tar archive support in EAS Submit. ([#297](https://github.com/expo/eas-cli/pull/297) by [@barthap](https://github.com/barthap))
### 🐛 Bug fixes
- Fix environment secret creation prompt. ([#298](https://github.com/expo/eas-cli/pull/298) by [@fiberjw](https://github.com/fiberjw))
### 🧹 Chores
- Replace REST API calls with GraphQL counterparts. ([#286](https://github.com/expo/eas-cli/pull/286), [#288](https://github.com/expo/eas-cli/pull/288), [#303](https://github.com/expo/eas-cli/pull/303), [#306](https://github.com/expo/eas-cli/pull/306), [#307](https://github.com/expo/eas-cli/pull/307) by [@dsokal](https://github.com/dsokal))
## [0.7.0](https://github.com/expo/eas-cli/releases/tag/v0.7.0) - 2021-03-22
### 🎉 New features
- Print common error messages when builds fail. ([#272](https://github.com/expo/eas-cli/pull/272) by [@dsokal](https://github.com/dsokal))
- Commit automatically if `EAS_BUILD_AUTOCOMMIT` is set. ([#271](https://github.com/expo/eas-cli/pull/271) by [@wkozyra95](https://github.com/wkozyra95))
- Allow for installing custom `bundler` version on EAS Build. ([#277](https://github.com/expo/eas-cli/pull/277) by [@dsokal](https://github.com/dsokal))
@@ -19,8 +53,6 @@ This is the log of notable changes to EAS CLI and related packages.
- 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))
### 🧹 Chores
## [0.6.0](https://github.com/expo/eas-cli/releases/tag/v0.6.0) - 2021-03-09
### 🛠 Breaking changes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.7.0"
"version": "0.8.1"
}
+63 -15
View File
@@ -36,6 +36,7 @@ eas --help COMMAND
* [`eas account:login`](#eas-accountlogin)
* [`eas account:logout`](#eas-accountlogout)
* [`eas account:view`](#eas-accountview)
* [`eas analytics [STATUS]`](#eas-analytics-status)
* [`eas build`](#eas-build)
* [`eas build:cancel [BUILD_ID]`](#eas-buildcancel-build_id)
* [`eas build:configure`](#eas-buildconfigure)
@@ -46,6 +47,8 @@ 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)
@@ -61,7 +64,7 @@ ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/account/login.ts)_
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/account/login.ts)_
## `eas account:logout`
@@ -75,7 +78,7 @@ ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/account/logout.ts)_
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/account/logout.ts)_
## `eas account:view`
@@ -89,7 +92,18 @@ ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/account/view.ts)_
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/account/view.ts)_
## `eas analytics [STATUS]`
View or change analytics settings
```
USAGE
$ eas analytics [STATUS]
```
_See code: [build/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/analytics.ts)_
## `eas build`
@@ -108,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.7.0/build/commands/build/index.ts)_
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -119,7 +133,7 @@ USAGE
$ eas build:cancel [BUILD_ID]
```
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/cancel.ts)_
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/build/cancel.ts)_
## `eas build:configure`
@@ -134,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.7.0/build/commands/build/configure.ts)_
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/build/configure.ts)_
## `eas build:list`
@@ -150,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.7.0/build/commands/build/list.ts)_
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/build/list.ts)_
## `eas build:view [BUILD_ID]`
@@ -161,7 +175,7 @@ USAGE
$ eas build:view [BUILD_ID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/view.ts)_
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/build/view.ts)_
## `eas credentials`
@@ -172,7 +186,7 @@ USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/credentials.ts)_
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/credentials.ts)_
## `eas device:create`
@@ -183,7 +197,7 @@ USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/device/create.ts)_
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/device/create.ts)_
## `eas device:list`
@@ -197,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.7.0/build/commands/device/list.ts)_
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/device/list.ts)_
## `eas device:view [UDID]`
@@ -208,7 +222,7 @@ USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/device/view.ts)_
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -227,6 +241,39 @@ OPTIONS
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
## `eas secrets:create`
Create an environment secret on the current project or owner account.
```
USAGE
$ eas secrets:create
OPTIONS
--name=name Name of the secret
--scope=(account|project) [default: project] Scope for the secret
--value=value Value of the secret
```
_See code: [build/commands/secrets/create.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/secrets/create.ts)_
## `eas secrets:delete ID`
Delete an environment secret by ID.
```
USAGE
$ eas secrets:delete ID
ARGUMENTS
ID ID of the secret to delete
DESCRIPTION
Unsure where to find the secret's ID? Run eas secrets:list
```
_See code: [build/commands/secrets/delete.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/secrets/delete.ts)_
## `eas secrets:list`
Lists environment secrets available for your current app
@@ -236,7 +283,7 @@ USAGE
$ eas secrets:list
```
_See code: [build/commands/secrets/list.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/secrets/list.ts)_
_See code: [build/commands/secrets/list.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/secrets/list.ts)_
## `eas submit --platform=(android|ios)`
@@ -262,7 +309,8 @@ OPTIONS
iOS specific options=asc-app-id App Store Connect unique application Apple ID
number. Providing this param results in skipping
app creation step. Learn more.
app creation step. Learn more:
https://expo.fyi/asc-app-id
iOS specific options=bundle-identifier Your iOS Bundle Identifier (default:
expo.ios.bundleIdentifier from app config)
@@ -315,5 +363,5 @@ EXAMPLES
and provide its App ID
```
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/submit.ts)_
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.8.1/build/commands/submit.ts)_
<!-- commandsstop -->
File diff suppressed because it is too large. Load diff
+5 -4
View File
@@ -1,18 +1,20 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.7.0",
"version": "0.8.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@amplitude/identify": "^1.5.0",
"@amplitude/node": "^1.5.0",
"@expo/apple-utils": "0.0.0-alpha.17",
"@expo/config": "~3.3.19",
"@expo/config-plugins": "1.0.21-alpha.0",
"@expo/eas-build-job": "0.2.21",
"@expo/eas-json": "^0.7.0",
"@expo/eas-build-job": "0.2.22",
"@expo/eas-json": "^0.8.0",
"@expo/json-file": "^8.2.24",
"@expo/plist": "^0.0.11",
"@expo/plugin-warn-if-update-available": "^1.7.0",
@@ -39,7 +41,6 @@
"indent-string": "^4.0.0",
"keychain": "^1.3.0",
"lodash": "^4.17.20",
"md5-file": "^5.0.0",
"mime": "^2.4.7",
"minimatch": "^3.0.4",
"node-fetch": "^2.6.1",
+101
View File
@@ -0,0 +1,101 @@
import { Identify } from '@amplitude/identify';
import * as Amplitude from '@amplitude/node';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
import UserSettings from './user/UserSettings';
/**
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
* the src directory and would change the directory structure of the emitted files
* under the build directory
*/
const packageJSON = require('../package.json');
const PLATFORM_TO_ANALYTICS_PLATFORM: { [platform: string]: string } = {
darwin: 'Mac',
win32: 'Windows',
linux: 'Linux',
};
let client: Amplitude.NodeClient | null = null;
let userIdentifyCalled = false;
let identifyData: {
userId: string;
deviceId: string;
identify: Identify;
} | null = null;
export async function initAsync(): Promise<void> {
if (process.env.DISABLE_EAS_ANALYTICS) {
await UserSettings.setAsync('amplitudeEnabled', false);
}
const amplitudeEnabled = await UserSettings.getAsync('amplitudeEnabled', true);
if (amplitudeEnabled) {
const apiKey =
process.env.EXPO_STAGING || process.env.EXPO_LOCAL
? 'cdebbc678931403439486c4750781544'
: '4ac443afd5073c0df6169291db1d3495';
client = Amplitude.init(apiKey);
}
}
export async function setUserDataAsync(
userId: string,
properties: Record<string, any>
): Promise<void> {
const identify = new Identify();
Object.entries(properties).forEach(([property, value]) => {
identify.set(property, value);
});
const savedDeviceId = await UserSettings.getAsync('amplitudeDeviceId', null);
const deviceId = savedDeviceId ?? uuidv4();
if (!savedDeviceId) {
await UserSettings.setAsync('amplitudeDeviceId', deviceId);
}
identifyData = {
userId,
deviceId,
identify,
};
ensureUserIdentified();
}
export async function flushAsync(): Promise<void> {
if (client) {
await client.flush();
}
}
export function logEvent(name: string, properties: Record<string, any> = {}) {
if (client) {
ensureUserIdentified();
const { userId, deviceId } = identifyData ?? {};
client.logEvent({
event_type: name,
event_properties: properties,
...(userId && { user_id: userId }),
...(deviceId && { device_id: deviceId }),
...getContext(),
});
}
}
function ensureUserIdentified() {
if (client && !userIdentifyCalled && identifyData) {
client.identify(identifyData.userId, identifyData.deviceId, identifyData.identify);
userIdentifyCalled = true;
}
}
function getContext() {
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
return {
os_name: platform,
os_version: os.release(),
device_brand: platform,
device_model: platform,
app_version: packageJSON?.version,
};
}
+26 -1
View File
@@ -1,4 +1,4 @@
import { Workflow } from '@expo/eas-build-job';
import { Android, Metadata, Workflow } from '@expo/eas-build-job';
import { EasConfig } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -8,14 +8,17 @@ import AndroidCredentialsProvider, {
AndroidCredentials,
} from '../../credentials/android/AndroidCredentialsProvider';
import { createCredentialsContextAsync } from '../../credentials/context';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
import { CredentialsResult, prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
import { ensureCredentialsAsync } from '../credentials';
import { transformMetadata } from '../graphql';
import { Platform } from '../types';
import { ensureApplicationIdIsValidAsync } from './applicationId';
import { validateAndSyncProjectConfigurationAsync } from './configure';
import { transformGenericJob, transformManagedJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
export async function prepareAndroidBuildAsync(
@@ -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,
});
}
},
});
}
@@ -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,
},
};
+61 -43
View File
@@ -1,18 +1,19 @@
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,
@@ -27,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;
@@ -43,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,
});
}
@@ -69,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,
}
@@ -89,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');
@@ -133,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,
@@ -147,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,
}
);
@@ -159,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> {
+11 -5
View File
@@ -1,12 +1,18 @@
import { Platform, RequestedPlatform } from './types';
import { AppPlatform } from '../graphql/generated';
import { RequestedPlatform } from './types';
export const platformDisplayNames = {
export const requestedPlatformDisplayNames: Record<RequestedPlatform, string> = {
[RequestedPlatform.iOS]: 'iOS',
[RequestedPlatform.Android]: 'Android',
[RequestedPlatform.All]: 'Android and iOS',
};
export const platformEmojis = {
[Platform.IOS]: '🍎',
[Platform.ANDROID]: '🤖',
export const appPlatformDisplayNames: Record<AppPlatform, string> = {
[AppPlatform.Android]: 'Android',
[AppPlatform.Ios]: 'iOS',
};
export const appPlatformEmojis = {
[AppPlatform.Ios]: '🍎',
[AppPlatform.Android]: '🤖',
};
+13 -7
View File
@@ -1,10 +1,13 @@
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;
@@ -28,7 +30,6 @@ export async function createCommandContextAsync({
exp,
projectDir,
projectId,
trackingCtx,
nonInteractive = false,
skipCredentialsCheck = false,
skipProjectConfiguration = false,
@@ -39,7 +40,6 @@ export async function createCommandContextAsync({
exp: ExpoConfig;
projectId: string;
projectDir: string;
trackingCtx: TrackingContext;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
@@ -58,7 +58,6 @@ export async function createCommandContextAsync({
accountName,
projectName,
exp,
trackingCtx,
nonInteractive,
skipCredentialsCheck,
skipProjectConfiguration,
@@ -102,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,
};
+25 -28
View File
@@ -1,14 +1,16 @@
import { EasJsonReader } from '@expo/eas-json';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import ora from 'ora';
import { apiClient } from '../api';
import { BuildFragment, BuildStatus } from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log from '../log';
import { sleep } from '../utils/promise';
import { prepareAndroidBuildAsync } from './android/build';
import { CommandContext } from './context';
import { prepareIosBuildAsync } from './ios/build';
import { Build, BuildStatus, Platform, RequestedPlatform } from './types';
import { Platform, RequestedPlatform } from './types';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureGitRepoExistsAsync, ensureGitStatusIsCleanAsync } from './utils/repository';
@@ -22,10 +24,7 @@ export async function buildAsync(commandCtx: CommandContext): Promise<void> {
Log.newLine();
if (commandCtx.waitForBuildEnd) {
const builds = await waitForBuildEndAsync(
commandCtx,
scheduledBuilds.map(i => i.buildId)
);
const builds = await waitForBuildEndAsync(scheduledBuilds.map(i => i.buildId));
printBuildResults(commandCtx.accountName, builds);
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
}
@@ -66,44 +65,40 @@ async function startBuildsAsync(
}
async function waitForBuildEndAsync(
commandCtx: CommandContext,
buildIds: string[],
{ timeoutSec = 1800, intervalSec = 30 } = {}
): Promise<(Build | null)[]> {
): Promise<(BuildFragment | null)[]> {
Log.log('Waiting for build to complete. You can press Ctrl+C to exit.');
const spinner = ora().start();
let time = new Date().getTime();
const endTime = time + timeoutSec * 1000;
while (time <= endTime) {
const builds: (Build | null)[] = await Promise.all(
const builds: (BuildFragment | null)[] = await Promise.all(
buildIds.map(async buildId => {
try {
const { data } = await apiClient
.get(`projects/${commandCtx.projectId}/builds/${buildId}`)
.json();
return data;
return await BuildQuery.byIdAsync(buildId, { useCache: false });
} catch (err) {
return null;
}
})
);
if (builds.length === 1) {
const build = builds[0] as Build;
const build = nullthrows(builds[0]);
switch (build.status) {
case BuildStatus.FINISHED:
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');
if (build.error) {
return builds;
@@ -115,13 +110,13 @@ async function waitForBuildEndAsync(
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
@@ -130,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}`,
@@ -157,8 +152,10 @@ async function waitForBuildEndAsync(
);
}
function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (Build | null)[]): void {
const failedBuilds = (maybeBuilds.filter(i => i) as Build[]).filter(i => i.status === 'errored');
function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | null)[]): void {
const failedBuilds = (maybeBuilds.filter(i => i) as BuildFragment[]).filter(
i => i.status === BuildStatus.Errored
);
if (failedBuilds.length > 0) {
process.exit(1);
}
+3 -3
View File
@@ -5,10 +5,10 @@ import chalk from 'chalk';
import { CredentialsProvider } from '../credentials/CredentialsProvider';
import Log from '../log';
import { confirmAsync, promptAsync } from '../prompts';
import { platformDisplayNames } from './constants';
import { requestedPlatformDisplayNames } from './constants';
function logCredentials(target: 'local' | 'remote', platform: Platform) {
let message = `Using ${target} ${platformDisplayNames[platform]} credentials`;
let message = `Using ${target} ${requestedPlatformDisplayNames[platform]} credentials`;
if (target === 'local') message += ` ${chalk.dim('(credentials.json)')}`;
if (target === 'remote') message += ` ${chalk.dim('(Expo server)')}`;
Log.succeed(message);
@@ -19,7 +19,7 @@ async function ensureCredentialsAutoAsync(
workflow: Workflow,
nonInteractive: boolean
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
const platform = platformDisplayNames[provider.platform];
const platform = requestedPlatformDisplayNames[provider.platform];
switch (workflow) {
case Workflow.MANAGED:
if (await provider.hasLocalAsync()) {
+72
View File
@@ -0,0 +1,72 @@
import { ArchiveSource, ArchiveSourceType, Metadata, Workflow } from '@expo/eas-build-job';
import {
BuildCredentialsSource,
BuildMetadataInput,
BuildWorkflow,
DistributionType,
ProjectArchiveSourceInput,
ProjectArchiveSourceType,
} from '../graphql/generated';
export function transformProjectArchive(archiveSource: ArchiveSource): ProjectArchiveSourceInput {
if (archiveSource.type === ArchiveSourceType.S3) {
return {
type: ProjectArchiveSourceType.S3,
bucketKey: archiveSource.bucketKey,
};
} else if (archiveSource.type === ArchiveSourceType.URL) {
return {
type: ProjectArchiveSourceType.Url,
url: archiveSource.url,
};
} else {
throw new Error(`Unsupported project archive source type: '${archiveSource.type}'`);
}
}
export function transformMetadata(metadata: Metadata): BuildMetadataInput {
return {
appIdentifier: metadata.appIdentifier,
appName: metadata.appName,
appVersion: metadata.appVersion,
buildProfile: metadata.buildProfile,
cliVersion: metadata.cliVersion,
credentialsSource:
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
distribution: metadata.distribution && transformDistribution(metadata.distribution),
gitCommitHash: metadata.gitCommitHash,
releaseChannel: metadata.releaseChannel,
sdkVersion: metadata.sdkVersion,
trackingContext: metadata.trackingContext,
workflow: metadata.workflow && transformWorkflow(metadata.workflow),
};
}
function transformCredentialsSource(
credentialsSource: Metadata['credentialsSource']
): BuildCredentialsSource {
if (credentialsSource === 'local') {
return BuildCredentialsSource.Local;
} else {
return BuildCredentialsSource.Remote;
}
}
function transformDistribution(distribution: Metadata['distribution']): DistributionType {
if (distribution === 'internal') {
return DistributionType.Internal;
} else if (distribution === 'simulator') {
return DistributionType.Simulator;
} else {
return DistributionType.Store;
}
}
function transformWorkflow(workflow: Metadata['workflow']): BuildWorkflow {
if (workflow === Workflow.GENERIC) {
return BuildWorkflow.Generic;
} else {
return BuildWorkflow.Managed;
}
}
+26 -1
View File
@@ -1,19 +1,22 @@
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 { transformGenericJob, transformManagedJob } from './graphql';
import { prepareJobAsync, sanitizedTargetName } from './prepareJob';
export async function prepareIosBuildAsync(
@@ -72,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,
});
}
},
});
}
+93
View File
@@ -0,0 +1,93 @@
import { Env, Ios } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import {
DistributionType,
IosGenericJobInput,
IosJobSecretsInput,
IosManagedBuildType,
IosManagedJobInput,
IosSchemeBuildConfiguration,
} from '../../graphql/generated';
import { transformProjectArchive } from '../graphql';
export function transformGenericJob(job: Ios.GenericJob): IosGenericJobInput {
return {
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
distribution: job.distribution && transformDistributionType(job.distribution),
secrets: transformIosSecrets(job.secrets),
builderEnvironment: job.builderEnvironment,
cache: job.cache,
scheme: job.scheme,
schemeBuildConfiguration:
job.schemeBuildConfiguration &&
transformSchemeBuildConfiguration(job.schemeBuildConfiguration),
artifactPath: job.artifactPath,
};
}
export function transformManagedJob(job: Ios.ManagedJob): IosManagedJobInput {
return {
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
distribution: job.distribution && transformDistributionType(job.distribution),
secrets: transformIosSecrets(job.secrets),
builderEnvironment: job.builderEnvironment,
cache: job.cache,
buildType: job.buildType && transformBuildType(job.buildType),
username: job.username,
};
}
function transformDistributionType(distributionType: Ios.DistributionType): DistributionType {
if (distributionType === 'store') {
return DistributionType.Store;
} else if (distributionType === 'internal') {
return DistributionType.Internal;
} else {
return DistributionType.Simulator;
}
}
function transformIosSecrets(secrets: {
buildCredentials?: Ios.BuildCredentials;
env?: Env;
}): IosJobSecretsInput {
const buildCredentials: IosJobSecretsInput['buildCredentials'] = [];
for (const targetName of Object.keys(secrets.buildCredentials ?? {})) {
buildCredentials.push({
targetName,
provisioningProfileBase64: nullthrows(secrets.buildCredentials?.[targetName])
.provisioningProfileBase64,
distributionCertificate: nullthrows(secrets.buildCredentials?.[targetName])
.distributionCertificate,
});
}
return {
buildCredentials,
environmentSecrets: secrets.env,
};
}
function transformSchemeBuildConfiguration(
schemeBuildConfiguration: Ios.SchemeBuildConfiguration
): IosSchemeBuildConfiguration {
if (schemeBuildConfiguration === Ios.SchemeBuildConfiguration.DEBUG) {
return IosSchemeBuildConfiguration.Debug;
} else {
return IosSchemeBuildConfiguration.Release;
}
}
function transformBuildType(buildType: Ios.ManagedBuildType): IosManagedBuildType {
if (buildType === Ios.ManagedBuildType.DEVELOPMENT_CLIENT) {
return IosManagedBuildType.DevelopmentClient;
} else {
return IosManagedBuildType.Release;
}
}
+5 -5
View File
@@ -15,7 +15,7 @@ import {
IosCredentials,
IosTargetCredentials,
isCredentialsMap,
readSecretEnvsAsync,
readEnvironmentSecretsAsync,
} from '../../credentials/credentialsJson/read';
import { getProjectAccountNameAsync } from '../../project/projectUtils';
import { gitRootDirectoryAsync } from '../../utils/git';
@@ -52,10 +52,10 @@ interface CommonJobProperties {
builderEnvironment: Ios.BuilderEnvironment;
releaseChannel?: string;
distribution?: Ios.DistributionType;
cache: Cache | null;
cache: Cache;
secrets: {
buildCredentials: Ios.BuildCredentials;
secretEnvs?: Record<string, string>;
environmentSecrets?: Record<string, string>;
};
}
@@ -67,7 +67,7 @@ async function prepareJobCommonAsync(
targetName,
}: { archiveBucketKey: string; credentials?: IosCredentials; targetName?: string }
): Promise<Partial<CommonJobProperties>> {
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
const environmentSecrets = await readEnvironmentSecretsAsync(ctx.commandCtx.projectDir);
let buildCredentials: CommonJobProperties['secrets']['buildCredentials'] = {};
if (credentials && isCredentialsMap(credentials)) {
@@ -103,7 +103,7 @@ async function prepareJobCommonAsync(
},
cache: ctx.buildProfile.cache,
secrets: {
...(secretEnvs ? { secretEnvs } : {}),
...(environmentSecrets ? { environmentSecrets } : {}),
buildCredentials,
},
};
+1 -44
View File
@@ -1,5 +1,4 @@
import { Metadata, Platform, errors } 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,43 +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>;
error?: errors.ExternalUserError;
}
interface BuildArtifacts {
buildUrl?: string;
logsUrl: string;
}
export type PlatformBuildProfile<T extends Platform> = T extends Platform.ANDROID
? AndroidBuildProfile
: iOSBuildProfile;
export enum AnalyticsEvent {
BUILD_COMMAND = 'builds cli build command',
PROJECT_UPLOAD_SUCCESS = 'builds cli project upload success',
PROJECT_UPLOAD_FAIL = 'builds cli project upload fail',
GATHER_CREDENTIALS_SUCCESS = 'builds cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'builds cli gather credentials fail',
CONFIGURE_PROJECT_SUCCESS = 'builds cli configure project success',
CONFIGURE_PROJECT_FAIL = 'builds cli configure project fail',
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'builds cli build request fail',
BUILD_STATUS_COMMAND = 'builds cli build status',
CREDENTIALS_SYNC_COMMAND = 'builds cli credentials sync command',
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'builds cli credentials sync update local success',
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'builds cli credentials sync update local fail',
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'builds cli credentials sync update remote success',
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'builds cli credentials sync update remote fail',
}
+25 -1
View File
@@ -1,3 +1,27 @@
import { logEvent } from '../../analytics';
export enum Event {
BUILD_COMMAND = 'build cli build command',
PROJECT_UPLOAD_SUCCESS = 'build cli project upload success',
PROJECT_UPLOAD_FAIL = 'build cli project upload fail',
GATHER_CREDENTIALS_SUCCESS = 'build cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'build cli gather credentials fail',
CONFIGURE_PROJECT_SUCCESS = 'build cli configure project success',
CONFIGURE_PROJECT_FAIL = 'build cli configure project fail',
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'build cli build request fail',
BUILD_STATUS_COMMAND = 'build cli build status',
CREDENTIALS_SYNC_COMMAND = 'build cli credentials sync command',
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'build cli credentials sync update local success',
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'build cli credentials sync update local fail',
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'build cli credentials sync update remote success',
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',
}
export default {
logEvent: (a: any, b: any) => {},
logEvent(name: Event, properties: Record<string, any>) {
logEvent(name, properties);
},
};
+42 -23
View File
@@ -1,57 +1,66 @@
import chalk from 'chalk';
import { BuildFragment, BuildStatus as GraphQLBuildStatus } from '../../graphql/generated';
import formatFields from '../../utils/formatFields';
import { platformDisplayNames } from '../constants';
import { Build, BuildStatus } from '../types';
import { appPlatformDisplayNames } from '../constants';
import { getBuildLogsUrl } from './url';
interface Options {
accountName: string;
}
export default function formatBuild(build: Build, { accountName }: Options) {
export function formatGraphQLBuild(build: BuildFragment) {
const actor = getActorName(build);
const account = build.project.__typename === 'App' ? build.project.ownerAccount.name : 'unknown';
const fields: { label: string; value: string }[] = [
{ label: 'ID', value: build.id },
{
label: 'Platform',
value: platformDisplayNames[build.platform],
value: appPlatformDisplayNames[build.platform],
},
{
label: 'Status',
get value() {
switch (build.status) {
case BuildStatus.IN_QUEUE:
case GraphQLBuildStatus.InQueue:
return chalk.blue('in queue');
case BuildStatus.IN_PROGRESS:
case GraphQLBuildStatus.InProgress:
return chalk.blue('in progress');
case BuildStatus.CANCELED:
case GraphQLBuildStatus.Canceled:
return chalk.gray('canceled');
case BuildStatus.FINISHED:
case GraphQLBuildStatus.Finished:
return chalk.green('finished');
case BuildStatus.ERRORED:
case GraphQLBuildStatus.Errored:
return chalk.red('errored');
default:
return 'unknown';
}
},
},
{
label: 'Distribution',
value: build.distribution?.toLowerCase() ?? chalk.gray('unknown'),
},
{
label: 'Release Channel',
value: build.releaseChannel ?? chalk.gray('unknown'),
},
{
label: 'Logs',
value: getBuildLogsUrl({ buildId: build.id, account: accountName }),
value: getBuildLogsUrl({ buildId: build.id, account }),
},
{
label: 'Artifact',
get value() {
if (build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS) {
if (
build.status === GraphQLBuildStatus.InQueue ||
build.status === GraphQLBuildStatus.InProgress
) {
}
switch (build.status) {
case BuildStatus.IN_QUEUE:
case BuildStatus.IN_PROGRESS:
case GraphQLBuildStatus.InQueue:
case GraphQLBuildStatus.InProgress:
return '<in progress>';
case BuildStatus.CANCELED:
case BuildStatus.ERRORED:
case GraphQLBuildStatus.Canceled:
case GraphQLBuildStatus.Errored:
return '---------';
case BuildStatus.FINISHED: {
case GraphQLBuildStatus.Finished: {
const url = build.artifacts?.buildUrl;
return url ? url : chalk.red('not found');
}
@@ -64,13 +73,23 @@ export default function formatBuild(build: Build, { accountName }: Options) {
{
label: 'Finished at',
value:
build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS
build.status === GraphQLBuildStatus.InQueue ||
build.status === GraphQLBuildStatus.InProgress
? '<in progress>'
: new Date(build.updatedAt).toLocaleString(),
},
// TODO: we can do it once we migrate to GraphQL
// { label: 'Started by', get: build => 'TODO' },
{ label: 'Started by', value: actor ?? 'unknown' },
];
return formatFields(fields);
}
const getActorName = (build: BuildFragment): string => {
if (!build.initiatingActor) {
return 'unknown';
} else if (build.initiatingActor.__typename === 'User') {
return build.initiatingActor.username;
} else {
return build.initiatingActor.firstName ?? 'unknown';
}
};
@@ -1,17 +1,22 @@
import { errors } from '@expo/eas-build-job';
import assert from 'assert';
import chalk from 'chalk';
import {
BuildError,
BuildFragment,
BuildStatus,
DistributionType,
EasBuildDeprecationInfo,
EasBuildDeprecationInfoType,
} from '../../graphql/generated';
import Log, { learnMore } from '../../log';
import { platformDisplayNames, platformEmojis } from '../constants';
import { Build } from '../types';
import {
appPlatformDisplayNames,
appPlatformEmojis,
requestedPlatformDisplayNames,
} from '../constants';
import { getBuildLogsUrl } from './url';
export interface DeprecationInfo {
type: 'user-facing' | 'internal';
message: string;
}
export function printLogsUrls(
accountName: string,
builds: { platform: 'android' | 'ios'; buildId: string }[]
@@ -29,51 +34,57 @@ 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[]).forEach(build => printBuildResult(accountName, build));
(builds.filter(i => i) as BuildFragment[]).forEach(build =>
printBuildResult(accountName, build)
);
}
}
function printBuildResult(accountName: string, build: Build): void {
function printBuildResult(accountName: string, build: BuildFragment): void {
Log.addNewLineIfNone();
if (build.status === 'errored') {
if (build.status === BuildStatus.Errored) {
const userError = build.error;
Log.error(
`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} build failed${
userError ? ':' : ''
}`
`${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
} build failed${userError ? ':' : ''}`
);
if (userError) {
printUserError(userError);
}
return;
}
if (build.status === 'canceled') {
if (build.status === BuildStatus.Canceled) {
Log.error(
`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} build was canceled`
`${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
} build was canceled`
);
return;
}
if (build.metadata?.distribution === 'internal') {
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)}`);
@@ -81,20 +92,20 @@ function printBuildResult(accountName: string, build: Build): void {
// 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)}`);
}
}
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.'
@@ -106,7 +117,7 @@ export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): voi
}
}
export function printUserError(error: errors.ExternalUserError): void {
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 -1
View File
@@ -15,7 +15,7 @@ import {
} 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 }) {
@@ -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,
@@ -4,11 +4,9 @@ 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,
@@ -44,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}`;
+56 -61
View File
@@ -2,16 +2,18 @@ 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';
@@ -59,57 +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 { 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 trackingCtx = {
tracking_id: uuidv4(),
requested_platform: flags.platform,
};
Analytics.logEvent(AnalyticsEvent.BUILD_COMMAND, trackingCtx);
const commandCtx = await createCommandContextAsync({
requestedPlatform: platform,
profile: flags.profile,
exp,
projectDir,
projectId,
trackingCtx,
nonInteractive,
skipCredentialsCheck: flags['skip-credentials-check'],
skipProjectConfiguration: flags['skip-project-configuration'],
waitForBuildEnd: flags.wait,
});
await buildAsync(commandCtx);
}
}
@@ -142,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;
+55 -19
View File
@@ -3,13 +3,13 @@ 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';
@@ -18,35 +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 { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const accountName = await getProjectAccountNameAsync(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}`
);
@@ -55,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}`);
@@ -68,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;
}
};
+20 -45
View File
@@ -1,15 +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';
@@ -25,33 +23,22 @@ export default class BuildView extends Command {
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const accountName = await getProjectAccountNameAsync(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) {
@@ -60,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;
}
}
}
@@ -60,7 +60,7 @@ async function getChannelByNameForAppAsync({
return updateChannelByNameResult;
}
async function updateChannelBranchMappingAsync({
export async function updateChannelBranchMappingAsync({
channelId,
branchMapping,
}: UpdateChannelBranchMappingMutationVariables): Promise<{
@@ -147,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();
@@ -170,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
@@ -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);
}
}
+94 -6
View File
@@ -14,7 +14,69 @@ import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
async function getUpdateChannelByNameForAppAsync({
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: {
operand: number;
clientKey: string;
branchMappingOperator: string;
} & string;
}[];
};
/**
* Get the branch mapping and determine whether it is a rollout.
* Ensure that the branch mapping is properly formatted.
*/
export function getBranchMapping(
getChannelByNameForAppQuery: GetChannelByNameForAppQuery
): { branchMapping: BranchMapping; isRollout: boolean; rolloutPercent?: number } {
const branchMappingString =
getChannelByNameForAppQuery.app?.byId.updateChannelByName.branchMapping;
if (!branchMappingString) {
throw new Error('Missing branch mapping.');
}
const branchMapping: BranchMapping = JSON.parse(branchMappingString);
if (branchMapping.version !== 0) {
throw new Error('Branch mapping must be version 0.');
}
const isRollout = branchMapping.data.length === 2;
const rolloutPercent = branchMapping.data[0].branchMappingLogic.operand;
switch (branchMapping.data.length) {
case 0:
break;
case 1:
if (branchMapping.data[0].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a single branch must be "true"');
}
break;
case 2:
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
}
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
}
if (rolloutPercent == null) {
throw new Error('Branch mapping is missing a "rolloutPercent"');
}
if (branchMapping.data[1].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
}
break;
default:
throw new Error('Branch mapping data must have length less than or equal to 2.');
}
return { branchMapping, isRollout, rolloutPercent };
}
export async function getUpdateChannelByNameForAppAsync({
appId,
channelName,
}: GetChannelByNameForAppQueryVariables): Promise<GetChannelByNameForAppQuery> {
@@ -30,7 +92,8 @@ async function getUpdateChannelByNameForAppAsync({
id
name
createdAt
updateBranches(offset: 0, limit: 1) {
branchMapping
updateBranches(offset: 0, limit: 1000) {
id
name
updates(offset: 0, limit: 1) {
@@ -124,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(),
+34 -33
View File
@@ -1,5 +1,5 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
@@ -12,39 +12,34 @@ import {
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { findAccountByName } from '../../user/Account';
import { ensureLoggedInAsync, getActorDisplayName } from '../../user/actions';
import { getActorDisplayName } from '../../user/User';
import { ensureLoggedInAsync } from '../../user/actions';
export enum EnvironmentSecretTargetLocation {
export enum EnvironmentSecretScope {
ACCOUNT = 'account',
PROJECT = 'project',
}
export default class EnvironmentSecretCreate extends Command {
static hidden = true;
static description = 'Create an environment secret on the current project or owner account.';
static args = [
{
name: 'target',
required: false,
description: 'Target location for the secret',
options: [EnvironmentSecretTargetLocation.ACCOUNT, EnvironmentSecretTargetLocation.PROJECT],
},
{
name: 'name',
required: false,
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',
},
{
name: 'value',
required: false,
}),
value: flags.string({
description: 'Value of the secret',
},
];
}),
};
async run() {
const user = await ensureLoggedInAsync();
const actor = await ensureLoggedInAsync();
let {
args: { name, value: secretValue, target },
flags: { name, value: secretValue, scope },
} = this.parse(EnvironmentSecretCreate);
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
@@ -63,16 +58,16 @@ export default class EnvironmentSecretCreate extends Command {
return;
}
if (!target) {
const validationMessage = 'Secret target may not be empty.';
if (!scope) {
const validationMessage = 'Secret scope may not be empty.';
({ target } = await promptAsync({
({ scope } = await promptAsync({
type: 'select',
name: 'target',
name: 'scope',
message: 'Where should this secret be used:',
choices: [
{ title: 'Account-wide', value: EnvironmentSecretTargetLocation.ACCOUNT },
{ title: 'Project-specific', value: EnvironmentSecretTargetLocation.PROJECT },
{ title: 'Account-wide', value: EnvironmentSecretScope.ACCOUNT },
{ title: 'Project-specific', value: EnvironmentSecretScope.PROJECT },
],
validate: value => (value ? true : validationMessage),
}));
@@ -88,13 +83,17 @@ export default class EnvironmentSecretCreate extends Command {
return 'Secret name may not be empty.';
}
if (value.match(/^\w+$/)) {
// 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) {
@@ -106,9 +105,11 @@ export default class EnvironmentSecretCreate extends Command {
message: 'Secret value:',
validate: value => (value ? true : validationMessage),
}));
if (!secretValue) throw new Error(validationMessage);
}
if (target === EnvironmentSecretTargetLocation.PROJECT) {
if (scope === EnvironmentSecretScope.PROJECT) {
const secret = await EnvironmentSecretMutation.createForApp(
{ name, value: secretValue },
projectId
@@ -124,11 +125,11 @@ export default class EnvironmentSecretCreate extends Command {
`@${accountName}/${slug}`
)}.`
);
} else if (target === EnvironmentSecretTargetLocation.ACCOUNT) {
const ownerAccount = findAccountByName(user.accounts, accountName);
} else if (scope === EnvironmentSecretScope.ACCOUNT) {
const ownerAccount = findAccountByName(actor.accounts, accountName);
if (!ownerAccount) {
Log.warn(
`Your account (${getActorDisplayName(user)}) doesn't have access to the ${chalk.bold(
`Your account (${getActorDisplayName(actor)}) doesn't have access to the ${chalk.bold(
accountName
)} account`
);
@@ -1,5 +1,6 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import Log from '../../log';
@@ -12,11 +13,8 @@ import { toggleConfirmAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
export default class EnvironmentSecretDelete extends Command {
static hidden = true;
static description = `
Delete an environment secret by ID.
Unsure where to find the secret's ID? Run ${'`eas secrets:list`'}
`;
static description = `Delete an environment secret by ID.
Unsure where to find the secret's ID? Run ${chalk.bold('eas secrets:list')}`;
static args = [
{
@@ -45,7 +43,7 @@ Unsure where to find the secret's ID? Run ${'`eas secrets:list`'}
Log.addNewLineIfNone();
Log.warn(
`You are about to permamently delete secret with id: "${id}".\nThis action is irreversable.`
`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?' });
@@ -18,7 +18,7 @@ import {
getProjectIdAsync,
} from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { EnvironmentSecretTargetLocation } from './create';
import { EnvironmentSecretScope } from './create';
export default class EnvironmentSecretsList extends Command {
static description = 'Lists environment secrets available for your current app';
@@ -49,18 +49,18 @@ export default class EnvironmentSecretsList extends Command {
]);
const secrets = [
...appSecrets.map(s => ({ ...s, target: EnvironmentSecretTargetLocation.PROJECT })),
...accountSecrets.map(s => ({ ...s, target: EnvironmentSecretTargetLocation.ACCOUNT })),
] as (EnvironmentSecretFragment & { target: EnvironmentSecretTargetLocation })[];
...appSecrets.map(s => ({ ...s, scope: EnvironmentSecretScope.PROJECT })),
...accountSecrets.map(s => ({ ...s, scope: EnvironmentSecretScope.ACCOUNT })),
] as (EnvironmentSecretFragment & { scope: EnvironmentSecretScope })[];
const table = new Table({
head: ['Name', 'Target', 'Updated at', 'ID'],
head: ['Name', 'Scope', 'ID', 'Updated at'],
wordWrap: true,
});
for (const secret of secrets) {
const { name, createdAt: updatedAt, target, id } = secret;
table.push([name, target, dateFormat(updatedAt, 'mmm dd HH:MM:ss'), id]);
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:}`);
@@ -1,3 +1,11 @@
import { AuthCtx } from '../ios/appstore/authenticate';
export const testAuthCtx: AuthCtx = {
appleId: 'test-apple-id',
appleIdPassword: 'test-apple-password',
team: { id: 'test-team-id', name: 'test-team-name', inHouse: false },
};
export function getAppstoreMock() {
return {
ensureAuthenticatedAsync: jest.fn(),
@@ -2,6 +2,7 @@ import {
AppFragment,
AppleAppIdentifierFragment,
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
AppleTeamFragment,
} from '../../graphql/generated';
import {
@@ -56,6 +57,17 @@ export const testAppFragment: AppFragment = {
slug: 'testapp',
};
export const testProvisioningProfileFragment: AppleProvisioningProfileFragment = {
id: 'test-prov-prof-id-1',
expiration: new Date(),
developerPortalIdentifier: 'test-developer-identifier',
provisioningProfile: 'test-provisioning-profile',
updatedAt: new Date(),
status: 'Active',
appleTeam: { ...testAppleTeamFragment },
appleDevices: [],
};
export const testDistCertFragmentNoDependencies: AppleDistributionCertificateFragment = {
id: 'test-dist-cert-id-1',
certificateP12: 'Y2VydHAxMg==',
@@ -106,7 +106,7 @@ export function getNewIosApiMockWithoutCredentials() {
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: () => testAppleTeam,
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
getDevicesForAppleTeamAsync: jest.fn(),
createProvisioningProfileAsync: jest.fn(),
+2 -2
View File
@@ -5,8 +5,8 @@ import pick from 'lodash/pick';
import Log from '../log';
import { getProjectAccountName } from '../project/projectUtils';
import { confirmAsync } from '../prompts';
import { Actor } from '../user/User';
import { ensureLoggedInAsync, getActorDisplayName } from '../user/actions';
import { Actor, getActorDisplayName } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import AndroidApi from './android/api/Client';
import iOSApi from './ios/api/Client';
import * as IosGraphqlClient from './ios/api/GraphqlClient';
@@ -227,8 +227,8 @@ describe('credentialsJson', () => {
});
});
describe('readSecretEnvsAsync', () => {
it('should read secretEnvs field correctly', async () => {
describe('readEnvironmentSecretsAsync', () => {
it('should read environmentSecrets field correctly', async () => {
vol.fromJSON({
'./credentials.json': JSON.stringify({
ios: {
@@ -245,7 +245,7 @@ describe('credentialsJson', () => {
'./pprofile': 'somebinarycontent',
'./cert.p12': 'somebinarycontent2',
});
const result = await credentialsJsonReader.readSecretEnvsAsync('.');
const result = await credentialsJsonReader.readEnvironmentSecretsAsync('.');
expect(result).toEqual({
NPM_TOKEN: 'VALUE',
});
@@ -136,7 +136,7 @@ async function readCredentialsForTargetAsync(
};
}
export async function readSecretEnvsAsync(
export async function readEnvironmentSecretsAsync(
projectDir: string
): Promise<Record<string, string> | undefined> {
if (!(await fileExistsAsync(projectDir))) {
@@ -122,13 +122,13 @@ export default class IosCredentialsProvider implements CredentialsProvider {
if (this.options.distribution === 'internal' && !isAdHoc) {
throw new Error(
`You must use an adhoc provisioning profile${
targetName && ` (target '${targetName})'`
targetName ? ` (target '${targetName})'` : ''
} for internal distribution`
);
} else if (this.options.distribution !== 'internal' && isAdHoc) {
throw new Error(
`You can't use an adhoc provisioning profile${
targetName && ` (target '${targetName}')`
targetName ? ` (target '${targetName}')` : ''
} for app store distribution`
);
}
@@ -0,0 +1,113 @@
import assert from 'assert';
import chalk from 'chalk';
import ora from 'ora';
import {
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
} from '../../../../graphql/generated';
import Log from '../../../../log';
import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
import { AppleProvisioningProfileMutationResult } from '../../api/graphql/mutations/AppleProvisioningProfileMutation';
import { ProvisioningProfileStoreInfo } from '../../appstore/Credentials.types';
import { AuthCtx } from '../../appstore/authenticate';
import { MissingCredentialsNonInteractiveError } from '../../errors';
export class ConfigureProvisioningProfile {
constructor(
private app: AppLookupParams,
private distributionCertificate: AppleDistributionCertificateFragment,
private originalProvisioningProfile: AppleProvisioningProfileFragment
) {}
public async runAsync(ctx: Context): Promise<AppleProvisioningProfileMutationResult | null> {
if (ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError(
'Configuring Provisioning Profiles is only supported in interactive mode.'
);
}
const { developerPortalIdentifier, provisioningProfile } = this.originalProvisioningProfile;
if (!developerPortalIdentifier && !provisioningProfile) {
Log.warn("The provisioning profile we have on file cannot be configured on Apple's servers.");
return null;
}
if (!ctx.appStore.authCtx) {
Log.warn(
"Without access to your Apple account we can't configure provisioning profiles for you."
);
Log.warn('Make sure to recreate the profile if you selected a new distribution certificate.');
return null;
}
const profilesFromApple = await ctx.appStore.listProvisioningProfilesAsync(
this.app.bundleIdentifier
);
const [matchingProfile] = profilesFromApple.filter(appleInfo =>
developerPortalIdentifier
? appleInfo.provisioningProfileId === developerPortalIdentifier
: appleInfo.provisioningProfile === provisioningProfile
);
if (!matchingProfile) {
Log.warn(
`Profile ${
developerPortalIdentifier ? `${developerPortalIdentifier} ` : ''
}not found on Apple Developer Portal.`
);
return null;
}
return await this.configureAndUpdateAsync(ctx, ctx.appStore.authCtx, this.app, matchingProfile);
}
private async configureAndUpdateAsync(
ctx: Context,
authCtx: AuthCtx,
app: AppLookupParams,
profileFromApple: ProvisioningProfileStoreInfo
): Promise<AppleProvisioningProfileMutationResult> {
const {
developerPortalIdentifier,
certificateP12,
certificatePassword,
serialNumber,
} = this.distributionCertificate;
assert(
certificateP12 && certificatePassword,
'Distribution Certificate P12 and Password is required'
);
// configure profile on Apple's Server to use our distCert
const updatedProfile = await ctx.appStore.useExistingProvisioningProfileAsync(
app.bundleIdentifier,
profileFromApple,
{
certId: developerPortalIdentifier ?? undefined,
certP12: certificateP12,
certPassword: certificatePassword,
distCertSerialNumber: serialNumber,
teamId: authCtx.appleId,
}
);
const bundleIdTag = `(${app.bundleIdentifier})`;
const slugTag = `@${app.account.name}/${app.projectName}`;
const projectTag = `${chalk.bold(slugTag)} ${chalk.dim(bundleIdTag)}`;
const spinner = ora(`Updating Expo profile for ${projectTag}`).start();
try {
const configuredProvisioningProfile = await ctx.newIos.updateProvisioningProfileAsync(
this.originalProvisioningProfile.id,
{
appleProvisioningProfile: updatedProfile.provisioningProfile,
developerPortalIdentifier: updatedProfile.provisioningProfileId,
}
);
spinner.succeed(`Updated Expo profile for ${projectTag}`);
return configuredProvisioningProfile;
} catch (error) {
spinner.fail();
throw error;
}
}
}
@@ -0,0 +1,83 @@
import assert from 'assert';
import nullthrows from 'nullthrows';
import { AppleDistributionCertificateFragment } from '../../../../graphql/generated';
import Log from '../../../../log';
import { Action, CredentialsManager } from '../../../CredentialsManager';
import { Context } from '../../../context';
import { askForUserProvidedAsync } from '../../../utils/promptForCredentials';
import { AppLookupParams } from '../../api/GraphqlClient';
import { AppleProvisioningProfileMutationResult } from '../../api/graphql/mutations/AppleProvisioningProfileMutation';
import { ProvisioningProfile } from '../../appstore/Credentials.types';
import { AuthCtx } from '../../appstore/authenticate';
import { provisioningProfileSchema } from '../../credentials';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { generateProvisioningProfileAsync } from '../ProvisioningProfileUtils';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
export class CreateProvisioningProfile implements Action {
private _provisioningProfile?: AppleProvisioningProfileMutationResult;
constructor(
private app: AppLookupParams,
private distributionCertificate: AppleDistributionCertificateFragment
) {}
public get provisioningProfile(): AppleProvisioningProfileMutationResult {
assert(
this._provisioningProfile,
'provisioningProfile can be accessed only after calling .runAsync()'
);
return this._provisioningProfile;
}
async runAsync(_manager: CredentialsManager, ctx: Context): Promise<void> {
if (ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError(
'Creating Provisioning Profiles is only supported in interactive mode.'
);
}
const appleAuthCtx = await ctx.appStore.ensureAuthenticatedAsync();
const provisioningProfile = await this.provideOrGenerateAsync(ctx, appleAuthCtx);
const appleTeam = nullthrows(await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app));
const appleAppIdentifier = await ctx.newIos.createOrGetExistingAppleAppIdentifierAsync(
this.app,
appleTeam
);
this._provisioningProfile = await ctx.newIos.createProvisioningProfileAsync(
this.app,
appleAppIdentifier,
{
appleProvisioningProfile: provisioningProfile.provisioningProfile,
developerPortalIdentifier: provisioningProfile.provisioningProfileId,
}
);
Log.succeed('Created provisioning profile');
}
private async provideOrGenerateAsync(
ctx: Context,
appleAuthCtx: AuthCtx
): Promise<ProvisioningProfile> {
const userProvided = await askForUserProvidedAsync(provisioningProfileSchema);
if (userProvided) {
// userProvided profiles don't come with ProvisioningProfileId's (only accessible from Apple Portal API)
Log.warn('Provisioning profile: Unable to validate specified profile.');
return userProvided;
}
assert(
this.distributionCertificate.certificateP12,
'Distribution Certificate must have p12 certificate'
);
assert(
this.distributionCertificate.certificatePassword,
'Distribution Certificate must have certificate password'
);
return await generateProvisioningProfileAsync(ctx, this.app.bundleIdentifier, {
certId: this.distributionCertificate.developerPortalIdentifier ?? undefined,
certP12: this.distributionCertificate.certificateP12,
certPassword: this.distributionCertificate.certificatePassword,
distCertSerialNumber: this.distributionCertificate.serialNumber,
teamId: this.distributionCertificate.appleTeam?.appleTeamIdentifier ?? appleAuthCtx.appleId,
});
}
}
@@ -52,7 +52,7 @@ export class SetupAdhocProvisioningProfile implements Action {
private async runNonInteractiveAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
// 1. Setup Distribution Certificate
const distCertAction = new SetupDistributionCertificate(this.app);
const distCertAction = new SetupDistributionCertificate(this.app, IosDistributionType.AdHoc);
await manager.runActionAsync(distCertAction);
// 2. Fetch profile from EAS servers
@@ -97,7 +97,7 @@ export class SetupAdhocProvisioningProfile implements Action {
}
// 2. Setup Distribution Certificate
const distCertAction = new SetupDistributionCertificate(this.app);
const distCertAction = new SetupDistributionCertificate(this.app, IosDistributionType.AdHoc);
await manager.runActionAsync(distCertAction);
const distCert = distCertAction.distributionCertificate;
@@ -22,7 +22,7 @@ export class SetupDistributionCertificate implements Action {
private validDistCerts?: AppleDistributionCertificateFragment[];
private _distributionCertificate?: AppleDistributionCertificateFragment;
constructor(private app: AppLookupParams) {}
constructor(private app: AppLookupParams, private distributionType: IosDistributionType) {}
public get distributionCertificate(): AppleDistributionCertificateFragment {
assert(
@@ -38,7 +38,7 @@ export class SetupDistributionCertificate implements Action {
try {
const currentCertificate = await ctx.newIos.getDistributionCertificateForAppAsync(
this.app,
IosDistributionType.AdHoc,
this.distributionType,
{ appleTeam }
);
@@ -61,9 +61,7 @@ export class SetupDistributionCertificate implements Action {
): Promise<void> {
// TODO: implement validation
Log.addNewLineIfNone();
Log.warn(
'Distribution Certificate is not validated for non-interactive internal distribution builds.'
);
Log.warn('Distribution Certificate is not validated for non-interactive builds.');
if (!currentCertificate) {
throw new MissingCredentialsNonInteractiveError();
}
@@ -0,0 +1,64 @@
import { confirmAsync } from '../../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../../../__tests__/fixtures-context';
import {
testDistCertFragmentNoDependencies,
testProvisioningProfile,
testProvisioningProfileFragment,
} from '../../../../__tests__/fixtures-ios';
import { ManageIosBeta } from '../../../../manager/ManageIosBeta';
import { MissingCredentialsNonInteractiveError } from '../../../errors';
import { ConfigureProvisioningProfile } from '../ConfigureProvisioningProfile';
jest.mock('../../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
describe('ConfigureProvisioningProfile', () => {
it('configures a Provisioning Profile in Interactive Mode', async () => {
const mockProvisioningProfileFromApple = {
provisioningProfileId: testProvisioningProfileFragment.developerPortalIdentifier,
provisioningProfile: testProvisioningProfileFragment.provisioningProfile,
};
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
authCtx: testAuthCtx,
createProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
listProvisioningProfilesAsync: jest.fn(() => [mockProvisioningProfileFromApple]),
useExistingProvisioningProfileAsync: jest.fn(() => mockProvisioningProfileFromApple),
},
});
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const provProfConfigurator = new ConfigureProvisioningProfile(
appLookupParams,
testDistCertFragmentNoDependencies,
testProvisioningProfileFragment
);
await provProfConfigurator.runAsync(ctx);
// expect provisioning profile not to be updated on expo servers
expect((ctx.newIos.updateProvisioningProfileAsync as any).mock.calls.length).toBe(1);
// expect provisioning profile not to be updated on apple portal
expect((ctx.appStore.useExistingProvisioningProfileAsync as any).mock.calls.length).toBe(1);
});
it('errors in Non Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const provProfConfigurator = new ConfigureProvisioningProfile(
appLookupParams,
testDistCertFragmentNoDependencies,
testProvisioningProfileFragment
);
await expect(provProfConfigurator.runAsync(ctx)).rejects.toThrowError(
MissingCredentialsNonInteractiveError
);
// expect provisioning profile not to be updated on expo servers
expect((ctx.newIos.updateProvisioningProfileAsync as any).mock.calls.length).toBe(0);
// expect provisioning profile not to be updated on apple portal
expect((ctx.appStore.useExistingProvisioningProfileAsync as any).mock.calls.length).toBe(0);
});
});
@@ -0,0 +1,57 @@
import { confirmAsync } from '../../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../../__tests__/fixtures-appstore';
import { createCtxMock, createManagerMock } from '../../../../__tests__/fixtures-context';
import {
testDistCertFragmentNoDependencies,
testProvisioningProfile,
} from '../../../../__tests__/fixtures-ios';
import { ManageIosBeta } from '../../../../manager/ManageIosBeta';
import { MissingCredentialsNonInteractiveError } from '../../../errors';
import { CreateProvisioningProfile } from '../CreateProvisioningProfile';
jest.mock('../../../../../prompts');
(confirmAsync as jest.Mock).mockImplementation(() => true);
describe('CreateProvisioningProfile', () => {
it('creates a Provisioning Profile in Interactive Mode', async () => {
const manager = createManagerMock();
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
authCtx: testAuthCtx,
createProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
},
});
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const createProvProfAction = new CreateProvisioningProfile(
appLookupParams,
testDistCertFragmentNoDependencies
);
await createProvProfAction.runAsync(manager, ctx);
// expect provisioning profile to be created on expo servers
expect((ctx.newIos.createProvisioningProfileAsync as any).mock.calls.length).toBe(1);
// expect provisioning profile to be created on apple portal
expect((ctx.appStore.createProvisioningProfileAsync as any).mock.calls.length).toBe(1);
});
it('errors in Non Interactive Mode', async () => {
const manager = createManagerMock();
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const createProvProfAction = new CreateProvisioningProfile(
appLookupParams,
testDistCertFragmentNoDependencies
);
await expect(createProvProfAction.runAsync(manager, ctx)).rejects.toThrowError(
MissingCredentialsNonInteractiveError
);
// expect provisioning profile not to be created on expo servers
expect((ctx.newIos.createProvisioningProfileAsync as any).mock.calls.length).toBe(0);
// expect provisioning profile not to be created on apple portal
expect((ctx.appStore.createProvisioningProfileAsync as any).mock.calls.length).toBe(0);
});
});
+1 -2
View File
@@ -9,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';
+458 -28
View File
@@ -58,6 +58,8 @@ export type RootQuery = {
buildJobs: BuildJobQuery;
builds: BuildQuery;
clientBuilds: ClientBuildQuery;
/** Top-level query object for querying Experimentation configuration. */
experimentation: ExperimentationQuery;
project: ProjectQuery;
/** Search for Snacks */
search: Array<SearchResult>;
@@ -204,6 +206,7 @@ export type Account = {
snacks: Array<Snack>;
/** Apps associated with this account */
apps: Array<App>;
appCount: Scalars['Int'];
/** Build Jobs associated with this account */
buildJobs: Array<BuildJob>;
/** (EAS Build) Builds associated with this account */
@@ -406,7 +409,10 @@ export type App = Project & {
username: Scalars['String'];
updated: Scalars['DateTime'];
pushSecurityEnabled?: Maybe<Scalars['Boolean']>;
ownerAccount: Account;
/** @deprecated Use 'privacySetting' instead. */
privacy: Scalars['String'];
privacySetting: AppPrivacy;
latestReleaseId: Scalars['ID'];
/** @deprecated 'likes' have been deprecated. */
isLikedByMe: Scalars['Boolean'];
@@ -419,6 +425,7 @@ export type App = Project & {
likedBy: Array<Maybe<User>>;
/** @deprecated Field no longer supported */
releases: Array<Maybe<AppRelease>>;
/** (EAS Build) Builds associated with this app */
builds: Array<Build>;
buildJobs: Array<BuildJob>;
/** @deprecated Field no longer supported */
@@ -475,6 +482,7 @@ export type AppBuildsArgs = {
offset: Scalars['Int'];
limit: Scalars['Int'];
status?: Maybe<BuildStatus>;
platform?: Maybe<AppPlatform>;
};
@@ -546,6 +554,12 @@ export type AppIcon = {
};
export enum AppPrivacy {
Public = 'PUBLIC',
Unlisted = 'UNLISTED',
Hidden = 'HIDDEN'
}
/** Represents a human (not robot) actor. */
export type User = Actor & {
__typename?: 'User';
@@ -765,8 +779,9 @@ export enum DistributionType {
export type BuildError = {
__typename?: 'BuildError';
errorCode?: Maybe<Scalars['String']>;
message?: Maybe<Scalars['String']>;
errorCode: Scalars['String'];
message: Scalars['String'];
docsUrl?: Maybe<Scalars['String']>;
};
/** Represents an Standalone App build job */
@@ -1374,6 +1389,16 @@ export type ClientBuild = {
userId?: Maybe<Scalars['String']>;
};
export type ExperimentationQuery = {
__typename?: 'ExperimentationQuery';
/** Get user experimentation config */
userConfig: Scalars['JSONObject'];
/** Get device experimentation config */
deviceConfig: Scalars['JSONObject'];
/** Get experimentation unit to use for device experiments. In this case, it is the IP address. */
deviceExperimentationUnit: Scalars['ID'];
};
export type ProjectQuery = {
__typename?: 'ProjectQuery';
byUsernameAndSlug: Project;
@@ -1515,6 +1540,7 @@ export type RootMutation = {
updateChannel: UpdateChannelMutation;
update: UpdateMutation;
updateBranch: UpdateBranchMutation;
uploadSession: UploadSession;
/** Mutations that create, delete, and accept UserInvitations */
userInvitation: UserInvitationMutation;
/** Mutations that modify the currently authenticated User */
@@ -1532,7 +1558,7 @@ export type RootMutationAccountArgs = {
export type RootMutationAppArgs = {
appId: Scalars['ID'];
appId?: Maybe<Scalars['ID']>;
};
@@ -1542,7 +1568,7 @@ export type RootMutationBuildJobArgs = {
export type RootMutationBuildArgs = {
buildId: Scalars['ID'];
buildId?: Maybe<Scalars['ID']>;
};
export type AccessTokenMutation = {
@@ -1615,6 +1641,8 @@ export type AccountMutation = {
sendEmail?: Maybe<Account>;
/** Require authorization to send push notifications for experiences owned by this account */
setPushSecurityEnabled?: Maybe<Account>;
/** Rename this account */
rename: Account;
};
@@ -1675,6 +1703,12 @@ export type AccountMutationSetPushSecurityEnabledArgs = {
pushSecurityEnabled: Scalars['Boolean'];
};
export type AccountMutationRenameArgs = {
accountID: Scalars['ID'];
newName: Scalars['String'];
};
export enum EmailTemplate {
/** Able to purchase Developer Services */
DevServicesOfferExtended = 'DEV_SERVICES_OFFER_EXTENDED',
@@ -2013,6 +2047,8 @@ export type AppleTeamInput = {
export type AppMutation = {
__typename?: 'AppMutation';
/** Create an unpublished app */
createApp: App;
/** @deprecated Field no longer supported */
grantAccess?: Maybe<App>;
/** Require api token to send push notifs for experience */
@@ -2020,6 +2056,11 @@ export type AppMutation = {
};
export type AppMutationCreateAppArgs = {
appInput: AppInput;
};
export type AppMutationGrantAccessArgs = {
toUser: Scalars['ID'];
accessLevel?: Maybe<Scalars['String']>;
@@ -2031,6 +2072,12 @@ export type AppMutationSetPushSecurityEnabledArgs = {
pushSecurityEnabled: Scalars['Boolean'];
};
export type AppInput = {
accountId: Scalars['ID'];
projectName: Scalars['String'];
privacy: AppPrivacy;
};
export type AssetMutation = {
__typename?: 'AssetMutation';
/**
@@ -2059,10 +2106,222 @@ export type BuildJobMutation = {
export type BuildMutation = {
__typename?: 'BuildMutation';
/** Cancel an EAS Build" */
/** Cancel an EAS Build build */
cancelBuild: Build;
/**
* Cancel an EAS Build build
* @deprecated Use cancelBuild instead
*/
cancel: Build;
/** Create an Android generic build */
createAndroidGenericBuild: CreateBuildResult;
/** Create an Android managed build */
createAndroidManagedBuild: CreateBuildResult;
/** Create an iOS generic build */
createIosGenericBuild: CreateBuildResult;
/** Create an iOS managed build */
createIosManagedBuild: CreateBuildResult;
};
export type BuildMutationCancelBuildArgs = {
buildId: Scalars['ID'];
};
export type BuildMutationCreateAndroidGenericBuildArgs = {
appId: Scalars['ID'];
job: AndroidGenericJobInput;
metadata?: Maybe<BuildMetadataInput>;
};
export type BuildMutationCreateAndroidManagedBuildArgs = {
appId: Scalars['ID'];
job: AndroidManagedJobInput;
metadata?: Maybe<BuildMetadataInput>;
};
export type BuildMutationCreateIosGenericBuildArgs = {
appId: Scalars['ID'];
job: IosGenericJobInput;
metadata?: Maybe<BuildMetadataInput>;
};
export type BuildMutationCreateIosManagedBuildArgs = {
appId: Scalars['ID'];
job: IosManagedJobInput;
metadata?: Maybe<BuildMetadataInput>;
};
export type AndroidGenericJobInput = {
projectArchive: ProjectArchiveSourceInput;
projectRootDirectory: Scalars['String'];
releaseChannel?: Maybe<Scalars['String']>;
secrets?: Maybe<AndroidJobSecretsInput>;
builderEnvironment?: Maybe<AndroidBuilderEnvironmentInput>;
cache?: Maybe<BuildCacheInput>;
gradleCommand?: Maybe<Scalars['String']>;
artifactPath?: Maybe<Scalars['String']>;
};
export type ProjectArchiveSourceInput = {
type: ProjectArchiveSourceType;
bucketKey?: Maybe<Scalars['String']>;
url?: Maybe<Scalars['String']>;
};
export enum ProjectArchiveSourceType {
S3 = 'S3',
Url = 'URL'
}
export type AndroidJobSecretsInput = {
buildCredentials?: Maybe<AndroidJobBuildCredentialsInput>;
environmentSecrets?: Maybe<Scalars['JSONObject']>;
};
export type AndroidJobBuildCredentialsInput = {
keystore: AndroidJobKeystoreInput;
};
export type AndroidJobKeystoreInput = {
dataBase64: Scalars['String'];
keystorePassword: Scalars['String'];
keyAlias: Scalars['String'];
keyPassword: Scalars['String'];
};
export type AndroidBuilderEnvironmentInput = {
image?: Maybe<Scalars['String']>;
node?: Maybe<Scalars['String']>;
yarn?: Maybe<Scalars['String']>;
ndk?: Maybe<Scalars['String']>;
env?: Maybe<Scalars['JSONObject']>;
};
export type BuildCacheInput = {
disabled?: Maybe<Scalars['Boolean']>;
key?: Maybe<Scalars['String']>;
cacheDefaultPaths?: Maybe<Scalars['Boolean']>;
customPaths?: Maybe<Array<Maybe<Scalars['String']>>>;
};
export type BuildMetadataInput = {
trackingContext?: Maybe<Scalars['JSONObject']>;
appVersion?: Maybe<Scalars['String']>;
cliVersion?: Maybe<Scalars['String']>;
workflow?: Maybe<BuildWorkflow>;
credentialsSource?: Maybe<BuildCredentialsSource>;
sdkVersion?: Maybe<Scalars['String']>;
releaseChannel?: Maybe<Scalars['String']>;
distribution?: Maybe<DistributionType>;
appName?: Maybe<Scalars['String']>;
appIdentifier?: Maybe<Scalars['String']>;
buildProfile?: Maybe<Scalars['String']>;
gitCommitHash?: Maybe<Scalars['String']>;
};
export enum BuildWorkflow {
Generic = 'GENERIC',
Managed = 'MANAGED'
}
export enum BuildCredentialsSource {
Local = 'LOCAL',
Remote = 'REMOTE'
}
export type CreateBuildResult = {
__typename?: 'CreateBuildResult';
build: Build;
deprecationInfo?: Maybe<EasBuildDeprecationInfo>;
};
export type EasBuildDeprecationInfo = {
__typename?: 'EASBuildDeprecationInfo';
type: EasBuildDeprecationInfoType;
message: Scalars['String'];
};
export enum EasBuildDeprecationInfoType {
UserFacing = 'USER_FACING',
Internal = 'INTERNAL'
}
export type AndroidManagedJobInput = {
projectArchive: ProjectArchiveSourceInput;
projectRootDirectory: Scalars['String'];
releaseChannel?: Maybe<Scalars['String']>;
secrets?: Maybe<AndroidJobSecretsInput>;
builderEnvironment?: Maybe<AndroidBuilderEnvironmentInput>;
cache?: Maybe<BuildCacheInput>;
username?: Maybe<Scalars['String']>;
};
export type IosGenericJobInput = {
projectArchive: ProjectArchiveSourceInput;
projectRootDirectory: Scalars['String'];
releaseChannel?: Maybe<Scalars['String']>;
distribution?: Maybe<DistributionType>;
secrets?: Maybe<IosJobSecretsInput>;
builderEnvironment?: Maybe<IosBuilderEnvironmentInput>;
cache?: Maybe<BuildCacheInput>;
scheme: Scalars['String'];
schemeBuildConfiguration?: Maybe<IosSchemeBuildConfiguration>;
artifactPath?: Maybe<Scalars['String']>;
};
export type IosJobSecretsInput = {
buildCredentials?: Maybe<Array<Maybe<IosJobTargetCredentialsInput>>>;
environmentSecrets?: Maybe<Scalars['JSONObject']>;
};
export type IosJobTargetCredentialsInput = {
targetName: Scalars['String'];
provisioningProfileBase64: Scalars['String'];
distributionCertificate: IosJobDistributionCertificateInput;
};
export type IosJobDistributionCertificateInput = {
dataBase64: Scalars['String'];
password: Scalars['String'];
};
export type IosBuilderEnvironmentInput = {
image?: Maybe<Scalars['String']>;
node?: Maybe<Scalars['String']>;
yarn?: Maybe<Scalars['String']>;
bundler?: Maybe<Scalars['String']>;
fastlane?: Maybe<Scalars['String']>;
cocoapods?: Maybe<Scalars['String']>;
env?: Maybe<Scalars['JSONObject']>;
};
export enum IosSchemeBuildConfiguration {
Release = 'RELEASE',
Debug = 'DEBUG'
}
export type IosManagedJobInput = {
projectArchive: ProjectArchiveSourceInput;
projectRootDirectory: Scalars['String'];
releaseChannel?: Maybe<Scalars['String']>;
distribution?: Maybe<DistributionType>;
secrets?: Maybe<IosJobSecretsInput>;
builderEnvironment?: Maybe<IosBuilderEnvironmentInput>;
cache?: Maybe<BuildCacheInput>;
buildType?: Maybe<IosManagedBuildType>;
username?: Maybe<Scalars['String']>;
};
export enum IosManagedBuildType {
Release = 'RELEASE',
DevelopmentClient = 'DEVELOPMENT_CLIENT'
}
export type IosAppBuildCredentialsMutation = {
__typename?: 'IosAppBuildCredentialsMutation';
/** Create a set of build credentials for an iOS app */
@@ -2317,6 +2576,22 @@ export type PartialManifestAsset = {
storageKey: Scalars['String'];
};
export type UploadSession = {
__typename?: 'UploadSession';
/** Create an Upload Session */
createUploadSession: Scalars['JSONObject'];
};
export type UploadSessionCreateUploadSessionArgs = {
type?: Maybe<UploadSessionType>;
};
export enum UploadSessionType {
EasBuildProjectSources = 'EAS_BUILD_PROJECT_SOURCES',
EasSubmitAppArchive = 'EAS_SUBMIT_APP_ARCHIVE'
}
export type UserInvitationMutation = {
__typename?: 'UserInvitationMutation';
/**
@@ -2626,13 +2901,13 @@ export type EnvironmentSecretMutation = {
export type EnvironmentSecretMutationCreateEnvironmentSecretForAccountArgs = {
environmentSecretData?: Maybe<CreateEnvironmentSecretInput>;
environmentSecretData: CreateEnvironmentSecretInput;
accountId: Scalars['String'];
};
export type EnvironmentSecretMutationCreateEnvironmentSecretForAppArgs = {
environmentSecretData?: Maybe<CreateEnvironmentSecretInput>;
environmentSecretData: CreateEnvironmentSecretInput;
appId: Scalars['String'];
};
@@ -2980,7 +3255,7 @@ export type GetChannelByNameForAppQuery = (
& Pick<App, 'id'>
& { updateChannelByName: (
{ __typename?: 'UpdateChannel' }
& Pick<UpdateChannel, 'id' | 'name' | 'createdAt'>
& Pick<UpdateChannel, 'id' | 'name' | 'createdAt' | 'branchMapping'>
& { updateBranches: Array<(
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
@@ -3607,6 +3882,118 @@ export type CommonIosAppCredentialsWithBuildCredentialsByAppIdentifierIdQuery =
)> }
);
export type CreateAppMutationVariables = Exact<{
appInput: AppInput;
}>;
export type CreateAppMutation = (
{ __typename?: 'RootMutation' }
& { app?: Maybe<(
{ __typename?: 'AppMutation' }
& { createApp: (
{ __typename?: 'App' }
& Pick<App, 'id'>
) }
)> }
);
export type CreateAndroidGenericBuildMutationVariables = Exact<{
appId: Scalars['ID'];
job: AndroidGenericJobInput;
metadata?: Maybe<BuildMetadataInput>;
}>;
export type CreateAndroidGenericBuildMutation = (
{ __typename?: 'RootMutation' }
& { build?: Maybe<(
{ __typename?: 'BuildMutation' }
& { createAndroidGenericBuild: (
{ __typename?: 'CreateBuildResult' }
& { build: (
{ __typename?: 'Build' }
& Pick<Build, 'id'>
), deprecationInfo?: Maybe<(
{ __typename?: 'EASBuildDeprecationInfo' }
& Pick<EasBuildDeprecationInfo, 'type' | 'message'>
)> }
) }
)> }
);
export type CreateAndroidManagedBuildMutationVariables = Exact<{
appId: Scalars['ID'];
job: AndroidManagedJobInput;
metadata?: Maybe<BuildMetadataInput>;
}>;
export type CreateAndroidManagedBuildMutation = (
{ __typename?: 'RootMutation' }
& { build?: Maybe<(
{ __typename?: 'BuildMutation' }
& { createAndroidManagedBuild: (
{ __typename?: 'CreateBuildResult' }
& { build: (
{ __typename?: 'Build' }
& Pick<Build, 'id'>
), deprecationInfo?: Maybe<(
{ __typename?: 'EASBuildDeprecationInfo' }
& Pick<EasBuildDeprecationInfo, 'type' | 'message'>
)> }
) }
)> }
);
export type CreateIosGenericBuildMutationVariables = Exact<{
appId: Scalars['ID'];
job: IosGenericJobInput;
metadata?: Maybe<BuildMetadataInput>;
}>;
export type CreateIosGenericBuildMutation = (
{ __typename?: 'RootMutation' }
& { build?: Maybe<(
{ __typename?: 'BuildMutation' }
& { createIosGenericBuild: (
{ __typename?: 'CreateBuildResult' }
& { build: (
{ __typename?: 'Build' }
& Pick<Build, 'id'>
), deprecationInfo?: Maybe<(
{ __typename?: 'EASBuildDeprecationInfo' }
& Pick<EasBuildDeprecationInfo, 'type' | 'message'>
)> }
) }
)> }
);
export type CreateIosManagedBuildMutationVariables = Exact<{
appId: Scalars['ID'];
job: IosManagedJobInput;
metadata?: Maybe<BuildMetadataInput>;
}>;
export type CreateIosManagedBuildMutation = (
{ __typename?: 'RootMutation' }
& { build?: Maybe<(
{ __typename?: 'BuildMutation' }
& { createIosManagedBuild: (
{ __typename?: 'CreateBuildResult' }
& { build: (
{ __typename?: 'Build' }
& Pick<Build, 'id'>
), deprecationInfo?: Maybe<(
{ __typename?: 'EASBuildDeprecationInfo' }
& Pick<EasBuildDeprecationInfo, 'type' | 'message'>
)> }
) }
)> }
);
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
input: CreateEnvironmentSecretInput;
accountId: Scalars['String'];
@@ -3619,6 +4006,7 @@ export type CreateEnvironmentSecretForAccountMutation = (
{ __typename?: 'EnvironmentSecretMutation' }
& { createEnvironmentSecretForAccount: (
{ __typename?: 'EnvironmentSecret' }
& Pick<EnvironmentSecret, 'id'>
& EnvironmentSecretFragment
) }
) }
@@ -3636,6 +4024,7 @@ export type CreateEnvironmentSecretForAppMutation = (
{ __typename?: 'EnvironmentSecretMutation' }
& { createEnvironmentSecretForApp: (
{ __typename?: 'EnvironmentSecret' }
& Pick<EnvironmentSecret, 'id'>
& EnvironmentSecretFragment
) }
) }
@@ -3689,6 +4078,19 @@ export type UpdatePublishMutation = (
) }
);
export type CreateUploadSessionMutationVariables = Exact<{
type: UploadSessionType;
}>;
export type CreateUploadSessionMutation = (
{ __typename?: 'RootMutation' }
& { uploadSession: (
{ __typename?: 'UploadSession' }
& Pick<UploadSession, 'createUploadSession'>
) }
);
export type BuildsByIdQueryVariables = Exact<{
buildId: Scalars['ID'];
}>;
@@ -3700,37 +4102,35 @@ export type BuildsByIdQuery = (
{ __typename?: 'BuildQuery' }
& { byId: (
{ __typename?: 'Build' }
& Pick<Build, 'id' | 'platform' | 'status' | 'createdAt'>
& { artifacts?: Maybe<(
{ __typename?: 'BuildArtifacts' }
& Pick<BuildArtifacts, 'buildUrl'>
)> }
& Pick<Build, 'id'>
& BuildFragment
) }
) }
);
export type BuildsForAppQueryVariables = Exact<{
export type GetAllBuildsForAppQueryVariables = Exact<{
appId: Scalars['String'];
limit?: Maybe<Scalars['Int']>;
offset?: Maybe<Scalars['Int']>;
platform?: Maybe<AppPlatform>;
offset: Scalars['Int'];
limit: Scalars['Int'];
status?: Maybe<BuildStatus>;
platform?: Maybe<AppPlatform>;
}>;
export type BuildsForAppQuery = (
export type GetAllBuildsForAppQuery = (
{ __typename?: 'RootQuery' }
& { builds: (
{ __typename?: 'BuildQuery' }
& { allForApp: Array<Maybe<(
{ __typename?: 'Build' }
& Pick<Build, 'id' | 'platform' | 'status' | 'createdAt'>
& { artifacts?: Maybe<(
{ __typename?: 'BuildArtifacts' }
& Pick<BuildArtifacts, 'buildUrl'>
& { app?: Maybe<(
{ __typename?: 'AppQuery' }
& { byId: (
{ __typename?: 'App' }
& Pick<App, 'id'>
& { builds: Array<(
{ __typename?: 'Build' }
& Pick<Build, 'id'>
& BuildFragment
)> }
)>> }
) }
) }
)> }
);
export type PendingBuildsForAccountAndPlatformQueryVariables = Exact<{
@@ -3771,6 +4171,7 @@ export type EnvironmentSecretsByAccountNameQuery = (
& Pick<Account, 'id'>
& { environmentSecrets: Array<(
{ __typename?: 'EnvironmentSecret' }
& Pick<EnvironmentSecret, 'id'>
& EnvironmentSecretFragment
)> }
) }
@@ -3791,6 +4192,7 @@ export type EnvironmentSecretsByAppFullNameQuery = (
& Pick<App, 'id'>
& { environmentSecrets: Array<(
{ __typename?: 'EnvironmentSecret' }
& Pick<EnvironmentSecret, 'id'>
& EnvironmentSecretFragment
)> }
) }
@@ -3860,6 +4262,34 @@ export type AppFragment = (
& Pick<App, 'id' | 'fullName' | 'slug'>
);
export type BuildFragment = (
{ __typename?: 'Build' }
& Pick<Build, 'id' | 'status' | 'platform' | 'releaseChannel' | 'distribution' | 'createdAt' | 'updatedAt'>
& { error?: Maybe<(
{ __typename?: 'BuildError' }
& Pick<BuildError, 'errorCode' | 'message' | 'docsUrl'>
)>, artifacts?: Maybe<(
{ __typename?: 'BuildArtifacts' }
& Pick<BuildArtifacts, 'buildUrl' | 'xcodeBuildLogsUrl'>
)>, initiatingActor?: Maybe<(
{ __typename: 'User' }
& Pick<User, 'username' | 'id'>
) | (
{ __typename: 'Robot' }
& Pick<Robot, 'firstName' | 'id'>
)>, project: (
{ __typename: 'Snack' }
& Pick<Snack, 'id'>
) | (
{ __typename: 'App' }
& Pick<App, 'id'>
& { ownerAccount: (
{ __typename?: 'Account' }
& Pick<Account, 'id' | 'name'>
) }
) }
);
export type EnvironmentSecretFragment = (
{ __typename?: 'EnvironmentSecret' }
& Pick<EnvironmentSecret, 'id' | 'name' | 'createdAt'>
@@ -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,35 @@
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
CreateUploadSessionMutation,
CreateUploadSessionMutationVariables,
UploadSessionType,
} from '../generated';
export interface PresignedPost {
url: string;
fields: Record<string, string>;
}
export const UploadSessionMutation = {
async createUploadSession(type: UploadSessionType): Promise<PresignedPost> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateUploadSessionMutation, CreateUploadSessionMutationVariables>(
gql`
mutation CreateUploadSessionMutation($type: UploadSessionType!) {
uploadSession {
createUploadSession(type: $type)
}
}
`,
{
type,
}
)
.toPromise()
);
return data.uploadSession.createUploadSession;
},
};
@@ -1,43 +1,53 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
AppPlatform,
Build,
BuildFragment,
BuildStatus,
BuildsByIdQuery,
BuildsForAppQuery,
BuildsByIdQueryVariables,
GetAllBuildsForAppQuery,
GetAllBuildsForAppQueryVariables,
PendingBuildsForAccountAndPlatformQuery,
PendingBuildsForAccountAndPlatformQueryVariables,
} from '../generated';
import { BuildFragmentNode } from '../types/Build';
type Filters = Partial<Pick<Build, 'platform' | 'status'>> & {
type Filters = {
platform?: AppPlatform;
status?: BuildStatus;
offset?: number;
limit?: number;
};
type BuildQueryResult = Pick<Build, 'id' | 'platform' | 'artifacts' | 'status' | 'createdAt'>;
type PendingBuildQueryResult = Pick<Build, 'id' | 'platform'>;
const BuildQuery = {
async byIdAsync(buildId: string): Promise<BuildQueryResult> {
export const BuildQuery = {
async byIdAsync(
buildId: string,
{ useCache = true }: { useCache?: boolean } = {}
): Promise<BuildFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<BuildsByIdQuery>(
.query<BuildsByIdQuery, BuildsByIdQueryVariables>(
gql`
query BuildsByIdQuery($buildId: ID!) {
builds {
byId(buildId: $buildId) {
id
platform
status
createdAt
artifacts {
buildUrl
}
...BuildFragment
}
}
}
${print(BuildFragmentNode)}
`,
{ buildId }
{ buildId },
{
requestPolicy: useCache ? 'cache-first' : 'network-only',
}
)
.toPromise()
);
@@ -45,44 +55,40 @@ const BuildQuery = {
return data.builds.byId;
},
async allForAppAsync(appId: string, filters?: Filters): Promise<BuildQueryResult[]> {
async allForAppAsync(
appId: string,
{ limit = 10, offset = 0, status, platform }: Filters
): Promise<BuildFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<BuildsForAppQuery>(
.query<GetAllBuildsForAppQuery, GetAllBuildsForAppQueryVariables>(
// TODO: Change $appId: String! to ID! when fixed server-side schema
gql`
query BuildsForAppQuery(
query GetAllBuildsForApp(
$appId: String!
$limit: Int
$offset: Int
$platform: AppPlatform
$offset: Int!
$limit: Int!
$status: BuildStatus
$platform: AppPlatform
) {
builds {
allForApp(
appId: $appId
limit: $limit
offset: $offset
platform: $platform
status: $status
) {
app {
byId(appId: $appId) {
id
platform
status
createdAt
artifacts {
buildUrl
builds(offset: $offset, limit: $limit, status: $status, platform: $platform) {
id
...BuildFragment
}
}
}
}
${print(BuildFragmentNode)}
`,
{ ...filters, appId }
{ appId, offset, limit, status, platform }
)
.toPromise()
);
return data.builds.allForApp as BuildQueryResult[];
return data.app?.byId.builds ?? [];
},
async getPendingBuildIdAsync(
@@ -91,7 +97,10 @@ const BuildQuery = {
): Promise<PendingBuildQueryResult | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<PendingBuildsForAccountAndPlatformQuery>(
.query<
PendingBuildsForAccountAndPlatformQuery,
PendingBuildsForAccountAndPlatformQueryVariables
>(
gql`
query PendingBuildsForAccountAndPlatform(
$accountName: String!
@@ -133,5 +142,3 @@ const BuildQuery = {
return pendingBuilds.length > 0 ? pendingBuilds[0] : null;
},
};
export { BuildQuery };
@@ -0,0 +1,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
}
`;
@@ -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,
});
}
+13 -2
View File
@@ -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';
@@ -37,13 +37,24 @@ export async function findProjectRootAsync(cwd?: string): Promise<string | null>
}
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,
});
}
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}`;
+2 -1
View File
@@ -10,9 +10,10 @@ import path from 'path';
import { AssetMetadataStatus, PartialManifestAsset } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import { PresignedPost } from '../graphql/mutations/UploadSessionMutation';
import { PublishQuery } from '../graphql/queries/PublishQuery';
import Log from '../log';
import { PresignedPost, uploadWithPresignedPostAsync } from '../uploads';
import { uploadWithPresignedPostAsync } from '../uploads';
export const TIMEOUT_LIMIT = 60_000; // 1 minute
const STORAGE_BUCKET = getStorageBucket();
@@ -6,13 +6,7 @@ import Log from '../../log';
import { promptAsync } from '../../prompts';
import { SubmissionPlatform } from '../types';
import { getBuildArtifactUrlByIdAsync, getLatestBuildArtifactUrlAsync } from '../utils/builds';
import {
downloadAppArchiveAsync,
extractLocalArchiveAsync,
isExistingFile,
pathIsTar,
uploadAppArchiveAsync,
} from '../utils/files';
import { isExistingFile, uploadAppArchiveAsync } from '../utils/files';
export enum ArchiveFileSourceType {
url,
@@ -50,10 +44,9 @@ interface ArchiveFileBuildIdSource extends ArchiveFileSourceBase {
interface ArchiveFilePromptSource extends ArchiveFileSourceBase {
sourceType: ArchiveFileSourceType.prompt;
projectDir: string;
}
interface ResolvedArchive {
export interface ResolvedArchive {
location: string;
realSource: ArchiveFileSource;
}
@@ -72,48 +65,29 @@ export async function getArchiveFileLocationAsync(
case ArchiveFileSourceType.prompt:
return await handlePromptSourceAsync(source);
case ArchiveFileSourceType.url: {
const urlArchive = await handleUrlSourceAsync(source);
const resolvedUrl = await getArchiveLocationForUrlAsync(urlArchive.location);
return { ...urlArchive, location: resolvedUrl };
return await handleUrlSourceAsync(source);
}
case ArchiveFileSourceType.latest: {
const urlArchive = await handleLatestSourceAsync(source);
const resolvedUrl = await getArchiveLocationForUrlAsync(urlArchive.location);
return { ...urlArchive, location: resolvedUrl };
return await handleLatestSourceAsync(source);
}
case ArchiveFileSourceType.path: {
const pathArchive = await handlePathSourceAsync(source);
const resolvedUrl = await getArchiveLocationForPathAsync(pathArchive.location);
return { ...pathArchive, location: resolvedUrl };
return await handlePathSourceAsync(source);
}
case ArchiveFileSourceType.buildId: {
const urlArchive = await handleBuildIdSourceAsync(source);
const resolvedUrl = await getArchiveLocationForUrlAsync(urlArchive.location);
return { ...urlArchive, location: resolvedUrl };
return await handleBuildIdSourceAsync(source);
}
}
}
async function getArchiveLocationForUrlAsync(url: string): Promise<string> {
// When a URL points to a tar file, download it and extract using unified logic.
// Otherwise send it directly to the server in online mode.
if (!pathIsTar(url)) {
return url;
} else {
Log.log('Downloading your app archive');
const localPath = await downloadAppArchiveAsync(url);
return await getArchiveLocationForPathAsync(localPath);
}
}
async function getArchiveLocationForPathAsync(path: string): Promise<string> {
const resolvedPath = await extractLocalArchiveAsync(path);
Log.log('Uploading your app archive to the Expo Submission Service');
return await uploadAppArchiveAsync(resolvedPath);
}
async function handleUrlSourceAsync(source: ArchiveFileUrlSource): Promise<ResolvedArchive> {
if (!validateUrl(source.url)) {
Log.error(chalk.bold(`The URL you provided is invalid: ${source.url}`));
return getArchiveFileLocationAsync({
...source,
sourceType: ArchiveFileSourceType.prompt,
});
}
return {
location: source.url,
realSource: source,
@@ -154,8 +128,11 @@ async function handlePathSourceAsync(source: ArchiveFilePathSource): Promise<Res
sourceType: ArchiveFileSourceType.prompt,
});
}
Log.log('Uploading your app archive to the Expo Submission Service');
const uploadUrl = await uploadAppArchiveAsync(source.path);
return {
location: source.path,
location: uploadUrl,
realSource: source,
};
}
@@ -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);
}
@@ -1,63 +0,0 @@
import { vol } from 'memfs';
import * as path from 'path';
import { downloadAppArchiveAsync, extractLocalArchiveAsync } from '../files';
jest.mock('fs');
const originalFs = jest.requireActual('fs');
jest.mock('got', () => {
return {
stream() {
return {
on() {
const fs = require('fs');
return fs.createReadStream('/apk-archive.tar.gz');
},
};
},
};
});
jest.mock('../files', () => {
const filesModule = jest.requireActual('../files');
return {
...filesModule,
createDownloadStream() {
const fs = require('fs');
const path = require('path');
return fs.createReadStream(path.resolve('fixtures/apk-archive.tar.gz'));
},
};
});
beforeAll(async () => {
vol.writeFileSync(
'/apk-archive.tar.gz',
originalFs.readFileSync(path.join(__dirname, 'fixtures/apk-archive.tar.gz'))
);
});
afterAll(() => {
vol.reset();
});
describe('downloadAppArchiveAsync', () => {
it(`downloads an apk to a file with the same name in a temporary directory`, async () => {
const path = await downloadAppArchiveAsync('http://fake.expo.io/app.apk');
expect(path).toMatch(/app.apk$/);
});
it(`downloads and extracts a tar to a file with the correct extension in a temporary directory`, async () => {
const path = await downloadAppArchiveAsync('http://fake.expo.io/app.tar.gz');
expect(path).toMatch(/app.tar.gz.apk$/);
});
});
describe('extractLocalArchiveAsync', () => {
it(`returns the input path if the file is not a tar`, async () => {
expect(await extractLocalArchiveAsync('/App.apk')).toBe('/App.apk');
});
it(`returns a temporary file from an extracted tar`, async () => {
expect(await extractLocalArchiveAsync('/apk-archive.tar.gz')).toMatch(
/apk-archive.tar.gz.apk$/
);
});
});
+3 -111
View File
@@ -1,17 +1,9 @@
import glob from 'fast-glob';
import fs from 'fs-extra';
import got from 'got';
import os from 'os';
import { basename, extname, join } from 'path';
import stream from 'stream';
import tar from 'tar';
import { promisify } from 'util';
import { UploadType, uploadAsync } from '../../uploads';
import { UploadSessionType } from '../../graphql/generated';
import { uploadAsync } from '../../uploads';
import { createProgressTracker } from '../../utils/progress';
const pipeline = promisify(stream.pipeline);
export async function isExistingFile(filePath: string) {
try {
const stats = await fs.stat(filePath);
@@ -21,74 +13,10 @@ export async function isExistingFile(filePath: string) {
}
}
async function moveFileOfTypeAsync(
directory: string,
extension: string,
dest: string
): Promise<string> {
const [matching] = await glob(`**/*.${extension}`, {
absolute: true,
cwd: directory,
});
if (!matching) {
throw new Error(`No .${extension} files found in directory: ${directory}`);
}
// The incoming destination may be tar.gz because it wasn't clear what type of app file was included.
// Compare the app file extension with the destination extension and if they don't match,
// append the app file extension to the destination. Ex. App.tar.gz.ipa
const matchingExtension = extname(matching).toLowerCase();
const destExtension = extname(dest).toLowerCase();
if (matchingExtension !== destExtension) {
dest = `${dest}${matchingExtension}`;
}
// Ensure we actually need to move the file.
if (matching !== dest) {
await fs.move(matching, dest);
}
return dest;
}
export function createDownloadStream(url: string) {
return got
.stream(url)
.on(
'downloadProgress',
createProgressTracker({ message: 'Downloading app', completedMessage: 'Downloaded app' })
);
}
export function pathIsTar(path: string): boolean {
return path.endsWith('tar.gz');
}
export async function downloadAppArchiveAsync(url: string): Promise<string> {
const filename = basename(url);
// Since we may need to rename the destination path,
// add everything to a folder which can be nuked to ensure we don't accidentally use an old build with the same name.
const destinationFolder = await createTemporaryDirectoryForExtractionAsync();
const destinationPath = join(destinationFolder, filename);
const downloadStream = createDownloadStream(url);
// Special use-case for downloading an EAS tar.gz file and unpackaging it.
if (pathIsTar(url)) {
await pipeline(downloadStream, tar.extract({ cwd: destinationFolder }, []));
// Move the folder contents matching .ipa, .apk, or .aab
return await moveFileOfTypeAsync(destinationFolder, '{ipa,apk,aab}', destinationPath);
} else {
await pipeline(downloadStream, fs.createWriteStream(destinationPath));
}
return destinationPath;
}
export async function uploadAppArchiveAsync(path: string): Promise<string> {
const fileSize = (await fs.stat(path)).size;
const { url } = await uploadAsync(
UploadType.SUBMISSION_APP_ARCHIVE,
UploadSessionType.EasSubmitAppArchive,
path,
createProgressTracker({
total: fileSize,
@@ -98,39 +26,3 @@ export async function uploadAppArchiveAsync(path: string): Promise<string> {
);
return url;
}
async function createTemporaryDirectoryForExtractionAsync(): Promise<string> {
// Since we may need to rename the destination path,
// add everything to a folder which can be nuked to ensure we don't accidentally use an old build with the same name.
const destinationFolder = join(os.tmpdir(), 'expo-submission-service');
if (await fs.pathExists(destinationFolder)) {
await fs.remove(destinationFolder);
}
await fs.ensureDir(destinationFolder);
return destinationFolder;
}
async function decompressTarAsync(src: string, destination: string): Promise<void> {
await pipeline(fs.createReadStream(src), tar.extract({ cwd: destination }, []));
}
export async function extractLocalArchiveAsync(filePath: string): Promise<string> {
if (!pathIsTar(filePath)) {
// No need to extract, copy, or rename the file.
// Leave it in place and return the path.
return filePath;
}
const filename = basename(filePath);
// Since we may need to rename the destination path,
// add everything to a folder which can be nuked to ensure we don't accidentally use an old build with the same name.
const destinationFolder = await createTemporaryDirectoryForExtractionAsync();
const destinationPath = join(destinationFolder, filename);
// Special use-case for downloading an EAS tar.gz file and unpackaging it.
await decompressTarAsync(filePath, destinationFolder);
// Move the folder contents matching .ipa, .apk, or .aab
return await moveFileOfTypeAsync(destinationFolder, '{ipa,apk,aab}', destinationPath);
}
+5 -31
View File
@@ -1,16 +1,11 @@
import assert from 'assert';
import FormData from 'form-data';
import fs from 'fs';
import fs from 'fs-extra';
import got, { Progress } from 'got';
import md5File from 'md5-file';
import { Readable } from 'stream';
import { apiClient } from './api';
export enum UploadType {
TURTLE_PROJECT_SOURCES = 'turtle-project-sources',
SUBMISSION_APP_ARCHIVE = 'submission-app-archive',
}
import { UploadSessionType } from './graphql/generated';
import { PresignedPost, UploadSessionMutation } from './graphql/mutations/UploadSessionMutation';
type ProgressHandler = (props: {
progress?: Progress;
@@ -19,11 +14,11 @@ type ProgressHandler = (props: {
}) => void;
export async function uploadAsync(
uploadType: UploadType,
type: UploadSessionType,
path: string,
handleProgressEvent: ProgressHandler
): Promise<{ url: string; bucketKey: string }> {
const presignedPost = await obtainS3PresignedPostAsync(uploadType, path);
const presignedPost = await UploadSessionMutation.createUploadSession(type);
const url = await uploadWithPresignedPostAsync(
fs.createReadStream(path),
presignedPost,
@@ -33,27 +28,6 @@ export async function uploadAsync(
return { url, bucketKey: presignedPost.fields.key };
}
export interface PresignedPost {
url: string;
fields: Record<string, string>;
}
async function obtainS3PresignedPostAsync(
uploadType: UploadType,
filePath: string
): Promise<PresignedPost> {
const fileHash = await md5File(filePath);
const { data } = await apiClient
.post('upload-sessions', {
json: {
type: uploadType,
checksum: fileHash,
},
})
.json();
return data.presignedUrl;
}
export async function uploadWithPresignedPostAsync(
stream: Readable | Buffer,
presignedPost: PresignedPost,
+24
View File
@@ -1,5 +1,6 @@
import gql from 'graphql-tag';
import * as Analytics from '../analytics';
import { apiClient } from '../api';
import { graphqlClient } from '../graphql/client';
import { CurrentUserQuery } from '../graphql/generated';
@@ -13,10 +14,33 @@ export type Actor = NonNullable<CurrentUserQuery['meActor']>;
let currentUser: Actor | undefined;
/**
* Resolve the name of the actor, either normal user or robot user.
* This should be used whenever the "current user" needs to be displayed.
* The display name CANNOT be used as project owner.
*/
export function getActorDisplayName(user?: Actor): string {
switch (user?.__typename) {
case 'User':
return user.username;
case 'Robot':
return user.firstName ? `${user.firstName} (robot)` : 'robot';
default:
return 'anonymous';
}
}
export async function getUserAsync(): Promise<Actor | undefined> {
if (!currentUser && (getAccessToken() || getSessionSecret())) {
const user = await UserQuery.currentUserAsync();
currentUser = user ?? undefined;
if (user) {
await Analytics.setUserDataAsync(user.id, {
username: getActorDisplayName(user),
user_id: user.id,
user_type: user.__typename,
});
}
}
return currentUser;
}
@@ -7,6 +7,8 @@ const SETTINGS_FILE_PATH = path.join(getConfigDirectory(), 'user-settings.json')
export type UserSettingsData = {
appleId?: string;
amplitudeDeviceId?: string;
amplitudeEnabled?: boolean;
};
const UserSettings: JsonFile<UserSettingsData> = new JsonFile<UserSettingsData>(
@@ -2,7 +2,14 @@ import fs from 'fs-extra';
import { vol } from 'memfs';
import { getStateJsonPath } from '../../utils/paths';
import { getSessionSecret, getUserAsync, loginAsync, logoutAsync } from '../User';
import {
Actor,
getActorDisplayName,
getSessionSecret,
getUserAsync,
loginAsync,
logoutAsync,
} from '../User';
jest.mock('fs');
jest.mock('../../api', () => ({
@@ -34,6 +41,20 @@ beforeEach(() => {
vol.reset();
});
const userStub: Actor = {
__typename: 'User',
id: 'userId',
username: 'username',
accounts: [],
};
const robotStub: Actor = {
__typename: 'Robot',
id: 'userId',
firstName: 'GLaDOS',
accounts: [],
};
describe('getUserAsync', () => {
it('skips fetching user without access token or session secret', async () => {
expect(await getUserAsync()).toBeUndefined();
@@ -77,3 +98,21 @@ describe('logoutAsync', () => {
expect(getSessionSecret()).toBe(null);
});
});
describe('getActorDisplayName', () => {
it('returns anonymous for unauthenticated users', () => {
expect(getActorDisplayName()).toBe('anonymous');
});
it('returns username for user actors', () => {
expect(getActorDisplayName(userStub)).toBe(userStub.username);
});
it('returns firstName with robot prefix for robot actors', () => {
expect(getActorDisplayName(robotStub)).toBe(`${robotStub.firstName} (robot)`);
});
it('returns robot prefix only for robot actors without firstName', () => {
expect(getActorDisplayName({ ...robotStub, firstName: undefined })).toBe('robot');
});
});
@@ -1,7 +1,7 @@
import ApiV2Error from '../../ApiV2Error';
import { promptAsync } from '../../prompts';
import { Actor, loginAsync } from '../User';
import { ensureActorHasUsername, getActorDisplayName, showLoginPromptAsync } from '../actions';
import { ensureActorHasUsername, showLoginPromptAsync } from '../actions';
import { UserSecondFactorDeviceMethod, retryUsernamePasswordAuthWithOTPAsync } from '../otp';
jest.mock('../../prompts');
@@ -34,24 +34,6 @@ const robotStub: Actor = {
accounts: [],
};
describe('getActorDisplayName', () => {
it('returns anonymous for unauthenticated users', () => {
expect(getActorDisplayName()).toBe('anonymous');
});
it('returns username for user actors', () => {
expect(getActorDisplayName(userStub)).toBe(userStub.username);
});
it('returns firstName with robot prefix for robot actors', () => {
expect(getActorDisplayName(robotStub)).toBe(`${robotStub.firstName} (robot)`);
});
it('returns robot prefix only for robot actors without firstName', () => {
expect(getActorDisplayName({ ...robotStub, firstName: undefined })).toBe('robot');
});
});
describe('ensureActorHasUsername', () => {
it('returns username for user actors', () => {
expect(ensureActorHasUsername(userStub)).toBe(userStub.username);
-16
View File
@@ -55,22 +55,6 @@ export async function ensureLoggedInAsync(): Promise<Actor> {
return user;
}
/**
* Resolve the name of the actor, either normal user or robot user.
* This should be used whenever the "current user" needs to be displayed.
* The display name CANNOT be used as project owner.
*/
export function getActorDisplayName(user?: Actor): string {
switch (user?.__typename) {
case 'User':
return user.username;
case 'Robot':
return user.firstName ? `${user.firstName} (robot)` : 'robot';
default:
return 'anonymous';
}
}
export function ensureActorHasUsername(user: Actor): string {
if (user.__typename === 'User') {
return user.username;
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with the eas.json",
"version": "0.7.0",
"version": "0.8.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/eas-build-job": "0.2.21",
"@expo/eas-build-job": "0.2.22",
"@hapi/joi": "^17.1.1",
"fs-extra": "^9.0.1",
"tslib": "^1"
+4 -4
View File
@@ -18,7 +18,7 @@ export interface AndroidManagedBuildProfile extends Android.BuilderEnvironment {
buildType?: Android.ManagedBuildType;
releaseChannel?: string;
distribution?: AndroidDistributionType;
cache: Cache | null;
cache: Cache;
}
export interface AndroidGenericBuildProfile extends Android.BuilderEnvironment {
@@ -29,7 +29,7 @@ export interface AndroidGenericBuildProfile extends Android.BuilderEnvironment {
artifactPath?: string;
withoutCredentials?: boolean;
distribution?: AndroidDistributionType;
cache: Cache | null;
cache: Cache;
}
export interface iOSManagedBuildProfile extends Ios.BuilderEnvironment {
@@ -39,7 +39,7 @@ export interface iOSManagedBuildProfile extends Ios.BuilderEnvironment {
releaseChannel?: string;
distribution?: iOSDistributionType;
autoIncrement: VersionAutoIncrement;
cache: Cache | null;
cache: Cache;
}
export interface iOSGenericBuildProfile extends Ios.BuilderEnvironment {
@@ -51,7 +51,7 @@ export interface iOSGenericBuildProfile extends Ios.BuilderEnvironment {
artifactPath?: string;
distribution?: iOSDistributionType;
autoIncrement: VersionAutoIncrement;
cache: Cache | null;
cache: Cache;
disableIosBundleIdentifierValidation?: boolean;
}
+6 -5
View File
@@ -32,7 +32,8 @@ const IosBuilderEnvironmentSchema = Joi.object({
});
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([]),
});
@@ -49,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({
@@ -62,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({
@@ -76,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({
@@ -88,7 +89,7 @@ const iOSManagedSchema = Joi.object({
autoIncrement: Joi.alternatives()
.try(Joi.boolean(), Joi.string().valid('version', 'buildNumber'))
.default(false),
cache: CacheSchema.allow(null).default({ key: '', cacheDefaultPaths: true, customPaths: [] }),
cache: CacheSchema.default(),
}).concat(IosBuilderEnvironmentSchema);
const schemaBuildProfileMap: Record<string, Record<string, Joi.Schema>> = {
@@ -29,7 +29,7 @@ test('minimal valid android eas.json', async () => {
credentialsSource: 'auto',
env: {},
withoutCredentials: false,
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
},
},
@@ -55,7 +55,7 @@ test('minimal valid ios eas.json', async () => {
workflow: 'generic',
autoIncrement: false,
env: {},
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
schemeBuildConfiguration: 'Release',
},
@@ -85,7 +85,7 @@ test('minimal valid eas.json for both platforms', async () => {
credentialsSource: 'auto',
env: {},
withoutCredentials: false,
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
},
ios: {
@@ -94,7 +94,7 @@ test('minimal valid eas.json for both platforms', async () => {
credentialsSource: 'auto',
autoIncrement: false,
env: {},
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
schemeBuildConfiguration: 'Release',
},
@@ -124,7 +124,7 @@ test('valid eas.json with both platform, but reading only android', async () =>
credentialsSource: 'auto',
env: {},
withoutCredentials: false,
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
},
},
@@ -158,7 +158,7 @@ test('valid eas.json for development client builds', async () => {
distribution: 'store',
env: {},
image: 'default',
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
buildType: 'development-client',
},
ios: {
@@ -168,7 +168,7 @@ test('valid eas.json for development client builds', async () => {
autoIncrement: false,
env: {},
image: 'default',
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
buildType: 'development-client',
},
},
@@ -198,7 +198,7 @@ test('valid generic profile for internal distribution on Android', async () => {
gradleCommand: ':app:assembleRelease',
env: {},
withoutCredentials: false,
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
},
},
@@ -227,7 +227,7 @@ test('valid managed profile for internal distribution on Android', async () => {
distribution: 'internal',
credentialsSource: 'auto',
env: {},
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
cache: { disabled: false, cacheDefaultPaths: true, customPaths: [] },
image: 'default',
},
},
+36 -9
View File
@@ -2,6 +2,38 @@
# yarn lockfile v1
"@amplitude/identify@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@amplitude/identify/-/identify-1.5.0.tgz#6d31aaae615393867328abc6fcad037e9d1639e8"
integrity sha512-GCDxvwZvfFInZQ6m5LYnb9sVmJP+n+Re6vkBdtclp9uTNVPpdsXtdw2ann5Ts5pV10fivDpgdZ3OHehSxe742A==
dependencies:
"@amplitude/types" "^1.5.0"
"@amplitude/utils" "^1.5.0"
tslib "^1.9.3"
"@amplitude/node@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@amplitude/node/-/node-1.5.0.tgz#23e1650fed6302696b669dd8ceea0164b73f220e"
integrity sha512-/jfmYHanhln/OAuL8QG76EjThnaRDnfxLxO4oYstMFZKRktnUahE5i0sH02+tJ7I2nzq+4KTYY2hc8Rvi+Vt4Q==
dependencies:
"@amplitude/identify" "^1.5.0"
"@amplitude/types" "^1.5.0"
"@amplitude/utils" "^1.5.0"
tslib "^1.9.3"
"@amplitude/types@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@amplitude/types/-/types-1.5.0.tgz#cf34d0f304558d8b0d847a6217d506e6226e653c"
integrity sha512-XspuOsUzUcxwAptHeGiIn4giuLWs285xTJa7h8kAEEynxtEI3/krWCoDYZSB9PekaPXB6phxiO/tMd9t5V9LgQ==
"@amplitude/utils@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@amplitude/utils/-/utils-1.5.0.tgz#b48c3233e12f2c04880dd98f3c2628fd0976d3cb"
integrity sha512-1DrDJkb4dVX+FiBXhGpO2Dn2cRKdP+gtrVR8vZcE8wz/V2XxUI3DDx7uQbIS6WbQf6swv6Uo2eMHYtrwebostw==
dependencies:
"@amplitude/types" "^1.5.0"
tslib "^1.9.3"
"@ardatan/[email protected]":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@ardatan/aggregate-error/-/aggregate-error-0.0.6.tgz#fe6924771ea40fc98dc7a7045c2e872dc8527609"
@@ -1151,10 +1183,10 @@
xcode "^3.0.0"
xml-js "^1.6.11"
"@expo/[email protected]1":
version "0.2.21"
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-0.2.21.tgz#86c4d69d36dc4efc7a23fea97c8d6a73f27295de"
integrity sha512-H0yNXmeiLQ382gqSRBbAruew6ptmM3TPzWNPXc9yIxj0dPLOBhezu2LV7H+n1BcNp15aWeZIRb4e95vs4iy78Q==
"@expo/[email protected]2":
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"