Compare commits

..
30 Commits
Author SHA1 Message Date
Wojciech Kozyra d92a2ae3f4 v0.7.0 2021-03-22 13:33:34 +01:00
Dominik Sokal b7dea35da4 better message when build was canceled 2021-03-22 12:02:08 +01:00
Wojciech Kozyra 5a5f11752d Update @expo/eas-build-job 2021-03-22 12:02:08 +01:00
Wojciech Kozyra b6c300b5c2 [eas-json] Detect dependency cycle for build profiles (#283)
* [eas-json] Detect dependency cycle for build profiles

* Update CHANGELOG.md

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

* add secrets: list command

* add create command

* list all secrets

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

* update changelog

* fix typo

* use target enum

* use US cancel

* move AccountResolver to generic place

* secret isn't reassigned

* check for eas in delete

* make mutation inputs required

* update generated graphql files

* use findAccountByName directly instead of AccountResolver

* update list format

* move accountresolver back into device manager file

* restore old devicemanager source

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

* update CHANGELOG.md

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

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

* update CHANGELOG

* address PR feedback

* address more PR feedback
2021-03-12 10:29:33 +01:00
jkh d0818932e7 [eas-cli] add --republish and --group flags to branch:publish command. (#253) 2021-03-11 05:37:18 -08:00
Dominik Sokal 23b60ed385 fix typo 2021-03-09 11:08:39 +01:00
Dominik Sokal 5d2811bf21 update CHANGELOG 2021-03-09 11:05:14 +01:00
Dominik Sokal 7b202f2aa7 v0.6.0 2021-03-09 10:42:59 +01:00
Dominik SokalandWojciech Kozyra fa6e9c1735 [ENG-444] support internal distribution in non-interactive builds (#269)
* support internal distribution in non-interactive builds

* update CHANGELOG.md

* address PR feedback

* Update packages/eas-cli/src/credentials/ios/actions/new/SetupAdhocProvisioningProfile.ts

Co-authored-by: Wojciech Kozyra <[email protected]>

* address more PR feedback

* update error message

Co-authored-by: Wojciech Kozyra <[email protected]>
2021-03-09 10:40:52 +01:00
Wojciech KozyraandDominik Sokal c3300036d8 [eas-cli] handle unknown team name in device list (#268)
* [eas-cli] handle unknown team name in device list

* Update CHANGELOG.md

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

Co-authored-by: Dominik Sokal <[email protected]>
2021-03-05 14:05:06 +01:00
Dominik Sokal 5efcdb2940 [eas-cli] use Metadata type from @expo/eas-build-job (#267) 2021-03-05 09:43:23 +01:00
Wojciech Kozyra 8113450ea4 [eas-json] add experimental.disableIosBundleIdentifierValidation flag (#263) 2021-03-04 15:12:45 +01:00
Dominik Sokal d10b0c3c65 iOS builds: default to build configuration 'Release' (#266)
* iOS builds: default to build configuration 'Release'

* update CHANGELOG.md

* fix tests
2021-03-04 10:25:59 +01:00
Bartosz Kaszubowski e8de0e8712 add installation section to eas-cli readme (#255) 2021-03-03 16:53:50 -08:00
Bartłomiej Klocek e1da1099da [eas submit] UX improvements (#144)
* Add finish message for iOS Submissions

* Improve summary table

* Improve table look

* Update CHANGELOG
2021-03-03 16:57:08 +01:00
Dominik Sokal 264aa8fefa [ENG-326] send more build metadata: release channel, build profile, git commit hash (#265)
* send more build metadata: release channel, build profile, git commit hash

* update CHANGELOG.md

* address PR feedback

* upgrade @expo/config-plugins 1.0.19-alpha.0 -> 1.0.21-alpha.0

* address PR feedback
2021-03-03 16:00:59 +01:00
jkh 941d0294b7 [eas-cli] use generated graphql types (#262) 2021-03-03 06:53:54 -08:00
Wojciech Kozyra 366f4e8914 [eas-cli] display size of the archive (#264) 2021-03-03 14:11:31 +01:00
Dominik Sokal 1460c3c5a2 add first item to the PR checklist (#260) 2021-03-03 09:16:37 +01:00
jkh 6271472827 [eas-cli] add branch:delete command (#248) 2021-03-02 09:43:53 -08:00
Dominik Sokal 3bc4a054ae update CHANGELOG.md 2021-03-02 11:35:19 +01:00
81 changed files with 3171 additions and 1718 deletions

No files matched your search

+6
View File
@@ -1,3 +1,9 @@
<!-- Thanks for contributing to _EAS CLI_! Before you submit your pull request, please make sure to check the following boxes by putting an x in the [ ] (don't: [x ], [ x], do: [x]) -->
# Checklist
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary.
# Why
Please describe the motivation for this PR, and link to relevant GitHub issues, forums posts, or feature requests.
+35
View File
@@ -8,6 +8,41 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Print common error messages when builds fail. ([#272](https://github.com/expo/eas-cli/pull/272) by [@dsokal](https://github.com/dsokal))
- Commit automatically if `EAS_BUILD_AUTOCOMMIT` is set. ([#271](https://github.com/expo/eas-cli/pull/271) by [@wkozyra95](https://github.com/wkozyra95))
- Allow for installing custom `bundler` version on EAS Build. ([#277](https://github.com/expo/eas-cli/pull/277) by [@dsokal](https://github.com/dsokal))
- Add support for managing environment secrets. ([#275](https://github.com/expo/eas-cli/pull/275) by [@fiberjw](https://github.com/fiberjw))
### 🐛 Bug fixes
- Fix `eas submit` local archive prompt for `.aab` files when submitting for iOS. ([#273](https://github.com/expo/eas-cli/pull/273) by [@barthap](https://github.com/barthap))
- Verify whether "name" field in app.json contains any alphanumeric characters. ([#280](https://github.com/expo/eas-cli/pull/280) by [@wkozyra95](https://github.com/wkozyra95))
- Detect dependency cycles in eas.json build profiles. ([#283](https://github.com/expo/eas-cli/pull/283) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
## [0.6.0](https://github.com/expo/eas-cli/releases/tag/v0.6.0) - 2021-03-09
### 🛠 Breaking changes
- Generic iOS projects: build release builds by default. ([#266](https://github.com/expo/eas-cli/pull/266) by [@dsokal](https://github.com/dsokal))
### 🎉 New features
- Log the size of the archived project when uploading. ([#264](https://github.com/expo/eas-cli/pull/264) by [@wkozyra95](https://github.com/wkozyra95))
- Add more build metadata (release channel, build profile name, git commit hash). ([#265](https://github.com/expo/eas-cli/pull/265) by [@dsokal](https://github.com/dsokal))
- Display App Store link after successful submission. ([#144](https://github.com/expo/eas-cli/pull/144) by [@barthap](https://github.com/barthap))
- Add `experimental.disableIosBundleIdentifierValidation` flag to eas.json. ([#263](https://github.com/expo/eas-cli/pull/263) by [@wkozyra95](https://github.com/wkozyra95))
- Support internal distribution in non-interactive builds. ([#269](https://github.com/expo/eas-cli/pull/269) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Print Apple Team ID in the output of `device:list` when the team name is unknown. ([#268](https://github.com/expo/eas-cli/pull/268) by [@wkozyra95](https://github.com/wkozyra95))
## [0.5.0](https://github.com/expo/eas-cli/releases/tag/v0.5.0) - 2021-03-02
### 🎉 New features
- Add support for build cache. ([#247](https://github.com/expo/eas-cli/pull/247) by [@wkozyra95](https://github.com/wkozyra95))
- Enable internal distribution support for self-managed credentials. ([#256](https://github.com/expo/eas-cli/pull/256) by [@dsokal](https://github.com/dsokal))
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.5.0"
"version": "0.7.0"
}
+42 -30
View File
@@ -7,27 +7,28 @@ EAS command line tool
[![Downloads/week](https://img.shields.io/npm/dw/eas-cli.svg)](https://npmjs.org/package/eas-cli)
[![License](https://img.shields.io/npm/l/eas-cli.svg)](https://github.com/expo/eas-cli/blob/main/package.json)
<!-- toc -->
* [eas-cli](#eas-cli)
* [Installation](#installation)
* [Usage](#usage)
* [Commands](#commands)
<!-- tocstop -->
# Installation
```sh
npm install -g eas-cli
# or
yarn global add eas-cli
```
# Usage
<!-- usage -->
```sh-session
$ npm install -g eas-cli
$ eas COMMAND
running command...
$ eas (-v|--version|version)
eas-cli/0.5.0 darwin-x64 node-v14.15.4
$ eas --help [COMMAND]
USAGE
$ eas COMMAND
...
```sh
eas COMMAND
# runs the command
eas (-v|--version|version)
# prints the version
eas --help COMMAND
# outputs help for specific command
```
<!-- usagestop -->
# Commands
@@ -45,6 +46,7 @@ USAGE
* [`eas device:list`](#eas-devicelist)
* [`eas device:view [UDID]`](#eas-deviceview-udid)
* [`eas help [COMMAND]`](#eas-help-command)
* [`eas secrets:list`](#eas-secretslist)
* [`eas submit --platform=(android|ios)`](#eas-submit---platformandroidios)
## `eas account:login`
@@ -59,7 +61,7 @@ ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/account/login.ts)_
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/account/login.ts)_
## `eas account:logout`
@@ -73,7 +75,7 @@ ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/account/logout.ts)_
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/account/logout.ts)_
## `eas account:view`
@@ -87,7 +89,7 @@ ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/account/view.ts)_
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/account/view.ts)_
## `eas build`
@@ -106,7 +108,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.5.0/build/commands/build/index.ts)_
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -117,7 +119,7 @@ USAGE
$ eas build:cancel [BUILD_ID]
```
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/build/cancel.ts)_
_See code: [build/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/cancel.ts)_
## `eas build:configure`
@@ -132,7 +134,7 @@ OPTIONS
--allow-experimental Enable experimental configuration steps.
```
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/build/configure.ts)_
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/configure.ts)_
## `eas build:list`
@@ -148,7 +150,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.5.0/build/commands/build/list.ts)_
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/list.ts)_
## `eas build:view [BUILD_ID]`
@@ -159,7 +161,7 @@ USAGE
$ eas build:view [BUILD_ID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/build/view.ts)_
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/build/view.ts)_
## `eas credentials`
@@ -170,7 +172,7 @@ USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/credentials.ts)_
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/credentials.ts)_
## `eas device:create`
@@ -181,7 +183,7 @@ USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/device/create.ts)_
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/device/create.ts)_
## `eas device:list`
@@ -195,7 +197,7 @@ OPTIONS
--apple-team-id=apple-team-id
```
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/device/list.ts)_
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/device/list.ts)_
## `eas device:view [UDID]`
@@ -206,7 +208,7 @@ USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/device/view.ts)_
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -225,6 +227,17 @@ OPTIONS
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
## `eas secrets:list`
Lists environment secrets available for your current app
```
USAGE
$ eas secrets:list
```
_See code: [build/commands/secrets/list.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/secrets/list.ts)_
## `eas submit --platform=(android|ios)`
Submits build artifact to app store
@@ -249,8 +262,7 @@ OPTIONS
iOS specific options=asc-app-id App Store Connect unique application Apple ID
number. Providing this param results in skipping
app creation step. Learn more:
https://expo.fyi/asc-app-id
app creation step. Learn more.
iOS specific options=bundle-identifier Your iOS Bundle Identifier (default:
expo.ios.bundleIdentifier from app config)
@@ -303,5 +315,5 @@ EXAMPLES
and provide its App ID
```
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.5.0/build/commands/submit.ts)_
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.7.0/build/commands/submit.ts)_
<!-- commandsstop -->
+1
View File
@@ -3,6 +3,7 @@ schema: 'https://staging.exp.host/--/graphql'
documents:
- 'src/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/commands/**/*.ts'
generates:
src/graphql/generated.ts:
plugins:
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.5.0",
"version": "0.7.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -10,9 +10,9 @@
"dependencies": {
"@expo/apple-utils": "0.0.0-alpha.17",
"@expo/config": "~3.3.19",
"@expo/config-plugins": "1.0.19-alpha.0",
"@expo/eas-build-job": "0.2.14",
"@expo/eas-json": "^0.5.0",
"@expo/config-plugins": "1.0.21-alpha.0",
"@expo/eas-build-job": "0.2.21",
"@expo/eas-json": "^0.7.0",
"@expo/json-file": "^8.2.24",
"@expo/plist": "^0.0.11",
"@expo/plugin-warn-if-update-available": "^1.7.0",
@@ -90,3 +90,21 @@ async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig)
throw new Error('Missing values in AndroidManifest.xml');
}
}
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
androidManifestPath
);
return AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidConfig.Updates.Config.RELEASE_CHANNEL
);
} catch (err) {
return null;
}
}
@@ -24,8 +24,12 @@ function isApplicationIdValid(applicationId: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(applicationId);
}
export async function ensureApplicationIdIsValidAsync(projectDir: string) {
const applicationId = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.ANDROID);
export async function ensureApplicationIdIsValidAsync(projectDir: string, exp: ExpoConfig) {
const applicationId = await ensureAppIdentifierIsDefinedAsync({
projectDir,
platform: Platform.ANDROID,
exp,
});
if (!isApplicationIdValid(applicationId)) {
const configDescription = getProjectConfigDescription(projectDir);
Log.error(
+1 -1
View File
@@ -60,7 +60,7 @@ This means that it will most likely produce an AAB and you will not be able to i
}
}
await ensureApplicationIdIsValidAsync(commandCtx.projectDir);
await ensureApplicationIdIsValidAsync(commandCtx.projectDir, commandCtx.exp);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
@@ -15,7 +15,7 @@ export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void
}
await AndroidConfig.EasBuild.configureEasBuildAsync(ctx.projectDir);
await configureApplicationIdAsync(ctx.projectDir, ctx.exp, ctx.allowExperimental);
await ensureApplicationIdIsValidAsync(ctx.projectDir);
await ensureApplicationIdIsValidAsync(ctx.projectDir, ctx.exp);
const easGradlePath = AndroidConfig.EasBuild.getEasBuildGradlePath(ctx.projectDir);
await gitAddAsync(easGradlePath, { intentToAdd: true });
@@ -114,7 +114,7 @@ async function prepareManagedJobAsync(
): Promise<Partial<Android.ManagedJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.exp);
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: Workflow.MANAGED,
+12 -2
View File
@@ -6,6 +6,7 @@ import { apiClient } from '../api';
import Log from '../log';
import { promptAsync } from '../prompts';
import { UploadType, uploadAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import { platformDisplayNames } from './constants';
import { BuildContext } from './context';
@@ -14,6 +15,7 @@ import { AnalyticsEvent, Platform, TrackingContext } from './types';
import Analytics from './utils/analytics';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
commitChangedFilesAsync,
commitPromptAsync,
isGitStatusCleanAsync,
makeProjectTarballAsync,
@@ -135,7 +137,10 @@ async function uploadProjectAsync<TPlatform extends Platform>(
projectTarball.path,
createProgressTracker({
total: projectTarball.size,
message: 'Uploading to EAS Build',
message: ratio =>
`Uploading to EAS Build (${formatBytes(projectTarball.size * ratio)} / ${formatBytes(
projectTarball.size
)})`,
completedMessage: `Uploaded to EAS`,
})
);
@@ -185,9 +190,14 @@ async function reviewAndCommitChangesAsync(
initialCommitMessage: string,
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
): Promise<void> {
if (process.env.EAS_BUILD_AUTOCOMMIT) {
await commitChangedFilesAsync(initialCommitMessage);
Log.withTick('Committed changes.');
return;
}
if (nonInteractive) {
throw new Error(
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode to review and commit changes.'
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode or set EAS_BUILD_AUTOCOMMIT=1 in your environment.'
);
}
const { selected } = await promptAsync({
+3 -2
View File
@@ -1,4 +1,4 @@
import { ExpoConfig, getConfig } from '@expo/config';
import { ExpoConfig } from '@expo/config';
import { AndroidBuildProfile, EasConfig, iOSBuildProfile } from '@expo/eas-json';
import { getProjectAccountName } from '../project/projectUtils';
@@ -25,6 +25,7 @@ export interface CommandContext {
export async function createCommandContextAsync({
requestedPlatform,
profile,
exp,
projectDir,
projectId,
trackingCtx,
@@ -35,6 +36,7 @@ export async function createCommandContextAsync({
}: {
requestedPlatform: RequestedPlatform;
profile: string;
exp: ExpoConfig;
projectId: string;
projectDir: string;
trackingCtx: TrackingContext;
@@ -44,7 +46,6 @@ export async function createCommandContextAsync({
waitForBuildEnd: boolean;
}): Promise<CommandContext> {
const user = await ensureLoggedInAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = getProjectAccountName(exp, user);
const projectName = exp.slug;
+15 -3
View File
@@ -26,8 +26,8 @@ export async function buildAsync(commandCtx: CommandContext): Promise<void> {
commandCtx,
scheduledBuilds.map(i => i.buildId)
);
Log.newLine();
printBuildResults(commandCtx.accountName, builds);
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
}
}
@@ -88,7 +88,8 @@ async function waitForBuildEndAsync(
})
);
if (builds.length === 1) {
switch (builds[0]?.status) {
const build = builds[0] as Build;
switch (build.status) {
case BuildStatus.FINISHED:
spinner.succeed('Build finished');
return builds;
@@ -104,7 +105,11 @@ async function waitForBuildEndAsync(
break;
case BuildStatus.ERRORED:
spinner.fail('Build failed');
throw new Error(`Standalone build failed!`);
if (build.error) {
return builds;
} else {
throw new Error(`Standalone build failed!`);
}
default:
spinner.warn('Unknown status.');
throw new Error(`Unknown status: ${builds} - aborting!`);
@@ -151,3 +156,10 @@ async function waitForBuildEndAsync(
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
);
}
function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (Build | null)[]): void {
const failedBuilds = (maybeBuilds.filter(i => i) as Build[]).filter(i => i.status === 'errored');
if (failedBuilds.length > 0) {
process.exit(1);
}
}
@@ -86,3 +86,12 @@ async function writeExpoPlistAsync(
await writePlistAsync(expoPlistPath, expoPlist);
await gitAddAsync(expoPlistPath, { intentToAdd: true });
}
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const expoPlist = await readExpoPlistAsync(projectDir);
return expoPlist[IOSConfig.Updates.Config.RELEASE_CHANNEL] ?? null;
} catch (err) {
return null;
}
}
+10 -2
View File
@@ -14,7 +14,7 @@ import { Platform } from '../types';
import { ensureBundleIdentifierIsValidAsync } from './bundleIdentifer';
import { validateAndSyncProjectConfigurationAsync } from './configure';
import { ensureIosCredentialsAsync } from './credentials';
import { prepareJobAsync } from './prepareJob';
import { prepareJobAsync, sanitizedTargetName } from './prepareJob';
export async function prepareIosBuildAsync(
commandCtx: CommandContext,
@@ -45,9 +45,17 @@ export async function prepareIosBuildAsync(
buildCtx.commandCtx.projectDir,
iosBuildScheme
);
} else {
if (!commandCtx.exp.name) {
throw new Error('"expo.name" is required in your app.json');
}
iosApplicationTarget = sanitizedTargetName(commandCtx.exp.name);
if (!iosApplicationTarget) {
throw new Error('"expo.name" needs to contain some alphanumeric characters');
}
}
await ensureBundleIdentifierIsValidAsync(commandCtx.projectDir);
await ensureBundleIdentifierIsValidAsync(commandCtx.projectDir, commandCtx.exp);
return await prepareBuildRequestForPlatformAsync({
ctx: buildCtx,
@@ -21,8 +21,12 @@ function isBundleIdentifierValid(bundleIdentifier: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(bundleIdentifier);
}
export async function ensureBundleIdentifierIsValidAsync(projectDir: string) {
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.IOS);
export async function ensureBundleIdentifierIsValidAsync(projectDir: string, exp: ExpoConfig) {
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync({
projectDir,
platform: Platform.IOS,
exp,
});
if (!isBundleIdentifierValid(bundleIdentifier)) {
const configDescription = getProjectConfigDescription(projectDir);
Log.error(
+26 -9
View File
@@ -1,7 +1,12 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import { Workflow } from '@expo/eas-build-job';
import { VersionAutoIncrement, iOSBuildProfile } from '@expo/eas-json';
import {
EasJsonReader,
VersionAutoIncrement,
iOSBuildProfile,
iOSGenericBuildProfile,
} from '@expo/eas-json';
import Log from '../../log';
import { ConfigureContext } from '../context';
@@ -17,8 +22,14 @@ export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasIosNativeProject) {
return;
}
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
await ensureBundleIdentifierIsValidAsync(ctx.projectDir);
const disableIosBundleIdentifierValidation = (
await new EasJsonReader(ctx.projectDir, 'all').readRawAsync()
)?.experimental?.disableIosBundleIdentifierValidation;
if (!disableIosBundleIdentifierValidation) {
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
await ensureBundleIdentifierIsValidAsync(ctx.projectDir, ctx.exp);
}
if (isExpoUpdatesInstalled(ctx.projectDir)) {
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
@@ -39,13 +50,19 @@ export async function validateAndSyncProjectConfigurationAsync({
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement);
if (workflow === Workflow.GENERIC) {
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(
projectDir
);
if (!bundleIdentifierFromPbxproj || bundleIdentifierFromPbxproj !== exp.ios?.bundleIdentifier) {
throw new Error(
'Bundle identifier is not configured correctly in your Xcode project. Please run "eas build:configure" to configure it.'
const genericBuildProfile = buildProfile as iOSGenericBuildProfile;
if (!genericBuildProfile.disableIosBundleIdentifierValidation) {
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(
projectDir
);
if (
!bundleIdentifierFromPbxproj ||
bundleIdentifierFromPbxproj !== exp.ios?.bundleIdentifier
) {
throw new Error(
'Bundle identifier is not configured correctly in your Xcode project. Please run "eas build:configure" to configure it.'
);
}
}
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
+8 -5
View File
@@ -96,6 +96,7 @@ async function prepareJobCommonAsync(
image: ctx.buildProfile.image,
node: ctx.buildProfile.node,
yarn: ctx.buildProfile.yarn,
bundler: ctx.buildProfile.bundler,
cocoapods: ctx.buildProfile.cocoapods,
fastlane: ctx.buildProfile.fastlane,
env: ctx.buildProfile.env,
@@ -133,7 +134,10 @@ async function prepareGenericJobAsync(
})),
type: Workflow.GENERIC,
scheme: jobData.projectConfiguration.iosBuildScheme,
schemeBuildConfiguration: buildProfile.schemeBuildConfiguration,
schemeBuildConfiguration:
buildProfile.schemeBuildConfiguration === 'Auto'
? undefined
: buildProfile.schemeBuildConfiguration,
artifactPath: buildProfile.artifactPath,
releaseChannel: buildProfile.releaseChannel,
projectRootDirectory,
@@ -147,13 +151,12 @@ async function prepareManagedJobAsync(
): Promise<Partial<Ios.ManagedJob>> {
const projectRootDirectory =
path.relative(await gitRootDirectoryAsync(), ctx.commandCtx.projectDir) || '.';
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
const targetName = sanitizedName(ctx.commandCtx.exp.name);
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.exp);
return {
...(await prepareJobCommonAsync(ctx, {
archiveBucketKey: jobData.archiveBucketKey,
credentials: jobData.credentials,
targetName,
targetName: jobData.projectConfiguration.iosApplicationTarget,
})),
type: Workflow.MANAGED,
buildType: buildProfile.buildType,
@@ -165,7 +168,7 @@ async function prepareManagedJobAsync(
// copy-pasted from expo-cli/packages/xdl/src/Exp.ts
// it's used in eject
function sanitizedName(name: string) {
export function sanitizedTargetName(name: string) {
return name
.replace(/[\W_]+/g, '')
.normalize('NFD')
+36 -4
View File
@@ -1,8 +1,13 @@
import { Metadata } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import { getAppIdentifierAsync } from '../project/projectUtils';
import { gitCommitHashAsync } from '../utils/git';
import { readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync } from './android/UpdatesModule';
import { BuildContext } from './context';
import { BuildMetadata, Platform } from './types';
import { readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync } from './ios/UpdatesModule';
import { Platform } from './types';
import { isExpoUpdatesInstalled } from './utils/updates';
/**
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
@@ -18,17 +23,44 @@ export async function collectMetadata<T extends Platform>(
}: {
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
}
): Promise<BuildMetadata> {
): Promise<Metadata> {
return {
trackingContext: ctx.trackingCtx,
appVersion: ctx.commandCtx.exp.version!,
cliVersion: packageJSON.version,
workflow: ctx.buildProfile.workflow,
credentialsSource,
sdkVersion: ctx.commandCtx.exp.sdkVersion,
trackingContext: ctx.trackingCtx,
releaseChannel: await resolveReleaseChannel(ctx),
distribution: ctx.buildProfile.distribution ?? 'store',
appName: ctx.commandCtx.exp.name,
appIdentifier:
(await getAppIdentifierAsync(ctx.commandCtx.projectDir, ctx.platform)) ?? undefined,
(await getAppIdentifierAsync({
projectDir: ctx.commandCtx.projectDir,
platform: ctx.platform,
exp: ctx.commandCtx.exp,
})) ?? undefined,
buildProfile: ctx.commandCtx.profile,
gitCommitHash: await gitCommitHashAsync(),
};
}
async function resolveReleaseChannel<T extends Platform>(
ctx: BuildContext<T>
): Promise<string | undefined> {
if (!isExpoUpdatesInstalled(ctx.commandCtx.projectDir)) {
return undefined;
}
if (ctx.buildProfile.releaseChannel) {
return ctx.buildProfile.releaseChannel;
}
let maybeReleaseChannel: string | null;
if (ctx.platform === Platform.ANDROID) {
maybeReleaseChannel = await readAndroidReleaseChannelSafelyAsync(ctx.commandCtx.projectDir);
} else {
maybeReleaseChannel = await readIosReleaseChannelSafelyAsync(ctx.commandCtx.projectDir);
}
return maybeReleaseChannel ?? 'default';
}
+4 -69
View File
@@ -1,10 +1,5 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import {
AndroidBuildProfile,
CredentialsSource,
DistributionType,
iOSBuildProfile,
} from '@expo/eas-json';
import { Metadata, Platform, errors } from '@expo/eas-build-job';
import { AndroidBuildProfile, iOSBuildProfile } from '@expo/eas-json';
export enum RequestedPlatform {
Android = 'android',
@@ -31,7 +26,8 @@ export interface Build {
createdAt: string;
updatedAt: string;
artifacts?: BuildArtifacts;
metadata?: Partial<BuildMetadata>;
metadata?: Partial<Metadata>;
error?: errors.ExternalUserError;
}
interface BuildArtifacts {
@@ -39,67 +35,6 @@ interface BuildArtifacts {
logsUrl: string;
}
export type BuildMetadata = {
/**
* Application version (the expo.version key in app.json/app.config.js)
*/
appVersion: string;
/**
* EAS CLI version
*/
cliVersion: string;
/**
* Build workflow
* It's either 'generic' or 'managed'
*/
workflow: Workflow;
/**
* Credentials source
* Credentials could be obtained either from credential.json or EAS servers.
*/
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
/**
* Expo SDK version
* It's determined by the expo package version in package.json.
* It's undefined if the expo package is not installed for the project.
*/
sdkVersion?: string;
/**
* Release channel (for expo-updates)
* It's undefined if the expo-updates package is not installed for the project.
*/
releaseChannel?: string;
/**
* Tracking context
* It's used to track build process across different Expo services and tools.
*/
trackingContext: TrackingContext;
/**
* Distribution type
* Indicates whether this is a build for store, internal distribution, or simulator (iOS).
*/
distribution: DistributionType;
/**
* App name (expo.name in app.json/app.config.js)
*/
appName?: string;
/**
* App identifier:
* - iOS builds: the bundle identifier (expo.ios.bundleIdentifier in app.json/app.config.js)
* - Android builds: the application id (expo.android.package in app.json/app.config.js)
*/
appIdentifier?: string;
};
export type PlatformBuildProfile<T extends Platform> = T extends Platform.ANDROID
? AndroidBuildProfile
: iOSBuildProfile;
@@ -1,7 +1,8 @@
import { errors } from '@expo/eas-build-job';
import assert from 'assert';
import chalk from 'chalk';
import Log from '../../log';
import Log, { learnMore } from '../../log';
import { platformDisplayNames, platformEmojis } from '../constants';
import { Build } from '../types';
import { getBuildLogsUrl } from './url';
@@ -34,18 +35,37 @@ export function printLogsUrls(
}
export function printBuildResults(accountName: string, builds: (Build | null)[]): void {
Log.newLine();
if (builds.length === 1) {
const [build] = builds;
assert(build, 'Build should be defined');
printBuildResult(accountName, build);
} else {
(builds.filter(i => i) as Build[])
.filter(build => build.status === 'finished')
.forEach(build => printBuildResult(accountName, build));
(builds.filter(i => i) as Build[]).forEach(build => printBuildResult(accountName, build));
}
}
function printBuildResult(accountName: string, build: Build): void {
Log.addNewLineIfNone();
if (build.status === 'errored') {
const userError = build.error;
Log.error(
`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} build failed${
userError ? ':' : ''
}`
);
if (userError) {
printUserError(userError);
}
return;
}
if (build.status === 'canceled') {
Log.error(
`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} build was canceled`
);
return;
}
if (build.metadata?.distribution === 'internal') {
const logsUrl = getBuildLogsUrl({
buildId: build.id,
@@ -57,14 +77,12 @@ function printBuildResult(accountName: string, build: Build): void {
} devices to install the app:`
);
Log.log(`${chalk.underline(logsUrl)}`);
Log.newLine();
} else {
// TODO: it looks like buildUrl could possibly be undefined, based on the code below.
// we should account for this case better if it is possible
const url = build.artifacts?.buildUrl ?? '';
Log.log(`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} app:`);
Log.log(`${chalk.underline(url)}`);
Log.newLine();
}
}
@@ -87,3 +105,10 @@ export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): voi
Log.warn(deprecationInfo);
}
}
export function printUserError(error: errors.ExternalUserError): void {
Log.error(error.message);
if (error.docsUrl) {
Log.error(learnMore(error.docsUrl, { dim: false }));
}
}
+15 -8
View File
@@ -8,6 +8,7 @@ import { v4 as uuidv4 } from 'uuid';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { formatBytes } from '../../utils/files';
import {
doesGitRepoExistAsync,
getGitDiffOutputAsync,
@@ -119,7 +120,6 @@ async function makeProjectTarballAsync(): Promise<{ path: string; size: number }
try {
await spawnAsync('git', [
'clone',
'--local',
'--no-hardlinks',
'--depth',
'1',
@@ -129,19 +129,26 @@ async function makeProjectTarballAsync(): Promise<{ path: string; size: number }
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.',
]);
} finally {
// Stop the timer
} catch (err) {
clearTimeout(timer);
await fs.remove(shallowClonePath);
const duration = endTimer(compressTimerLabel);
if (spinner.isSpinning) {
const prettyTime = formatMilliseconds(duration);
spinner.succeed(`Compressed project files ${chalk.dim(prettyTime)}`);
spinner.fail();
}
throw err;
} finally {
await fs.remove(shallowClonePath);
}
clearTimeout(timer);
const { size } = await fs.stat(tarPath);
const duration = endTimer(compressTimerLabel);
if (spinner.isSpinning) {
const prettyTime = formatMilliseconds(duration);
spinner.succeed(
`Compressed project files ${chalk.dim(`${prettyTime} (${formatBytes(size)})`)}`
);
}
return { size, path: tarPath };
}
+13 -11
View File
@@ -4,7 +4,11 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateBranch } from '../../graphql/generated';
import {
CreateUpdateBranchForAppMutation,
CreateUpdateBranchForAppMutationVariables,
UpdateBranch,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
@@ -15,16 +19,10 @@ import { getBranchNameAsync } from '../../utils/git';
export async function createUpdateBranchOnAppAsync({
appId,
name,
}: {
appId: string;
name: string;
}): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const data = await withErrorHandlingAsync(
}: CreateUpdateBranchForAppMutationVariables): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateBranch: { createUpdateBranchForApp: Pick<UpdateBranch, 'id' | 'name'> } },
{ appId: string; name: string }
>(
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
gql`
mutation createUpdateBranchForApp($appId: ID!, $name: String!) {
updateBranch {
@@ -42,7 +40,11 @@ export async function createUpdateBranchOnAppAsync({
)
.toPromise()
);
return data.updateBranch.createUpdateBranchForApp;
const newBranch = result.updateBranch.createUpdateBranchForApp;
if (!newBranch) {
throw new Error(`Could not create branch ${name}.`);
}
return newBranch;
}
export default class BranchCreate extends Command {
@@ -0,0 +1,156 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateBranchMutation,
DeleteUpdateBranchMutationVariables,
DeleteUpdateBranchResult,
GetBranchInfoQuery,
GetBranchInfoQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
async function getBranchInfoAsync({
appId,
name,
}: GetBranchInfoQueryVariables): Promise<{ branchId: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GetBranchInfoQuery, GetBranchInfoQueryVariables>(
gql`
query GetBranchInfo($appId: String!, $name: String!) {
app {
byId(appId: $appId) {
id
updateBranchByName(name: $name) {
id
name
}
}
}
}
`,
{
appId,
name,
}
)
.toPromise()
);
const branchId = data.app?.byId.updateBranchByName.id;
if (!branchId) {
throw new Error(`Could not find branch ${name} on ${appId}`);
}
return { branchId };
}
async function deleteBranchOnAppAsync({
branchId,
}: DeleteUpdateBranchMutationVariables): Promise<DeleteUpdateBranchResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateBranchMutation, DeleteUpdateBranchMutationVariables>(
gql`
mutation DeleteUpdateBranch($branchId: ID!) {
updateBranch {
deleteUpdateBranch(branchId: $branchId) {
id
}
}
}
`,
{
branchId,
}
)
.toPromise()
);
return data.updateBranch.deleteUpdateBranch;
}
export default class BranchDelete extends Command {
static hidden = true;
static description = 'Republish an update group';
static args = [
{
name: 'name',
required: false,
description: 'Name of the branch to delete',
},
];
static flags = {
json: flags.boolean({
description: `return JSON with the edited branch's ID and name.`,
default: false,
}),
};
async run() {
let {
args: { name },
flags: { json: jsonFlag },
} = this.parse(BranchDelete);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const { exp } = await getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: exp.slug,
});
if (!name) {
const validationMessage = 'branch name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please enter the name of the branch to delete:',
validate: value => (value ? true : validationMessage),
}));
}
const { branchId } = await getBranchInfoAsync({ appId: projectId, name });
if (!jsonFlag) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permamently delete branch: "${name}" and all of the updates published on it.` +
`\nThis action is irreversable.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Cancelled deletion of branch: "${name}".`);
process.exit(1);
}
}
const deletionResult = await deleteBranchOnAppAsync({
branchId,
});
if (jsonFlag) {
Log.log(JSON.stringify(deletionResult));
}
Log.withTick(
`️Deleted branch "${name}" and all of its updates on project ${chalk.bold(
`@${accountName}/${exp.slug}`
)}.`
);
}
}
+54 -51
View File
@@ -1,55 +1,27 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import { CLIError } from '@oclif/errors';
import CliTable from 'cli-table3';
import gql from 'graphql-tag';
import { format } from 'timeago.js';
import { graphqlClient } from '../../graphql/client';
import { RootQuery, Update, UpdateBranch } from '../../graphql/generated';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
BranchesByAppQuery,
BranchesByAppQueryVariables,
Maybe,
Robot,
Update,
User,
} from '../../graphql/generated';
import Log from '../../log';
import { findProjectRootAsync, getProjectFullNameAsync } from '../../project/projectUtils';
import { getActorDisplayName } from '../../user/actions';
const BRANCHES_LIMIT = 10_000;
export default class BranchList extends Command {
static hidden = true;
static description = 'List all branches on this project.';
static flags = {
json: flags.boolean({
description: 'return output as JSON',
default: false,
}),
};
async run() {
const { flags } = this.parse(BranchList);
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const fullName = await getProjectFullNameAsync(projectDir);
const branches = await this.listBranchesAsync({ fullName });
if (flags.json) {
Log.log(JSON.stringify(branches, null, 2));
} else {
const table = new CliTable({ head: ['Branch', 'Latest update'] });
table.push(
...branches.map((branch: UpdateBranch) => [branch.name, formatUpdate(branch.updates[0])])
);
Log.log(table.toString());
if (branches.length >= BRANCHES_LIMIT) {
Log.warn(`Showing first ${BRANCHES_LIMIT} branches, some results might be omitted.`);
}
}
}
async listBranchesAsync({ fullName }: { fullName: string }): Promise<UpdateBranch[]> {
const { data, error } = await graphqlClient
.query<RootQuery>(
export async function listBranchesAsync({ fullName }: { fullName: string }) {
const data = await withErrorHandlingAsync(
graphqlClient
.query<BranchesByAppQuery, BranchesByAppQueryVariables>(
gql`
query BranchesByAppQuery($fullName: String!, $limit: Int!) {
app {
@@ -84,21 +56,52 @@ export default class BranchList extends Command {
limit: BRANCHES_LIMIT,
}
)
.toPromise();
.toPromise()
);
if (error) {
if (error.networkError) {
throw new CLIError(`Fetching branches failed: ${error.networkError.message}`);
} else {
throw new CLIError(error.graphQLErrors.map(e => e.message).join('\n'));
return data?.app?.byFullName.updateBranches ?? [];
}
export default class BranchList extends Command {
static hidden = true;
static description = 'List all branches on this project.';
static flags = {
json: flags.boolean({
description: 'return output as JSON',
default: false,
}),
};
async run() {
const { flags } = this.parse(BranchList);
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 fullName = await getProjectFullNameAsync(exp);
const branches = await listBranchesAsync({ fullName });
if (flags.json) {
Log.log(JSON.stringify(branches, null, 2));
} else {
const table = new CliTable({ head: ['Branch', 'Latest update'] });
table.push(...branches.map(branch => [branch.name, formatUpdate(branch.updates[0])]));
Log.log(table.toString());
if (branches.length >= BRANCHES_LIMIT) {
Log.warn(`Showing first ${BRANCHES_LIMIT} branches, some results might be omitted.`);
}
}
return data?.app?.byFullName.updateBranches ?? [];
}
}
function formatUpdate(update: Update | undefined): string {
export function formatUpdate(
update: Pick<Update, 'id' | 'updatedAt' | 'message'> & {
actor?: Maybe<Pick<User, 'username' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
}
): string {
if (!update) {
return 'N/A';
}
+157 -48
View File
@@ -1,51 +1,98 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import assert from 'assert';
import chalk from 'chalk';
import Table from 'cli-table3';
import dateFormat from 'dateformat';
import gql from 'graphql-tag';
import { uniqBy } from 'lodash';
import ora from 'ora';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
Actor,
GetUpdateGroupAsyncQuery,
RootQueryUpdatesByGroupArgs,
Update,
UpdateInfoGroup,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
findProjectRootAsync,
getBranchByNameAsync,
getProjectAccountNameAsync,
} from '../../project/projectUtils';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import {
buildBundlesAsync,
buildUpdateInfoGroupAsync,
collectAssets,
uploadAssetsAsync,
} from '../../project/publish';
import { promptAsync } from '../../prompts';
import { promptAsync, selectAsync } from '../../prompts';
import { getLastCommitMessageAsync } from '../../utils/git';
import { formatUpdate, listBranchesAsync } from './list';
import { viewUpdateBranchAsync } from './view';
type PublishPlatforms = 'android' | 'ios';
async function getUpdateGroupAsync({
group,
}: RootQueryUpdatesByGroupArgs): Promise<GetUpdateGroupAsyncQuery['updatesByGroup']> {
const { updatesByGroup } = await withErrorHandlingAsync(
graphqlClient
.query<GetUpdateGroupAsyncQuery, RootQueryUpdatesByGroupArgs>(
gql`
query getUpdateGroupAsync($group: ID!) {
updatesByGroup(group: $group) {
id
group
runtimeVersion
manifestFragment
platform
message
}
}
`,
{
group,
}
)
.toPromise()
);
return updatesByGroup;
}
export default class BranchPublish extends Command {
static hidden = true;
static description = 'Publish an update group to a branch.';
static flags = {
'input-dir': flags.string({
description: 'location of the bundle',
default: 'dist',
required: false,
}),
branch: flags.string({
description: 'current name of the branch.',
description: 'name of the branch to publish on.',
}),
message: flags.string({
description: 'Short message describing the updates.',
required: false,
}),
json: flags.boolean({
description: `return a json with the edited branch's ID and name.`,
default: false,
republish: flags.boolean({
description: 'republish an update group',
exclusive: ['input-dir', 'skip-bundler'],
}),
group: flags.string({
description: 'update group to republish',
exclusive: ['input-dir', 'skip-bundler'],
}),
'input-dir': flags.string({
description: 'location of the bundle',
default: 'dist',
required: false,
}),
'skip-bundler': flags.boolean({
description: `skip running Expo CLI to bundle the app before publishing`,
default: false,
}),
json: flags.boolean({
description: `return a json with the new update group.`,
default: false,
}),
};
async run() {
@@ -54,20 +101,23 @@ export default class BranchPublish extends Command {
json: jsonFlag,
branch: name,
message,
republish,
group,
'input-dir': inputDir,
'skip-bundler': skipBundler,
},
} = this.parse(BranchPublish);
// If a group was specified, that means we are republishing it.
republish = group ? true : republish;
const projectDir = await findProjectRootAsync(process.cwd());
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug, runtimeVersion },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug, runtimeVersion } = exp;
if (!runtimeVersion) {
throw new Error(
"Couldn't find 'runtimeVersion'. Please specify it under the 'expo' key in 'app.json'"
@@ -83,12 +133,79 @@ export default class BranchPublish extends Command {
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please enter the name of the branch to publish on:',
validate: value => (value ? true : validationMessage),
}));
const branches = await listBranchesAsync({ fullName: `@${accountName}/${slug}` });
name = await selectAsync<string>(
'which branch would you like to publish on?',
branches.map(branch => {
return {
title: `${branch.name} ${chalk.grey(
`- current update: ${formatUpdate(branch.updates[0])}`
)}`,
value: branch.name,
};
})
);
}
assert(name, 'branch name must be specified.');
const { id: branchId, updates } = await viewUpdateBranchAsync({
appId: projectId,
name,
});
let updateInfoGroup: UpdateInfoGroup = {};
let oldMessage: string, oldRuntimeVersion: string;
if (republish) {
// If we are republishing, we don't need to worry about building the bundle or uploading the assets.
// Instead we get the `updateInfoGroup` from the update we wish to republish.
let updatesToRepublish: Pick<
Update,
'group' | 'message' | 'runtimeVersion' | 'manifestFragment' | 'platform'
>[];
if (group) {
updatesToRepublish = await getUpdateGroupAsync({ group });
} else {
const updateGroups = uniqBy(updates, u => u.group).map(update => ({
title: formatUpdateTitle(update),
value: update.group,
}));
if (updateGroups.length === 0) {
throw new Error(
`There are no updates on branch "${name}". Did you mean to do a regular publish?`
);
}
const updateGroup = await selectAsync<string>(
'which update would you like to republish?',
updateGroups
);
updatesToRepublish = updates.filter(update => update.group === updateGroup);
}
for (const update of updatesToRepublish) {
const { platform, manifestFragment } = update;
updateInfoGroup[platform as PublishPlatforms] = JSON.parse(manifestFragment);
}
// These are the same for each member of an update group
group = updatesToRepublish[0].group;
oldMessage = updatesToRepublish[0].message ?? '';
oldRuntimeVersion = updatesToRepublish[0].runtimeVersion;
} else {
// build bundle and upload assets for a new publish
if (!skipBundler) {
await buildBundlesAsync({ projectDir, inputDir });
}
const assetSpinner = ora('Uploading assets...').start();
try {
const assets = collectAssets(inputDir!);
await uploadAssetsAsync(assets);
updateInfoGroup = await buildUpdateInfoGroupAsync(assets);
assetSpinner.succeed('Uploaded assets!');
} catch (e) {
assetSpinner.fail('Failed to upload assets');
throw e;
}
}
if (!message) {
@@ -100,39 +217,20 @@ export default class BranchPublish extends Command {
type: 'text',
name: 'publishMessage',
message: `Please enter a publication message.`,
initial: (await getLastCommitMessageAsync())?.trim(),
initial: republish
? `Republish "${oldMessage!}" - group: ${group}`
: (await getLastCommitMessageAsync())?.trim(),
validate: value => (value ? true : validationMessage),
}));
}
const { id: branchId } = await getBranchByNameAsync({
appId: projectId,
name: name!,
});
if (!skipBundler) {
await buildBundlesAsync({ projectDir, inputDir });
}
let updateInfoGroup;
const assetSpinner = ora('Uploading assets...').start();
try {
const assets = collectAssets(inputDir!);
await uploadAssetsAsync(assets);
updateInfoGroup = await buildUpdateInfoGroupAsync(assets);
assetSpinner.succeed('Uploaded assets!');
} catch (e) {
assetSpinner.fail('Failed to upload assets');
throw e;
}
let newUpdateGroup;
const publishSpinner = ora('Publishing...').start();
try {
newUpdateGroup = await PublishMutation.publishUpdateGroupAsync({
branchId,
updateInfoGroup,
runtimeVersion,
runtimeVersion: republish ? oldRuntimeVersion! : runtimeVersion,
message,
});
publishSpinner.succeed('Published!');
@@ -176,3 +274,14 @@ export default class BranchPublish extends Command {
}
}
}
function formatUpdateTitle(
update: Pick<Update, 'message' | 'createdAt' | 'runtimeVersion'> & {
actor?: Pick<Actor, 'firstName'> | null;
}
): string {
const { message, createdAt, actor, runtimeVersion } = update;
return `[${dateFormat(createdAt, 'mmm dd HH:MM')} by ${
actor?.firstName ?? 'unknown'
}, runtimeVersion: ${runtimeVersion}] ${message}`;
}
+11 -15
View File
@@ -4,7 +4,12 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { EditUpdateBranchInput, UpdateBranch } from '../../graphql/generated';
import {
EditUpdateBranchInput,
EditUpdateBranchMutation,
EditUpdateBranchMutationVariables,
UpdateBranch,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
@@ -13,21 +18,12 @@ import { ensureLoggedInAsync } from '../../user/actions';
async function renameUpdateBranchOnAppAsync({
appId,
currentName,
name,
newName,
}: {
appId: string;
currentName: string;
newName: string;
}): Promise<UpdateBranch> {
}: EditUpdateBranchInput): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateBranch: { editUpdateBranch: UpdateBranch } },
{
input: EditUpdateBranchInput;
}
>(
.mutation<EditUpdateBranchMutation, EditUpdateBranchMutationVariables>(
gql`
mutation EditUpdateBranch($input: EditUpdateBranchInput!) {
updateBranch {
@@ -41,7 +37,7 @@ async function renameUpdateBranchOnAppAsync({
{
input: {
appId,
name: currentName,
name,
newName,
},
}
@@ -114,7 +110,7 @@ export default class BranchRename extends Command {
const editedBranch = await renameUpdateBranchOnAppAsync({
appId: projectId,
currentName: currentName!,
name: currentName!,
newName: newName!,
});
+33 -33
View File
@@ -6,7 +6,15 @@ import gql from 'graphql-tag';
import { groupBy } from 'lodash';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { Update } from '../../graphql/generated';
import {
Maybe,
Robot,
Update,
UpdateBranch,
User,
ViewBranchQuery,
ViewBranchQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
@@ -16,37 +24,22 @@ const PAGE_LIMIT = 10_000;
type TruncatedUpdate = Pick<Update, 'group' | 'message' | 'createdAt' | 'actor'>;
async function viewUpdateBranchAsync({
export async function viewUpdateBranchAsync({
appId,
name,
}: {
appId: string;
name: string;
}): Promise<{
id: string;
name: string;
updates: TruncatedUpdate[];
}> {
}: Pick<ViewBranchQueryVariables, 'appId' | 'name'>): Promise<
Pick<UpdateBranch, 'id' | 'name'> & {
updates: (Pick<
Update,
'id' | 'group' | 'message' | 'createdAt' | 'runtimeVersion' | 'platform' | 'manifestFragment'
> & {
actor?: Maybe<Pick<User, 'firstName' | 'id'> | Pick<Robot, 'firstName' | 'id'>>;
})[];
}
> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{
app: {
byId: {
updateBranchByName: {
id: string;
name: string;
updates: TruncatedUpdate[];
};
};
};
},
{
appId: string;
name: string;
limit: number;
}
>(
.query<ViewBranchQuery, ViewBranchQueryVariables>(
gql`
query ViewBranch($appId: String!, $name: String!, $limit: Int!) {
app {
@@ -60,6 +53,9 @@ async function viewUpdateBranchAsync({
group
message
createdAt
runtimeVersion
platform
manifestFragment
actor {
id
... on User {
@@ -83,7 +79,11 @@ async function viewUpdateBranchAsync({
)
.toPromise()
);
return data.app.byId.updateBranchByName;
const updateBranch = data.app?.byId.updateBranchByName;
if (!updateBranch) {
throw new Error(`Could not find branch "${name}"`);
}
return updateBranch;
}
export default class BranchView extends Command {
@@ -115,10 +115,10 @@ export default class BranchView extends Command {
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
+12 -4
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import gql from 'graphql-tag';
@@ -6,7 +7,13 @@ import ora from 'ora';
import { platformEmojis } from '../../build/constants';
import { Platform } from '../../build/types';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { AppPlatform, Build, BuildStatus } from '../../graphql/generated';
import {
AppPlatform,
Build,
BuildStatus,
CancelBuildMutation,
CancelBuildMutationVariables,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import {
@@ -19,7 +26,7 @@ import { confirmAsync, selectAsync } from '../../prompts';
async function cancelBuildAsync(buildId: string): Promise<Pick<Build, 'id' | 'status'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<{ build: { cancel: { id: string; status: BuildStatus } } }>(
.mutation<CancelBuildMutation, CancelBuildMutationVariables>(
gql`
mutation CancelBuildMutation($buildId: ID!) {
build(buildId: $buildId) {
@@ -109,8 +116,9 @@ export default class BuildCancel extends Command {
const { BUILD_ID: buildIdFromArg } = this.parse(BuildCancel).args;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const projectFullName = await getProjectFullNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
if (buildIdFromArg) {
await ensureBuildExistsAsync(buildIdFromArg);
@@ -70,10 +70,9 @@ function logSuccess(platform: RequestedPlatform) {
- Run ${chalk.bold('eas build')} when you are ready to create your first build.
- Once the build is completed, run ${chalk.bold('eas submit')} to upload the app to ${storesText}
- ${learnMore(
'https://docs.expo.io/build/introduction',
'Learn more about other capabilities of EAS Build'
)}`);
- ${learnMore('https://docs.expo.io/build/introduction', {
learnMoreMessage: 'Learn more about other capabilities of EAS Build',
})}`);
}
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
+5 -2
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -68,7 +69,8 @@ export default class Build extends Command {
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
@@ -76,7 +78,7 @@ export default class Build extends Command {
return;
}
const accountName = await getProjectAccountNameAsync(projectDir);
const accountName = await getProjectAccountNameAsync(exp);
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
accountName,
platform,
@@ -98,6 +100,7 @@ export default class Build extends Command {
const commandCtx = await createCommandContextAsync({
requestedPlatform: platform,
profile: flags.profile,
exp,
projectDir,
projectId,
trackingCtx,
+5 -3
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import ora from 'ora';
@@ -26,9 +27,10 @@ export default class BuildList extends Command {
const { platform, status, limit = 10 } = this.parse(BuildList).flags;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const accountName = await getProjectAccountNameAsync(projectDir);
const projectName = await getProjectFullNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const accountName = await getProjectAccountNameAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const spinner = ora().start('Fetching the build list for the project…');
+5 -3
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import { HTTPError } from 'got';
import ora from 'ora';
@@ -22,9 +23,10 @@ export default class BuildView extends Command {
const { BUILD_ID: buildId } = this.parse(BuildView).args;
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const accountName = await getProjectAccountNameAsync(projectDir);
const projectName = await getProjectFullNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const accountName = await getProjectAccountNameAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const spinner = ora().start('Fetching the build…');
+20 -14
View File
@@ -4,7 +4,10 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateChannel } from '../../graphql/generated';
import {
CreateUpdateChannelOnAppMutation,
CreateUpdateChannelOnAppMutationVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
@@ -23,20 +26,17 @@ async function createUpdateChannelOnAppAsync({
appId: string;
channelName: string;
branchId: string;
}): Promise<UpdateChannel> {
}): Promise<CreateUpdateChannelOnAppMutation> {
// Point the new channel at a branch with its same name.
const branchMapping = JSON.stringify({
data: [{ branchId, branchMappingLogic: 'true' }],
version: 0,
});
const data = await withErrorHandlingAsync(
return await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateChannel: { createUpdateChannelForApp: UpdateChannel } },
{ appId: string; name: string; branchMapping: string }
>(
.mutation<CreateUpdateChannelOnAppMutation, CreateUpdateChannelOnAppMutationVariables>(
gql`
mutation CreateUpdateChannelForApp($appId: ID!, $name: String!, $branchMapping: String!) {
mutation CreateUpdateChannelOnApp($appId: ID!, $name: String!, $branchMapping: String!) {
updateChannel {
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
id
@@ -54,7 +54,6 @@ async function createUpdateChannelOnAppAsync({
)
.toPromise()
);
return data.updateChannel.createUpdateChannelForApp;
}
export default class ChannelCreate extends Command {
@@ -87,10 +86,9 @@ export default class ChannelCreate extends Command {
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
@@ -127,12 +125,20 @@ export default class ChannelCreate extends Command {
branchMessage = `We also went ahead and made a branch with the same name`;
}
const newChannel = await createUpdateChannelOnAppAsync({
const {
updateChannel: { createUpdateChannelForApp: newChannel },
} = await createUpdateChannelOnAppAsync({
appId: projectId,
channelName,
branchId,
});
if (!newChannel) {
throw new Error(
`Could not create channel with name ${channelName} on app with id ${projectId}`
);
}
if (jsonFlag) {
Log.log(JSON.stringify(newChannel));
return;
+42 -28
View File
@@ -4,7 +4,14 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateChannel } from '../../graphql/generated';
import {
GetChannelByNameToEditQuery,
GetChannelByNameToEditQueryVariables,
UpdateBranch,
UpdateChannel,
UpdateChannelBranchMappingMutation,
UpdateChannelBranchMappingMutationVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
@@ -14,15 +21,19 @@ import {
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
async function getChannelByNameForAppAsync(variables: {
appId: string;
channelName: string;
}): Promise<UpdateChannel> {
async function getChannelByNameForAppAsync({
appId,
channelName,
}: GetChannelByNameToEditQueryVariables): Promise<
Pick<UpdateChannel, 'id' | 'name'> & {
updateBranches: Pick<UpdateBranch, 'id' | 'name'>[];
}
> {
const data = await withErrorHandlingAsync(
graphqlClient
.query(
.query<GetChannelByNameToEditQuery, GetChannelByNameToEditQueryVariables>(
gql`
query GetChannelByNameForApp($appId: String!, $channelName: String!) {
query GetChannelByNameToEdit($appId: String!, $channelName: String!) {
app {
byId(appId: $appId) {
id
@@ -38,41 +49,48 @@ async function getChannelByNameForAppAsync(variables: {
}
}
`,
variables
{ appId, channelName }
)
.toPromise()
);
return data.app.byId.updateChannelByName;
const updateChannelByNameResult = data.app?.byId.updateChannelByName;
if (!updateChannelByNameResult) {
throw new Error(`Could not find a channel named ${channelName} on app with id ${appId}`);
}
return updateChannelByNameResult;
}
async function updateChannelBranchMappingAsync(variables: {
channelId: string;
async function updateChannelBranchMappingAsync({
channelId,
branchMapping,
}: UpdateChannelBranchMappingMutationVariables): Promise<{
id: string;
name: string;
branchMapping: string;
}): Promise<UpdateChannel> {
}> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation(
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
gql`
mutation UpdateChannelBranchMapping($channelId: ID!, $branchMapping: String!) {
updateChannel {
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
id
name
createdAt
branchMapping
updateBranches(offset: 0, limit: 1) {
id
name
}
}
}
}
`,
variables
{ channelId, branchMapping }
)
.toPromise()
);
return data.updateChannel.editUpdateChannel;
const channel = data.updateChannel.editUpdateChannel;
if (!channel) {
throw new Error(`Could not fine channel with id ${channelId}`);
}
return data.updateChannel.editUpdateChannel!;
}
export default class ChannelEdit extends Command {
@@ -107,10 +125,9 @@ export default class ChannelEdit extends Command {
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
@@ -152,10 +169,7 @@ export default class ChannelEdit extends Command {
}));
}
const branch = await getBranchByNameAsync({
appId: projectId,
name: branchName!,
});
const branch = await getBranchByNameAsync({ appId: projectId, name: branchName! });
const channel = await updateChannelBranchMappingAsync({
channelId: existingChannel.id,
+18 -11
View File
@@ -5,7 +5,10 @@ import Table from 'cli-table3';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateChannel } from '../../graphql/generated';
import {
GetAllChannelsForAppQuery,
GetAllChannelsForAppQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
@@ -16,10 +19,10 @@ async function getAllUpdateChannelForAppAsync({
appId,
}: {
appId: string;
}): Promise<UpdateChannel[]> {
const data = await withErrorHandlingAsync(
}): Promise<GetAllChannelsForAppQuery> {
return await withErrorHandlingAsync(
graphqlClient
.query(
.query<GetAllChannelsForAppQuery, GetAllChannelsForAppQueryVariables>(
gql`
query GetAllChannelsForApp($appId: String!, $offset: Int!, $limit: Int!) {
app {
@@ -52,11 +55,10 @@ async function getAllUpdateChannelForAppAsync({
}
}
`,
{ appId, limit: CHANNEL_LIMIT }
{ appId, offset: 0, limit: CHANNEL_LIMIT }
)
.toPromise()
);
return data.app.byId.updateChannels;
}
export default class ChannelList extends Command {
@@ -79,16 +81,21 @@ export default class ChannelList extends Command {
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
const channels = await getAllUpdateChannelForAppAsync({ appId: projectId });
const getAllUpdateChannelForAppResult = await getAllUpdateChannelForAppAsync({
appId: projectId,
});
const channels = getAllUpdateChannelForAppResult.app?.byId.updateChannels;
if (!channels) {
throw new Error(`Could not find channels on project with id ${projectId}`);
}
if (jsonFlag) {
Log.log(JSON.stringify(channels));
+22 -14
View File
@@ -5,19 +5,22 @@ import Table from 'cli-table3';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdateChannel } from '../../graphql/generated';
import {
GetChannelByNameForAppQuery,
GetChannelByNameForAppQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
async function getUpdateChannelByNameForAppAsync(variables: {
appId: string;
channelName: string;
}): Promise<UpdateChannel> {
const data = await withErrorHandlingAsync(
async function getUpdateChannelByNameForAppAsync({
appId,
channelName,
}: GetChannelByNameForAppQueryVariables): Promise<GetChannelByNameForAppQuery> {
return await withErrorHandlingAsync(
graphqlClient
.query(
.query<GetChannelByNameForAppQuery, GetChannelByNameForAppQueryVariables>(
gql`
query GetChannelByNameForApp($appId: String!, $channelName: String!) {
app {
@@ -51,11 +54,10 @@ async function getUpdateChannelByNameForAppAsync(variables: {
}
}
`,
variables
{ appId, channelName }
)
.toPromise()
);
return data.app.byId.updateChannelByName;
}
export default class ChannelView extends Command {
@@ -87,10 +89,9 @@ export default class ChannelView extends Command {
if (!projectDir) {
throw new Error('Please run this command inside a project directory.');
}
const accountName = await getProjectAccountNameAsync(projectDir);
const {
exp: { slug },
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
@@ -109,7 +110,14 @@ export default class ChannelView extends Command {
}));
}
const channel = await getUpdateChannelByNameForAppAsync({ appId: projectId, channelName });
const getUpdateChannelByNameForAppresult = await getUpdateChannelByNameForAppAsync({
appId: projectId,
channelName,
});
const channel = getUpdateChannelByNameForAppresult.app?.byId.updateChannelByName;
if (!channel) {
throw new Error(`Could not fine channel with name ${channelName}`);
}
if (jsonFlag) {
Log.log(JSON.stringify(channel));
+9 -3
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import assert from 'assert';
import chalk from 'chalk';
@@ -21,7 +22,8 @@ export default class BuildList extends Command {
let appleTeamIdentifier = this.parse(BuildList).flags['apple-team-id'];
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const accountName = await getProjectAccountNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
let spinner: ora.Ora;
@@ -42,7 +44,9 @@ export default class BuildList extends Command {
name: 'appleTeamIdentifier',
message: 'What Apple Team would you like to list devices for?',
choices: teams.map(team => ({
title: `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`,
title: team.appleTeamName
? `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`
: team.appleTeamIdentifier,
value: team.appleTeamIdentifier,
})),
});
@@ -71,7 +75,9 @@ export default class BuildList extends Command {
if (result?.appleDevices.length) {
const { appleTeamName, appleDevices } = result;
spinner.succeed(`Found ${appleDevices.length} devices for team ${appleTeamName}`);
spinner.succeed(
`Found ${appleDevices.length} devices for team ${appleTeamName ?? appleTeamIdentifier}`
);
const list = appleDevices
.map(device =>
+3 -1
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import ora from 'ora';
@@ -29,7 +30,8 @@ If you are not sure what is the UDID of the device you are looking for, run:
}
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const accountName = await getProjectAccountNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const spinner = ora().start(`Fetching device details for ${UDID}…`);
@@ -0,0 +1,154 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import Log from '../../log';
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { findAccountByName } from '../../user/Account';
import { ensureLoggedInAsync, getActorDisplayName } from '../../user/actions';
export enum EnvironmentSecretTargetLocation {
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,
description: 'Name of the secret',
},
{
name: 'value',
required: false,
description: 'Value of the secret',
},
];
async run() {
const user = await ensureLoggedInAsync();
let {
args: { name, value: secretValue, target },
} = this.parse(EnvironmentSecretCreate);
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await ensureProjectExistsAsync({
accountName,
projectName: slug,
});
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
if (!target) {
const validationMessage = 'Secret target may not be empty.';
({ target } = await promptAsync({
type: 'select',
name: 'target',
message: 'Where should this secret be used:',
choices: [
{ title: 'Account-wide', value: EnvironmentSecretTargetLocation.ACCOUNT },
{ title: 'Project-specific', value: EnvironmentSecretTargetLocation.PROJECT },
],
validate: value => (value ? true : validationMessage),
}));
}
if (!name) {
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: `Secret name:`,
validate: value => {
if (!value) {
return 'Secret name may not be empty.';
}
if (value.match(/^\w+$/)) {
return 'Names may contain only letters, numbers, and underscores.';
}
return true;
},
}));
}
if (!secretValue) {
const validationMessage = 'Secret value may not be empty.';
({ secretValue } = await promptAsync({
type: 'text',
name: 'secretValue',
message: 'Secret value:',
validate: value => (value ? true : validationMessage),
}));
}
if (target === EnvironmentSecretTargetLocation.PROJECT) {
const secret = await EnvironmentSecretMutation.createForApp(
{ name, value: secretValue },
projectId
);
if (!secret) {
throw new Error(
`Could not create secret with name ${name} on project with id ${projectId}`
);
}
Log.withTick(
`️Created a new secret ${chalk.bold(name)} on project ${chalk.bold(
`@${accountName}/${slug}`
)}.`
);
} else if (target === EnvironmentSecretTargetLocation.ACCOUNT) {
const ownerAccount = findAccountByName(user.accounts, accountName);
if (!ownerAccount) {
Log.warn(
`Your account (${getActorDisplayName(user)}) doesn't have access to the ${chalk.bold(
accountName
)} account`
);
return;
}
const secret = await EnvironmentSecretMutation.createForAccount(
{ name, value: secretValue },
ownerAccount.id
);
if (!secret) {
throw new Error(
`Could not create secret with name ${name} on account with id ${ownerAccount.id}`
);
}
Log.withTick(
`️Created a new secret ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
);
}
}
}
@@ -0,0 +1,61 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import Log from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { toggleConfirmAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
export default class EnvironmentSecretDelete extends Command {
static hidden = true;
static description = `
Delete an environment secret by ID.
Unsure where to find the secret's ID? Run ${'`eas secrets:list`'}
`;
static args = [
{
name: 'id',
required: true,
description: `ID of the secret to delete`,
},
];
async run() {
await ensureLoggedInAsync();
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
const {
args: { id },
} = this.parse(EnvironmentSecretDelete);
Log.addNewLineIfNone();
Log.warn(
`You are about to permamently delete secret with id: "${id}".\nThis action is irreversable.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Canceled deletion of secret with id: "${id}".`);
process.exit(1);
}
await EnvironmentSecretMutation.delete(id);
Log.withTick(`️Deleted secret with id "${id}".`);
}
}
@@ -0,0 +1,69 @@
import { getConfig } from '@expo/config';
import { Command } from '@oclif/command';
import chalk from 'chalk';
import Table from 'cli-table3';
import dateFormat from 'dateformat';
import { EnvironmentSecretFragment } from '../../graphql/generated';
import { EnvironmentSecretsQuery } from '../../graphql/queries/EnvironmentSecretsQuery';
import Log from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { EnvironmentSecretTargetLocation } from './create';
export default class EnvironmentSecretsList extends Command {
static description = 'Lists environment secrets available for your current app';
static usage = 'secrets:list';
async run(): Promise<void> {
await ensureLoggedInAsync();
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
const projectAccountName = await getProjectAccountNameAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
process.exitCode = 1;
return;
}
if (!projectDir) {
throw new Error("Please run this command inside your project's directory");
}
const [accountSecrets, appSecrets] = await Promise.all([
EnvironmentSecretsQuery.byAcccountNameAsync(projectAccountName),
EnvironmentSecretsQuery.byAppFullNameAsync(projectFullName),
]);
const secrets = [
...appSecrets.map(s => ({ ...s, target: EnvironmentSecretTargetLocation.PROJECT })),
...accountSecrets.map(s => ({ ...s, target: EnvironmentSecretTargetLocation.ACCOUNT })),
] as (EnvironmentSecretFragment & { target: EnvironmentSecretTargetLocation })[];
const table = new Table({
head: ['Name', 'Target', 'Updated at', 'ID'],
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]);
}
Log.log(chalk`{bold Secrets for this account and project:}`);
Log.log(table.toString());
}
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { getConfig } from '@expo/config';
import { Command, flags } from '@oclif/command';
import { learnMore } from '../log';
@@ -168,7 +169,8 @@ export default class BuildSubmit extends Command {
const platform = platformFromParams ?? (await promptForPlatformAsync());
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const projectId = await getProjectIdAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const projectId = await getProjectIdAsync(exp);
if (!(await isEasEnabledForProjectAsync(projectId))) {
warnEasUnavailable();
@@ -1,7 +1,7 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource, iOSDistributionType } from '@expo/eas-json';
import { IosDistributionType } from '../../graphql/generated';
import { AppleTeamFragment, IosDistributionType } from '../../graphql/generated';
import Log from '../../log';
import { findAccountByName } from '../../user/Account';
import { CredentialsManager } from '../CredentialsManager';
@@ -10,6 +10,8 @@ import { Context } from '../context';
import * as credentialsJsonReader from '../credentialsJson/read';
import type { IosCredentials } from '../credentialsJson/read';
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
import { resolveAppleTeamIfAuthenticatedAsync } from './actions/new/AppleTeamUtils';
import { AppleTeamMissingError, MissingCredentialsNonInteractiveError } from './errors';
import { isAdHocProfile } from './utils/provisioningProfile';
export { IosCredentials };
@@ -126,7 +128,7 @@ export default class IosCredentialsProvider implements CredentialsProvider {
} 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`
);
}
@@ -183,31 +185,36 @@ export default class IosCredentialsProvider implements CredentialsProvider {
projectName: this.options.app.projectName,
};
// for now, let's require the user to authenticate with Apple
const { team } = await this.ctx.appStore.ensureAuthenticatedAsync();
const appleTeam = await this.ctx.newIos.createOrGetExistingAppleTeamAsync(appLookupParams, {
appleTeamIdentifier: team.id,
appleTeamName: team.name,
});
const [distCert, provisioningProfile] = await Promise.all([
this.ctx.newIos.getDistributionCertificateForAppAsync(
appLookupParams,
appleTeam,
IosDistributionType.AdHoc
),
this.ctx.newIos.getProvisioningProfileAsync(
appLookupParams,
appleTeam,
IosDistributionType.AdHoc
),
]);
return {
provisioningProfile: provisioningProfile?.provisioningProfile ?? undefined,
distributionCertificate: {
certP12: distCert?.certificateP12 ?? undefined,
certPassword: distCert?.certificatePassword ?? undefined,
},
};
let appleTeam: AppleTeamFragment | null = null;
if (!this.ctx.nonInteractive) {
await this.ctx.appStore.ensureAuthenticatedAsync();
appleTeam = await resolveAppleTeamIfAuthenticatedAsync(this.ctx, appLookupParams);
}
try {
const [distCert, provisioningProfile] = await Promise.all([
this.ctx.newIos.getDistributionCertificateForAppAsync(
appLookupParams,
IosDistributionType.AdHoc,
{ appleTeam }
),
this.ctx.newIos.getProvisioningProfileAsync(appLookupParams, IosDistributionType.AdHoc, {
appleTeam,
}),
]);
return {
provisioningProfile: provisioningProfile?.provisioningProfile ?? undefined,
distributionCertificate: {
certP12: distCert?.certificateP12 ?? undefined,
certPassword: distCert?.certificatePassword ?? undefined,
},
};
} catch (err) {
if (err instanceof AppleTeamMissingError && this.ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError();
}
throw err;
}
} else {
const [distCert, provisioningProfile] = await Promise.all([
this.ctx.ios.getDistributionCertificateAsync(this.options.app),
@@ -37,11 +37,6 @@ export class SetupBuildCredentials implements Action {
if (!account) {
throw new Error(`You do not have access to the ${this.app.accountName} account`);
}
if (ctx.nonInteractive) {
throw new Error('Internal distribution builds are not supported in non-interactive mode');
}
// for now, let's require the user to authenticate with Apple
await ctx.appStore.ensureAuthenticatedAsync();
const action = new SetupAdhocProvisioningProfile({
account,
projectName: this.app.projectName,
@@ -11,7 +11,11 @@ export class UpdateCredentialsJson implements Action {
constructor(private app: AppLookupParams) {}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const bundleIdentifer = await ensureAppIdentifierIsDefinedAsync(ctx.projectDir, Platform.IOS);
const bundleIdentifer = await ensureAppIdentifierIsDefinedAsync({
projectDir: ctx.projectDir,
platform: Platform.IOS,
exp: ctx.exp,
});
Log.log('Updating iOS credentials in credentials.json');
await updateIosCredentialsAsync(ctx, bundleIdentifer);
Log.succeed(
@@ -0,0 +1,16 @@
import { AppleTeamFragment } from '../../../../graphql/generated';
import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
export async function resolveAppleTeamIfAuthenticatedAsync(
ctx: Context,
app: AppLookupParams
): Promise<AppleTeamFragment | null> {
if (!ctx.appStore.authCtx) {
return null;
}
return await ctx.newIos.createOrGetExistingAppleTeamAsync(app, {
appleTeamIdentifier: ctx.appStore.authCtx.team.id,
appleTeamName: ctx.appStore.authCtx.team.name,
});
}
@@ -6,6 +6,7 @@ import {
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../../graphql/generated';
import Log from '../../../../log';
import { confirmAsync } from '../../../../prompts';
import { Action, CredentialsManager } from '../../../CredentialsManager';
import { Context } from '../../../context';
@@ -15,6 +16,8 @@ import { AppleDeviceFragmentWithAppleTeam } from '../../api/graphql/queries/Appl
import { AppleProvisioningProfileQueryResult } from '../../api/graphql/queries/AppleProvisioningProfileQuery';
import { ProvisioningProfileStoreInfo } from '../../appstore/Credentials.types';
import { ProfileClass } from '../../appstore/provisioningProfile';
import { AppleTeamMissingError, MissingCredentialsNonInteractiveError } from '../../errors';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
import { chooseDevices } from './DeviceUtils';
import { doUDIDsMatch, isDevPortalAdhocProfileValid } from './ProvisioningProfileUtils';
import { SetupDistributionCertificate } from './SetupDistributionCertificate';
@@ -33,15 +36,55 @@ export class SetupAdhocProvisioningProfile implements Action {
}
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
const authCtx = await ctx.appStore.ensureAuthenticatedAsync();
if (ctx.nonInteractive) {
try {
await this.runNonInteractiveAsync(manager, ctx);
} catch (err) {
if (err instanceof AppleTeamMissingError) {
throw new MissingCredentialsNonInteractiveError();
}
throw err;
}
} else {
await this.runInteractiveAsync(manager, ctx);
}
}
// 0. Fetch apple team object
const appleTeam = await ctx.newIos.createOrGetExistingAppleTeamAsync(this.app, {
appleTeamIdentifier: authCtx.team.id,
appleTeamName: authCtx.team.name,
});
private async runNonInteractiveAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
// 1. Setup Distribution Certificate
const distCertAction = new SetupDistributionCertificate(this.app);
await manager.runActionAsync(distCertAction);
// 1. Fetch devices registered on EAS servers.
// 2. Fetch profile from EAS servers
const currentProfile = await ctx.newIos.getProvisioningProfileAsync(
this.app,
IosDistributionType.AdHoc
);
if (!currentProfile) {
throw new MissingCredentialsNonInteractiveError();
}
// TODO: implement validation
Log.warn(
'Provisioning Profile is not validated for non-interactive internal distribution builds.'
);
// app credentials should exist here because the profile exists
const appCredentials = nullthrows(
await ctx.newIos.getIosAppCredentialsWithBuildCredentialsAsync(this.app, {
iosDistributionType: IosDistributionType.AdHoc,
})
);
this._iosAppBuildCredentials = appCredentials.iosAppBuildCredentialsArray[0];
}
private async runInteractiveAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
// 0. Ensure the user is authenticated with Apple and resolve the Apple team object
await ctx.appStore.ensureAuthenticatedAsync();
const appleTeam = nullthrows(await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app));
// 1. Fetch devices registered on EAS servers
const registeredAppleDevices = await ctx.newIos.getDevicesForAppleTeamAsync(
this.app,
appleTeam
@@ -58,17 +101,17 @@ export class SetupAdhocProvisioningProfile implements Action {
await manager.runActionAsync(distCertAction);
const distCert = distCertAction.distributionCertificate;
// 3. Fetch profile from EAS servers
let currentProfileFromExpoServers = (await ctx.newIos.getProvisioningProfileAsync(
this.app,
let profileFromExpoServersToUse:
| AppleProvisioningProfileQueryResult
| AppleProvisioningProfileMutationResult
| null = await ctx.newIos.getProvisioningProfileAsync(this.app, IosDistributionType.AdHoc, {
appleTeam,
IosDistributionType.AdHoc
)) as AppleProvisioningProfileQueryResult | AppleProvisioningProfileMutationResult | null;
});
// 4. Choose devices for internal distribution
let chosenDevices: AppleDeviceFragmentWithAppleTeam[];
if (currentProfileFromExpoServers) {
const appleDeviceIdentifiersFromProfile = ((currentProfileFromExpoServers.appleDevices ??
if (profileFromExpoServersToUse) {
const appleDeviceIdentifiersFromProfile = ((profileFromExpoServersToUse.appleDevices ??
[]) as AppleDevice[]).map(({ identifier }) => identifier);
let shouldAskToChooseDevices = true;
@@ -110,10 +153,10 @@ export class SetupAdhocProvisioningProfile implements Action {
this.app,
appleTeam
);
if (currentProfileFromExpoServers) {
if (profileFromExpoServersToUse) {
if (profileWasRecreated) {
currentProfileFromExpoServers = await ctx.newIos.updateProvisioningProfileAsync(
currentProfileFromExpoServers.id,
profileFromExpoServersToUse = await ctx.newIos.updateProvisioningProfileAsync(
profileFromExpoServersToUse.id,
{
appleProvisioningProfile: profileToUse.provisioningProfile,
developerPortalIdentifier: profileToUse.provisioningProfileId,
@@ -121,7 +164,7 @@ export class SetupAdhocProvisioningProfile implements Action {
);
}
} else {
currentProfileFromExpoServers = await ctx.newIos.createProvisioningProfileAsync(
profileFromExpoServersToUse = await ctx.newIos.createProvisioningProfileAsync(
this.app,
appleAppIdentifier,
{
@@ -138,7 +181,7 @@ export class SetupAdhocProvisioningProfile implements Action {
appleTeam,
appleAppIdentifierId: appleAppIdentifier.id,
appleDistributionCertificateId: distCert.id,
appleProvisioningProfileId: currentProfileFromExpoServers.id,
appleProvisioningProfileId: profileFromExpoServersToUse.id,
iosDistributionType: IosDistributionType.AdHoc,
}
);
@@ -13,6 +13,8 @@ import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
import { AppleDistributionCertificateMutationResult } from '../../api/graphql/mutations/AppleDistributionCertificateMutation';
import { getValidCertSerialNumbers } from '../../appstore/CredentialsUtils';
import { AppleTeamMissingError, MissingCredentialsNonInteractiveError } from '../../errors';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
import { CreateDistributionCertificate } from './CreateDistributionCertificate';
import { formatDistributionCertificate } from './DistributionCertificateUtils';
@@ -31,21 +33,48 @@ export class SetupDistributionCertificate implements Action {
}
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
assert(
ctx.appStore.authCtx,
'authCtx is defined in this context - enforced by ensureAuthenticatedAsync call in SetupBuildCredentials'
);
const appleTeam = await ctx.newIos.createOrGetExistingAppleTeamAsync(this.app, {
appleTeamIdentifier: ctx.appStore.authCtx.team.id,
appleTeamName: ctx.appStore.authCtx.team.name,
});
const appleTeam = await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app);
const currentCertificate = await ctx.newIos.getDistributionCertificateForAppAsync(
this.app,
appleTeam,
IosDistributionType.AdHoc
);
try {
const currentCertificate = await ctx.newIos.getDistributionCertificateForAppAsync(
this.app,
IosDistributionType.AdHoc,
{ appleTeam }
);
if (ctx.nonInteractive) {
await this.runNonInteractiveAsync(ctx, currentCertificate);
} else {
await this.runInteractiveAsync(ctx, manager, currentCertificate);
}
} catch (err) {
if (err instanceof AppleTeamMissingError && ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError();
}
throw err;
}
}
private async runNonInteractiveAsync(
ctx: Context,
currentCertificate: AppleDistributionCertificateFragment | null
): Promise<void> {
// TODO: implement validation
Log.addNewLineIfNone();
Log.warn(
'Distribution Certificate is not validated for non-interactive internal distribution builds.'
);
if (!currentCertificate) {
throw new MissingCredentialsNonInteractiveError();
}
this._distributionCertificate = currentCertificate;
}
private async runInteractiveAsync(
ctx: Context,
manager: CredentialsManager,
currentCertificate: AppleDistributionCertificateFragment | null
): Promise<void> {
if (await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) {
assert(currentCertificate, 'currentCertificate is defined here');
this._distributionCertificate = currentCertificate;
@@ -11,6 +11,7 @@ import {
} from '../../../graphql/generated';
import { Account } from '../../../user/Account';
import { DistributionCertificate } from '../appstore/Credentials.types';
import { AppleTeamMissingError } from '../errors';
import { AppleAppIdentifierMutation } from './graphql/mutations/AppleAppIdentifierMutation';
import {
AppleDistributionCertificateMutation,
@@ -98,6 +99,25 @@ export async function createOrUpdateIosAppBuildCredentialsAsync(
}
export async function getIosAppCredentialsWithBuildCredentialsAsync(
appLookupParams: AppLookupParams,
{ iosDistributionType }: { iosDistributionType: IosDistributionType }
): Promise<IosAppCredentialsWithBuildCredentialsQueryResult | null> {
const { account, bundleIdentifier } = appLookupParams;
const appleAppIdentifier = await AppleAppIdentifierQuery.byBundleIdentifierAsync(
account.name,
bundleIdentifier
);
if (!appleAppIdentifier) {
return null;
}
const projectFullName = formatProjectFullName(appLookupParams);
return await IosAppCredentialsQuery.withBuildCredentialsByAppIdentifierIdAsync(projectFullName, {
appleAppIdentifierId: appleAppIdentifier.id,
iosDistributionType,
});
}
export async function getIosAppCredentialsWithCommonFieldsAsync(
appLookupParams: AppLookupParams
): Promise<CommonIosAppCredentialsFragment | null> {
const { account, bundleIdentifier } = appLookupParams;
@@ -126,12 +146,12 @@ export async function createOrGetExistingIosAppCredentialsWithBuildCredentialsAs
iosDistributionType: IosDistributionType;
}
): Promise<IosAppCredentialsWithBuildCredentialsQueryResult> {
const projectFullName = formatProjectFullName(appLookupParams);
const maybeIosAppCredentials = await IosAppCredentialsQuery.withBuildCredentialsByAppIdentifierIdAsync(
projectFullName,
{ appleAppIdentifierId, iosDistributionType }
const maybeIosAppCredentials = await getIosAppCredentialsWithBuildCredentialsAsync(
appLookupParams,
{
iosDistributionType,
}
);
if (maybeIosAppCredentials) {
return maybeIosAppCredentials;
} else {
@@ -144,6 +164,7 @@ export async function createOrGetExistingIosAppCredentialsWithBuildCredentialsAs
app.id,
appleAppIdentifier.id
);
const projectFullName = formatProjectFullName(appLookupParams);
return nullthrows(
await IosAppCredentialsQuery.withBuildCredentialsByAppIdentifierIdAsync(projectFullName, {
appleAppIdentifierId,
@@ -173,7 +194,7 @@ export async function createOrGetExistingAppleTeamAsync(
export async function createOrGetExistingAppleAppIdentifierAsync(
{ account, bundleIdentifier }: AppLookupParams,
appleTeam: AppleTeamFragment
appleTeam: AppleTeamFragment | null
): Promise<AppleAppIdentifierFragment> {
const appleAppIdentifier = await AppleAppIdentifierQuery.byBundleIdentifierAsync(
account.name,
@@ -182,6 +203,9 @@ export async function createOrGetExistingAppleAppIdentifierAsync(
if (appleAppIdentifier) {
return appleAppIdentifier;
} else {
if (!appleTeam) {
throw new AppleTeamMissingError();
}
return await AppleAppIdentifierMutation.createAppleAppIdentifierAsync(
{ bundleIdentifier, appleTeamId: appleTeam.id },
account.id
@@ -213,8 +237,8 @@ export async function createProvisioningProfileAsync(
export async function getProvisioningProfileAsync(
appLookupParams: AppLookupParams,
appleTeam: AppleTeamFragment,
iosDistributionType: IosDistributionType
iosDistributionType: IosDistributionType,
{ appleTeam }: { appleTeam: AppleTeamFragment | null } = { appleTeam: null }
): Promise<AppleProvisioningProfileQueryResult | null> {
const projectFullName = formatProjectFullName(appLookupParams);
const appleAppIdentifier = await createOrGetExistingAppleAppIdentifierAsync(
@@ -222,7 +246,7 @@ export async function getProvisioningProfileAsync(
appleTeam
);
return await AppleProvisioningProfileQuery.getForAppAsync(projectFullName, {
appleAppIdentifierId: appleAppIdentifier?.id,
appleAppIdentifierId: appleAppIdentifier.id,
iosDistributionType,
});
}
@@ -250,8 +274,8 @@ export async function deleteProvisioningProfilesAsync(
export async function getDistributionCertificateForAppAsync(
appLookupParams: AppLookupParams,
appleTeam: AppleTeamFragment,
iosDistributionType: IosDistributionType
iosDistributionType: IosDistributionType,
{ appleTeam }: { appleTeam: AppleTeamFragment | null } = { appleTeam: null }
): Promise<AppleDistributionCertificateFragment | null> {
const projectFullName = formatProjectFullName(appLookupParams);
const appleAppIdentifier = await createOrGetExistingAppleAppIdentifierAsync(
@@ -259,7 +283,7 @@ export async function getDistributionCertificateForAppAsync(
appleTeam
);
return await AppleDistributionCertificateQuery.getForAppAsync(projectFullName, {
appleAppIdentifierId: appleAppIdentifier?.id,
appleAppIdentifierId: appleAppIdentifier.id,
iosDistributionType,
});
}
@@ -0,0 +1,13 @@
export class AppleTeamMissingError extends Error {
constructor(message?: string) {
super(message ?? 'Apple Team is necessary to create Apple App Identifier');
}
}
export class MissingCredentialsNonInteractiveError extends Error {
constructor(message?: string) {
super(
message ?? 'Credentials are not set up. Please run this command again in interactive mode.'
);
}
}
@@ -40,7 +40,7 @@ export class ManageIosBeta implements Action {
}
if (ctx.hasProjectContext) {
const appLookupParams = ManageIosBeta.getAppLookupParamsFromContext(ctx);
const iosAppCredentials = await ctx.newIos.getIosAppCredentialsWithBuildCredentialsAsync(
const iosAppCredentials = await ctx.newIos.getIosAppCredentialsWithCommonFieldsAsync(
appLookupParams
);
if (!iosAppCredentials) {
+11 -1
View File
@@ -1,9 +1,12 @@
import { ExpoConfig, getConfig } from '@expo/config';
import AppStoreApi from '../credentials/ios/appstore/AppStoreApi';
import { findProjectRootAsync } from '../project/projectUtils';
import { Actor } from '../user/User';
export interface DeviceManagerContext {
appStore: AppStoreApi;
exp: ExpoConfig | null;
projectDir: string | null;
user: Actor;
}
@@ -17,9 +20,16 @@ export async function createContext({
cwd?: string;
user: Actor;
}): Promise<DeviceManagerContext> {
const projectDir = await findProjectRootAsync(cwd);
let exp: ExpoConfig | null = null;
if (projectDir) {
const config = getConfig(projectDir, { skipSDKVersionRequirement: true });
exp = config.exp;
}
return {
appStore,
projectDir: await findProjectRootAsync(cwd),
projectDir,
exp,
user,
};
}
+6 -5
View File
@@ -1,3 +1,4 @@
import { ExpoConfig } from '@expo/config';
import assert from 'assert';
import chalk from 'chalk';
@@ -39,16 +40,16 @@ export default class DeviceManager {
}
private async resolveAccountAsync(): Promise<Account> {
const resolver = new AccountResolver(this.ctx.projectDir, this.ctx.user);
const resolver = new AccountResolver(this.ctx.exp, this.ctx.user);
return await resolver.resolveAccountAsync();
}
}
export class AccountResolver {
constructor(private projectDir: string | null, private user: Actor) {}
constructor(private exp: ExpoConfig | null, private user: Actor) {}
public async resolveAccountAsync(): Promise<Account> {
if (this.projectDir) {
if (this.exp) {
const account = await this.resolveProjectAccountAsync();
if (account) {
return account;
@@ -58,9 +59,9 @@ export class AccountResolver {
}
private async resolveProjectAccountAsync(): Promise<Account | undefined> {
assert(this.projectDir, 'project directory is not set ');
assert(this.exp, 'expo config is not set');
const projectAccountName = await getProjectAccountNameAsync(this.projectDir);
const projectAccountName = await getProjectAccountNameAsync(this.exp);
const projectAccount = findAccountByName(this.user.accounts, projectAccountName);
if (!projectAccount) {
Log.warn(
+470 -195
View File
@@ -31,6 +31,8 @@ export type RootQuery = {
* @deprecated Not used.
*/
_doNotUse?: Maybe<Scalars['String']>;
/** fetch all updates in a group */
updatesByGroup: Array<Update>;
/** Top-level query object for querying Accounts. */
account: AccountQuery;
/** Top-level query object for querying Actors. */
@@ -88,6 +90,11 @@ export type RootQuery = {
};
export type RootQueryUpdatesByGroupArgs = {
group: Scalars['ID'];
};
export type RootQueryAppByAppIdArgs = {
appId: Scalars['String'];
};
@@ -126,24 +133,52 @@ export type RootQueryUserByUsernameArgs = {
username: Scalars['String'];
};
export type AccountQuery = {
__typename?: 'AccountQuery';
/** Query an Account by ID */
byId: Account;
/** Query an Account by name */
byName: Account;
export type Update = ActivityTimelineProjectActivity & {
__typename?: 'Update';
id: Scalars['ID'];
actor?: Maybe<Actor>;
activityTimestamp: Scalars['DateTime'];
branchId: Scalars['ID'];
platform: Scalars['String'];
manifestFragment: Scalars['String'];
runtimeVersion: Scalars['String'];
group: Scalars['String'];
updatedAt: Scalars['DateTime'];
createdAt: Scalars['DateTime'];
message?: Maybe<Scalars['String']>;
branch: UpdateBranch;
};
export type ActivityTimelineProjectActivity = {
id: Scalars['ID'];
actor?: Maybe<Actor>;
activityTimestamp: Scalars['DateTime'];
};
/** A user or robot that can authenticate with Expo services and be a member of accounts. */
export type Actor = {
id: Scalars['ID'];
firstName?: Maybe<Scalars['String']>;
created?: Maybe<Scalars['DateTime']>;
isExpoAdmin?: Maybe<Scalars['Boolean']>;
/** Associated accounts */
accounts: Array<Account>;
/** Access Tokens belonging to this actor */
accessTokens: Array<AccessToken>;
/**
* Server feature gate values for this actor, optionally filtering by desired gates.
* Only resolves for the viewer.
*/
featureGates: Scalars['JSONObject'];
};
export type AccountQueryByIdArgs = {
accountId: Scalars['String'];
/** A user or robot that can authenticate with Expo services and be a member of accounts. */
export type ActorFeatureGatesArgs = {
filter?: Maybe<Array<Scalars['String']>>;
};
export type AccountQueryByNameArgs = {
accountName: Scalars['String'];
};
/**
* An account is a container owning projects, credentials, billing and other organization
* data and settings. Actors may own and be members of accounts.
@@ -217,6 +252,7 @@ export type AccountSnacksArgs = {
export type AccountAppsArgs = {
offset: Scalars['Int'];
limit: Scalars['Int'];
includeUnpublished?: Maybe<Scalars['Boolean']>;
};
@@ -287,7 +323,6 @@ export type AccountEnvironmentSecretsArgs = {
filterNames?: Maybe<Array<Scalars['String']>>;
};
export type Offer = {
__typename?: 'Offer';
id: Scalars['ID'];
@@ -575,6 +610,7 @@ export type UserSnacksArgs = {
export type UserAppsArgs = {
offset: Scalars['Int'];
limit: Scalars['Int'];
includeUnpublished?: Maybe<Scalars['Boolean']>;
};
@@ -590,43 +626,6 @@ export type UserFeatureGatesArgs = {
filter?: Maybe<Array<Scalars['String']>>;
};
/** A user or robot that can authenticate with Expo services and be a member of accounts. */
export type Actor = {
id: Scalars['ID'];
firstName?: Maybe<Scalars['String']>;
created?: Maybe<Scalars['DateTime']>;
isExpoAdmin?: Maybe<Scalars['Boolean']>;
/** Associated accounts */
accounts: Array<Account>;
/** Access Tokens belonging to this actor */
accessTokens: Array<AccessToken>;
/**
* Server feature gate values for this actor, optionally filtering by desired gates.
* Only resolves for the viewer.
*/
featureGates: Scalars['JSONObject'];
};
/** A user or robot that can authenticate with Expo services and be a member of accounts. */
export type ActorFeatureGatesArgs = {
filter?: Maybe<Array<Scalars['String']>>;
};
/** A method of authentication for an Actor */
export type AccessToken = {
__typename?: 'AccessToken';
id: Scalars['ID'];
visibleTokenPrefix: Scalars['String'];
createdAt: Scalars['DateTime'];
updatedAt: Scalars['DateTime'];
revokedAt?: Maybe<Scalars['DateTime']>;
lastUsedAt?: Maybe<Scalars['DateTime']>;
owner: Actor;
note?: Maybe<Scalars['String']>;
};
/** A second factor device belonging to a User */
export type UserSecondFactorDevice = {
__typename?: 'UserSecondFactorDevice';
@@ -648,6 +647,19 @@ export enum SecondFactorMethod {
Sms = 'SMS'
}
/** A method of authentication for an Actor */
export type AccessToken = {
__typename?: 'AccessToken';
id: Scalars['ID'];
visibleTokenPrefix: Scalars['String'];
createdAt: Scalars['DateTime'];
updatedAt: Scalars['DateTime'];
revokedAt?: Maybe<Scalars['DateTime']>;
lastUsedAt?: Maybe<Scalars['DateTime']>;
owner: Actor;
note?: Maybe<Scalars['String']>;
};
/** An pending invitation sent to an email granting membership on an Account. */
export type UserInvitation = {
__typename?: 'UserInvitation';
@@ -679,6 +691,7 @@ export enum Role {
NotAdmin = 'NOT_ADMIN'
}
export enum AppPlatform {
Ios = 'IOS',
Android = 'ANDROID'
@@ -727,12 +740,9 @@ export type Build = ActivityTimelineProjectActivity & {
releaseChannel?: Maybe<Scalars['String']>;
metrics?: Maybe<BuildMetrics>;
distribution?: Maybe<DistributionType>;
};
export type ActivityTimelineProjectActivity = {
id: Scalars['ID'];
actor?: Maybe<Actor>;
activityTimestamp: Scalars['DateTime'];
buildProfile?: Maybe<Scalars['String']>;
gitCommitHash?: Maybe<Scalars['String']>;
error?: Maybe<BuildError>;
};
export type BuildArtifacts = {
@@ -753,6 +763,12 @@ export enum DistributionType {
Simulator = 'SIMULATOR'
}
export type BuildError = {
__typename?: 'BuildError';
errorCode?: Maybe<Scalars['String']>;
message?: Maybe<Scalars['String']>;
};
/** Represents an Standalone App build job */
export type BuildJob = ActivityTimelineProjectActivity & {
__typename?: 'BuildJob';
@@ -1060,22 +1076,6 @@ export type UpdateBranchUpdatesArgs = {
limit: Scalars['Int'];
};
export type Update = ActivityTimelineProjectActivity & {
__typename?: 'Update';
id: Scalars['ID'];
actor?: Maybe<Actor>;
activityTimestamp: Scalars['DateTime'];
branchId: Scalars['ID'];
platform: Scalars['String'];
manifestFragment: Scalars['String'];
runtimeVersion: Scalars['String'];
group: Scalars['String'];
updatedAt: Scalars['DateTime'];
createdAt: Scalars['DateTime'];
message?: Maybe<Scalars['String']>;
branch: UpdateBranch;
};
export type EnvironmentSecret = {
__typename?: 'EnvironmentSecret';
id: Scalars['ID'];
@@ -1149,6 +1149,24 @@ export type Charge = {
receiptUrl?: Maybe<Scalars['String']>;
};
export type AccountQuery = {
__typename?: 'AccountQuery';
/** Query an Account by ID */
byId: Account;
/** Query an Account by name */
byName: Account;
};
export type AccountQueryByIdArgs = {
accountId: Scalars['String'];
};
export type AccountQueryByNameArgs = {
accountName: Scalars['String'];
};
export type ActorQuery = {
__typename?: 'ActorQuery';
/** Query an Actor by ID */
@@ -1573,19 +1591,9 @@ export type DeleteAccessTokenResult = {
export type AccountMutation = {
__typename?: 'AccountMutation';
/**
* Add user to an account
* @deprecated Use 'grantActorPermissions' mutation.
*/
grantAccess?: Maybe<Account>;
/**
* Revoke permissions for a given user
* @deprecated Use 'revokeActorPermissions' mutation.
*/
revokeAccess?: Maybe<Account>;
/** Add specified account Permissions for Actor */
/** Add specified account Permissions for Actor. Actor must already have at least one permission on the account. */
grantActorPermissions?: Maybe<Account>;
/** Revoke specified Permissions for Actor */
/** Revoke specified Permissions for Actor. Actor must already have at least one permission on the account. */
revokeActorPermissions?: Maybe<Account>;
/** Add a subscription */
subscribeToProduct?: Maybe<Account>;
@@ -1603,8 +1611,6 @@ export type AccountMutation = {
setBuildAutoRenew?: Maybe<Account>;
/** Set payment details */
setPaymentSource?: Maybe<Account>;
/** Extend offer to account */
extendOffer?: Maybe<Account>;
/** Send an email to primary account email */
sendEmail?: Maybe<Account>;
/** Require authorization to send push notifications for experiences owned by this account */
@@ -1612,20 +1618,6 @@ export type AccountMutation = {
};
export type AccountMutationGrantAccessArgs = {
accountName: Scalars['ID'];
toUser: Scalars['ID'];
accessLevels?: Maybe<Array<Maybe<Permission>>>;
};
export type AccountMutationRevokeAccessArgs = {
accountName: Scalars['ID'];
fromUser: Scalars['ID'];
accessLevels?: Maybe<Array<Maybe<Permission>>>;
};
export type AccountMutationGrantActorPermissionsArgs = {
accountID: Scalars['ID'];
actorID: Scalars['ID'];
@@ -1672,13 +1664,6 @@ export type AccountMutationSetPaymentSourceArgs = {
};
export type AccountMutationExtendOfferArgs = {
accountName: Scalars['ID'];
offer: StandardOffer;
suppressMessage?: Maybe<Scalars['Boolean']>;
};
export type AccountMutationSendEmailArgs = {
accountName: Scalars['ID'];
emailTemplate: EmailTemplate;
@@ -1690,15 +1675,6 @@ export type AccountMutationSetPushSecurityEnabledArgs = {
pushSecurityEnabled: Scalars['Boolean'];
};
export enum StandardOffer {
/** $29 USD per month, 30 day trial */
Default = 'DEFAULT',
/** $29 USD per month, 1 year trial */
YcDeals = 'YC_DEALS',
/** $800 USD per month */
Support = 'SUPPORT'
}
export enum EmailTemplate {
/** Able to purchase Developer Services */
DevServicesOfferExtended = 'DEV_SERVICES_OFFER_EXTENDED',
@@ -2352,6 +2328,11 @@ export type UserInvitationMutation = {
resendUserInvitation: UserInvitation;
/** Rescind UserInvitation by ID */
deleteUserInvitation: RescindUserInvitationResult;
/**
* Delete UserInvitation by token. Note that the viewer's email is not required to match
* the email on the invitation.
*/
deleteUserInvitationByToken: RescindUserInvitationResult;
/** Accept UserInvitation by ID. Viewer must have matching email and email must be verified. */
acceptUserInvitationAsViewer: AcceptUserInvitationResult;
/**
@@ -2380,6 +2361,11 @@ export type UserInvitationMutationDeleteUserInvitationArgs = {
};
export type UserInvitationMutationDeleteUserInvitationByTokenArgs = {
token: Scalars['ID'];
};
export type UserInvitationMutationAcceptUserInvitationAsViewerArgs = {
id: Scalars['ID'];
};
@@ -2665,6 +2651,15 @@ export type DeleteEnvironmentSecretResult = {
id: Scalars['ID'];
};
export enum StandardOffer {
/** $29 USD per month, 30 day trial */
Default = 'DEFAULT',
/** $29 USD per month, 1 year trial */
YcDeals = 'YC_DEALS',
/** $800 USD per month */
Support = 'SUPPORT'
}
export type BaseSearchResult = SearchResult & {
__typename?: 'BaseSearchResult';
/** @deprecated Use SearchResult instead */
@@ -2709,6 +2704,303 @@ export enum CacheControlScope {
}
export type CreateUpdateBranchForAppMutationVariables = Exact<{
appId: Scalars['ID'];
name: Scalars['String'];
}>;
export type CreateUpdateBranchForAppMutation = (
{ __typename?: 'RootMutation' }
& { updateBranch: (
{ __typename?: 'UpdateBranchMutation' }
& { createUpdateBranchForApp?: Maybe<(
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
)> }
) }
);
export type GetBranchInfoQueryVariables = Exact<{
appId: Scalars['String'];
name: Scalars['String'];
}>;
export type GetBranchInfoQuery = (
{ __typename?: 'RootQuery' }
& { app?: Maybe<(
{ __typename?: 'AppQuery' }
& { byId: (
{ __typename?: 'App' }
& Pick<App, 'id'>
& { updateBranchByName: (
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
) }
) }
)> }
);
export type DeleteUpdateBranchMutationVariables = Exact<{
branchId: Scalars['ID'];
}>;
export type DeleteUpdateBranchMutation = (
{ __typename?: 'RootMutation' }
& { updateBranch: (
{ __typename?: 'UpdateBranchMutation' }
& { deleteUpdateBranch: (
{ __typename?: 'DeleteUpdateBranchResult' }
& Pick<DeleteUpdateBranchResult, 'id'>
) }
) }
);
export type BranchesByAppQueryVariables = Exact<{
fullName: Scalars['String'];
limit: Scalars['Int'];
}>;
export type BranchesByAppQuery = (
{ __typename?: 'RootQuery' }
& { app?: Maybe<(
{ __typename?: 'AppQuery' }
& { byFullName: (
{ __typename?: 'App' }
& Pick<App, 'id' | 'fullName'>
& { updateBranches: Array<(
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
& { updates: Array<(
{ __typename?: 'Update' }
& Pick<Update, 'id' | 'updatedAt' | 'message'>
& { actor?: Maybe<(
{ __typename: 'User' }
& Pick<User, 'username' | 'id'>
) | (
{ __typename: 'Robot' }
& Pick<Robot, 'firstName' | 'id'>
)> }
)> }
)> }
) }
)> }
);
export type GetUpdateGroupAsyncQueryVariables = Exact<{
group: Scalars['ID'];
}>;
export type GetUpdateGroupAsyncQuery = (
{ __typename?: 'RootQuery' }
& { updatesByGroup: Array<(
{ __typename?: 'Update' }
& Pick<Update, 'id' | 'group' | 'runtimeVersion' | 'manifestFragment' | 'platform' | 'message'>
)> }
);
export type EditUpdateBranchMutationVariables = Exact<{
input: EditUpdateBranchInput;
}>;
export type EditUpdateBranchMutation = (
{ __typename?: 'RootMutation' }
& { updateBranch: (
{ __typename?: 'UpdateBranchMutation' }
& { editUpdateBranch: (
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
) }
) }
);
export type ViewBranchQueryVariables = Exact<{
appId: Scalars['String'];
name: Scalars['String'];
limit: Scalars['Int'];
}>;
export type ViewBranchQuery = (
{ __typename?: 'RootQuery' }
& { app?: Maybe<(
{ __typename?: 'AppQuery' }
& { byId: (
{ __typename?: 'App' }
& Pick<App, 'id'>
& { updateBranchByName: (
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
& { updates: Array<(
{ __typename?: 'Update' }
& Pick<Update, 'id' | 'group' | 'message' | 'createdAt' | 'runtimeVersion' | 'platform' | 'manifestFragment'>
& { actor?: Maybe<(
{ __typename?: 'User' }
& Pick<User, 'firstName' | 'id'>
) | (
{ __typename?: 'Robot' }
& Pick<Robot, 'firstName' | 'id'>
)> }
)> }
) }
) }
)> }
);
export type CancelBuildMutationVariables = Exact<{
buildId: Scalars['ID'];
}>;
export type CancelBuildMutation = (
{ __typename?: 'RootMutation' }
& { build?: Maybe<(
{ __typename?: 'BuildMutation' }
& { cancel: (
{ __typename?: 'Build' }
& Pick<Build, 'id' | 'status'>
) }
)> }
);
export type CreateUpdateChannelOnAppMutationVariables = Exact<{
appId: Scalars['ID'];
name: Scalars['String'];
branchMapping: Scalars['String'];
}>;
export type CreateUpdateChannelOnAppMutation = (
{ __typename?: 'RootMutation' }
& { updateChannel: (
{ __typename?: 'UpdateChannelMutation' }
& { createUpdateChannelForApp?: Maybe<(
{ __typename?: 'UpdateChannel' }
& Pick<UpdateChannel, 'id' | 'name' | 'branchMapping'>
)> }
) }
);
export type GetChannelByNameToEditQueryVariables = Exact<{
appId: Scalars['String'];
channelName: Scalars['String'];
}>;
export type GetChannelByNameToEditQuery = (
{ __typename?: 'RootQuery' }
& { app?: Maybe<(
{ __typename?: 'AppQuery' }
& { byId: (
{ __typename?: 'App' }
& Pick<App, 'id'>
& { updateChannelByName: (
{ __typename?: 'UpdateChannel' }
& Pick<UpdateChannel, 'id' | 'name'>
& { updateBranches: Array<(
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
)> }
) }
) }
)> }
);
export type UpdateChannelBranchMappingMutationVariables = Exact<{
channelId: Scalars['ID'];
branchMapping: Scalars['String'];
}>;
export type UpdateChannelBranchMappingMutation = (
{ __typename?: 'RootMutation' }
& { updateChannel: (
{ __typename?: 'UpdateChannelMutation' }
& { editUpdateChannel?: Maybe<(
{ __typename?: 'UpdateChannel' }
& Pick<UpdateChannel, 'id' | 'name' | 'branchMapping'>
)> }
) }
);
export type GetAllChannelsForAppQueryVariables = Exact<{
appId: Scalars['String'];
offset: Scalars['Int'];
limit: Scalars['Int'];
}>;
export type GetAllChannelsForAppQuery = (
{ __typename?: 'RootQuery' }
& { app?: Maybe<(
{ __typename?: 'AppQuery' }
& { byId: (
{ __typename?: 'App' }
& Pick<App, 'id'>
& { updateChannels: Array<(
{ __typename?: 'UpdateChannel' }
& Pick<UpdateChannel, 'id' | 'name'>
& { updateBranches: Array<(
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
& { updates: Array<(
{ __typename?: 'Update' }
& Pick<Update, 'id' | 'group' | 'message' | 'createdAt'>
& { actor?: Maybe<(
{ __typename?: 'User' }
& Pick<User, 'firstName' | 'id'>
) | (
{ __typename?: 'Robot' }
& Pick<Robot, 'firstName' | 'id'>
)> }
)> }
)> }
)> }
) }
)> }
);
export type GetChannelByNameForAppQueryVariables = Exact<{
appId: Scalars['String'];
channelName: Scalars['String'];
}>;
export type GetChannelByNameForAppQuery = (
{ __typename?: 'RootQuery' }
& { app?: Maybe<(
{ __typename?: 'AppQuery' }
& { byId: (
{ __typename?: 'App' }
& Pick<App, 'id'>
& { updateChannelByName: (
{ __typename?: 'UpdateChannel' }
& Pick<UpdateChannel, 'id' | 'name' | 'createdAt'>
& { updateBranches: Array<(
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
& { updates: Array<(
{ __typename?: 'Update' }
& Pick<Update, 'id' | 'group' | 'message' | 'createdAt'>
& { actor?: Maybe<(
{ __typename?: 'User' }
& Pick<User, 'firstName' | 'id'>
) | (
{ __typename?: 'Robot' }
& Pick<Robot, 'firstName' | 'id'>
)> }
)> }
)> }
) }
) }
)> }
);
export type CreateAppleAppIdentifierMutationVariables = Exact<{
appleAppIdentifierInput: AppleAppIdentifierInput;
accountId: Scalars['ID'];
@@ -3315,42 +3607,53 @@ export type CommonIosAppCredentialsWithBuildCredentialsByAppIdentifierIdQuery =
)> }
);
export type CreateUpdateChannelForAppMutationVariables = Exact<{
appId: Scalars['ID'];
name: Scalars['String'];
branchMapping: Scalars['String'];
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
input: CreateEnvironmentSecretInput;
accountId: Scalars['String'];
}>;
export type CreateUpdateChannelForAppMutation = (
export type CreateEnvironmentSecretForAccountMutation = (
{ __typename?: 'RootMutation' }
& { updateChannel: (
{ __typename?: 'UpdateChannelMutation' }
& { createUpdateChannelForApp?: Maybe<(
{ __typename?: 'UpdateChannel' }
& Pick<UpdateChannel, 'id' | 'name' | 'branchMapping'>
)> }
& { environmentSecret: (
{ __typename?: 'EnvironmentSecretMutation' }
& { createEnvironmentSecretForAccount: (
{ __typename?: 'EnvironmentSecret' }
& EnvironmentSecretFragment
) }
) }
);
export type UpdateChannelBranchMappingMutationVariables = Exact<{
channelId: Scalars['ID'];
branchMapping: Scalars['String'];
export type CreateEnvironmentSecretForAppMutationVariables = Exact<{
input: CreateEnvironmentSecretInput;
appId: Scalars['String'];
}>;
export type UpdateChannelBranchMappingMutation = (
export type CreateEnvironmentSecretForAppMutation = (
{ __typename?: 'RootMutation' }
& { updateChannel: (
{ __typename?: 'UpdateChannelMutation' }
& { editUpdateChannel?: Maybe<(
{ __typename?: 'UpdateChannel' }
& Pick<UpdateChannel, 'id' | 'name' | 'createdAt' | 'branchMapping'>
& { updateBranches: Array<(
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
)> }
)> }
& { environmentSecret: (
{ __typename?: 'EnvironmentSecretMutation' }
& { createEnvironmentSecretForApp: (
{ __typename?: 'EnvironmentSecret' }
& EnvironmentSecretFragment
) }
) }
);
export type DeleteEnvironmentSecretMutationVariables = Exact<{
id: Scalars['String'];
}>;
export type DeleteEnvironmentSecretMutation = (
{ __typename?: 'RootMutation' }
& { environmentSecret: (
{ __typename?: 'EnvironmentSecretMutation' }
& { deleteEnvironmentSecret: (
{ __typename?: 'DeleteEnvironmentSecretResult' }
& Pick<DeleteEnvironmentSecretResult, 'id'>
) }
) }
);
@@ -3454,74 +3757,41 @@ export type PendingBuildsForAccountAndPlatformQuery = (
) }
);
export type GetChannelByNameForApp123213QueryVariables = Exact<{
appId: Scalars['String'];
name: Scalars['String'];
export type EnvironmentSecretsByAccountNameQueryVariables = Exact<{
accountName: Scalars['String'];
}>;
export type GetChannelByNameForApp123213Query = (
export type EnvironmentSecretsByAccountNameQuery = (
{ __typename?: 'RootQuery' }
& { app?: Maybe<(
{ __typename?: 'AppQuery' }
& { byId: (
{ __typename?: 'App' }
& Pick<App, 'id' | 'name'>
& { updateChannelByName: (
{ __typename?: 'UpdateChannel' }
& Pick<UpdateChannel, 'id' | 'name' | 'createdAt'>
& { updateBranches: Array<(
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
& { updates: Array<(
{ __typename?: 'Update' }
& Pick<Update, 'id' | 'group' | 'message' | 'createdAt'>
& { actor?: Maybe<(
{ __typename?: 'User' }
& Pick<User, 'firstName' | 'id'>
) | (
{ __typename?: 'Robot' }
& Pick<Robot, 'firstName' | 'id'>
)> }
)> }
)> }
) }
& { account: (
{ __typename?: 'AccountQuery' }
& { byName: (
{ __typename?: 'Account' }
& Pick<Account, 'id'>
& { environmentSecrets: Array<(
{ __typename?: 'EnvironmentSecret' }
& EnvironmentSecretFragment
)> }
) }
)> }
) }
);
export type ListChannelsForAppQueryVariables = Exact<{
appId: Scalars['String'];
offset: Scalars['Int'];
limit: Scalars['Int'];
export type EnvironmentSecretsByAppFullNameQueryVariables = Exact<{
fullName: Scalars['String'];
}>;
export type ListChannelsForAppQuery = (
export type EnvironmentSecretsByAppFullNameQuery = (
{ __typename?: 'RootQuery' }
& { app?: Maybe<(
{ __typename?: 'AppQuery' }
& { byId: (
& { byFullName: (
{ __typename?: 'App' }
& Pick<App, 'id' | 'name'>
& { updateChannels: Array<(
{ __typename?: 'UpdateChannel' }
& Pick<UpdateChannel, 'id' | 'name'>
& { updateBranches: Array<(
{ __typename?: 'UpdateBranch' }
& Pick<UpdateBranch, 'id' | 'name'>
& { updates: Array<(
{ __typename?: 'Update' }
& Pick<Update, 'id' | 'group' | 'message' | 'createdAt'>
& { actor?: Maybe<(
{ __typename?: 'User' }
& Pick<User, 'firstName' | 'id'>
) | (
{ __typename?: 'Robot' }
& Pick<Robot, 'firstName' | 'id'>
)> }
)> }
)> }
& Pick<App, 'id'>
& { environmentSecrets: Array<(
{ __typename?: 'EnvironmentSecret' }
& EnvironmentSecretFragment
)> }
) }
)> }
@@ -3590,6 +3860,11 @@ export type AppFragment = (
& Pick<App, 'id' | 'fullName' | 'slug'>
);
export type EnvironmentSecretFragment = (
{ __typename?: 'EnvironmentSecret' }
& Pick<EnvironmentSecret, 'id' | 'name' | 'createdAt'>
);
export type AppleAppIdentifierFragment = (
{ __typename?: 'AppleAppIdentifier' }
& Pick<AppleAppIdentifier, 'id' | 'bundleIdentifier'>
@@ -0,0 +1,93 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
CreateEnvironmentSecretForAccountMutation,
CreateEnvironmentSecretForAppMutation,
DeleteEnvironmentSecretMutation,
EnvironmentSecretFragment,
} from '../generated';
import { EnvironmentSecretFragmentNode } from '../types/EnvironmentSecret';
export const EnvironmentSecretMutation = {
async createForAccount(
input: { name: string; value: string },
accountId: string
): Promise<EnvironmentSecretFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateEnvironmentSecretForAccountMutation>(
gql`
mutation CreateEnvironmentSecretForAccount(
$input: CreateEnvironmentSecretInput!
$accountId: String!
) {
environmentSecret {
createEnvironmentSecretForAccount(
environmentSecretData: $input
accountId: $accountId
) {
id
...EnvironmentSecretFragment
}
}
}
${print(EnvironmentSecretFragmentNode)}
`,
{ input, accountId }
)
.toPromise()
);
return data.environmentSecret.createEnvironmentSecretForAccount;
},
async createForApp(
input: { name: string; value: string },
appId: string
): Promise<EnvironmentSecretFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<CreateEnvironmentSecretForAppMutation>(
gql`
mutation CreateEnvironmentSecretForApp(
$input: CreateEnvironmentSecretInput!
$appId: String!
) {
environmentSecret {
createEnvironmentSecretForApp(environmentSecretData: $input, appId: $appId) {
id
...EnvironmentSecretFragment
}
}
}
${print(EnvironmentSecretFragmentNode)}
`,
{ input, appId }
)
.toPromise()
);
return data.environmentSecret.createEnvironmentSecretForApp;
},
async delete(id: string): Promise<{ id: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<DeleteEnvironmentSecretMutation>(
gql`
mutation DeleteEnvironmentSecret($id: String!) {
environmentSecret {
deleteEnvironmentSecret(id: $id) {
id
}
}
}
`,
{ id }
)
.toPromise()
);
return data.environmentSecret.deleteEnvironmentSecret;
},
};
@@ -31,7 +31,7 @@ const PublishMutation = {
async publishUpdateGroupAsync(
publishUpdateGroupInput: PublishUpdateGroupInput
): Promise<Pick<Update, 'id' | 'group'>> {
): Promise<Pick<Update, 'group'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UpdatePublishMutation>(
@@ -49,7 +49,8 @@ const PublishMutation = {
)
.toPromise()
);
return data.updateBranch.publishUpdateGroup[0]!;
const { group } = data.updateBranch.publishUpdateGroup[0]!;
return { group };
},
};
@@ -0,0 +1,63 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
EnvironmentSecretFragment,
EnvironmentSecretsByAccountNameQuery,
EnvironmentSecretsByAppFullNameQuery,
} from '../generated';
import { EnvironmentSecretFragmentNode } from '../types/EnvironmentSecret';
export const EnvironmentSecretsQuery = {
async byAcccountNameAsync(accountName: string): Promise<EnvironmentSecretFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<EnvironmentSecretsByAccountNameQuery>(
gql`
query EnvironmentSecretsByAccountName($accountName: String!) {
account {
byName(accountName: $accountName) {
id
environmentSecrets {
id
...EnvironmentSecretFragment
}
}
}
}
${print(EnvironmentSecretFragmentNode)}
`,
{ accountName }
)
.toPromise()
);
return data.account.byName.environmentSecrets;
},
async byAppFullNameAsync(fullName: string): Promise<EnvironmentSecretFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<EnvironmentSecretsByAppFullNameQuery>(
gql`
query EnvironmentSecretsByAppFullName($fullName: String!) {
app {
byFullName(fullName: $fullName) {
id
environmentSecrets {
id
...EnvironmentSecretFragment
}
}
}
}
${print(EnvironmentSecretFragmentNode)}
`,
{ fullName }
)
.toPromise()
);
return data.app?.byFullName.environmentSecrets ?? [];
},
};
@@ -0,0 +1,9 @@
import gql from 'graphql-tag';
export const EnvironmentSecretFragmentNode = gql`
fragment EnvironmentSecretFragment on EnvironmentSecret {
id
name
createdAt
}
`;
+9 -4
View File
@@ -78,15 +78,20 @@ export default class Log {
}
/**
* Format links as dim with an underline.
* Format links as dim (unless disabled) with an underline.
*
* @example Learn more: https://expo.io
* @param url
*/
export function learnMore(url: string, learnMoreMessage?: string): string {
export function learnMore(
url: string,
{ learnMoreMessage, dim = true }: { learnMoreMessage?: string; dim?: boolean } = {}
): string {
// Links can be disabled via env variables https://github.com/jamestalmage/supports-hyperlinks/blob/master/index.js
if (terminalLink.isSupported) {
return chalk.dim(terminalLink(learnMoreMessage ?? 'Learn more.', url));
const text = terminalLink(learnMoreMessage ?? 'Learn more.', url);
return dim ? chalk.dim(text) : text;
}
return chalk.dim(`${learnMoreMessage ?? 'Learn more'}: ${chalk.underline(url)}`);
const text = `${learnMoreMessage ?? 'Learn more'}: ${chalk.underline(url)}`;
return dim ? chalk.dim(text) : text;
}
@@ -1,4 +1,3 @@
import { getConfig } from '@expo/config';
import { vol } from 'memfs';
import { asMock } from '../../__tests__/utils';
@@ -64,6 +63,7 @@ describe(getProjectAccountName, () => {
__typename: 'User',
id: 'userId',
username: 'notbrent',
accounts: [],
});
expect(projectAccountName).toBe(expWithOwner.owner);
});
@@ -73,6 +73,7 @@ describe(getProjectAccountName, () => {
__typename: 'Robot',
id: 'userId',
firstName: 'notauser',
accounts: [],
});
expect(projectAccountName).toBe(expWithOwner.owner);
});
@@ -82,6 +83,7 @@ describe(getProjectAccountName, () => {
__typename: 'User',
id: 'userId',
username: 'dominik',
accounts: [],
});
expect(projectAccountName).toBe('dominik');
});
@@ -92,23 +94,21 @@ describe(getProjectAccountName, () => {
__typename: 'Robot',
id: 'userId',
firstName: 'notauser',
accounts: [],
});
expect(resolveProjectAccountName).toThrow('manifest property is required');
});
});
describe(getProjectAccountNameAsync, () => {
const expWithOwner: any = { owner: 'dominik' };
const expWithoutOwner: any = {};
beforeEach(() => {
asMock(getConfig).mockReset();
asMock(getUserAsync).mockReset();
});
it(`returns the owner field's value from app.json / app.config.js`, async () => {
asMock(getConfig).mockImplementation(() => ({
exp: {
owner: 'dominik',
},
}));
asMock(getUserAsync).mockImplementation((): Actor | undefined => ({
__typename: 'User',
id: 'user_id',
@@ -119,14 +119,11 @@ describe(getProjectAccountNameAsync, () => {
],
}));
const projectAccountName = await getProjectAccountNameAsync('/app');
const projectAccountName = await getProjectAccountNameAsync(expWithOwner);
expect(projectAccountName).toBe('dominik');
});
it(`returns the username if owner field is not set in app.json / app.config.js`, async () => {
asMock(getConfig).mockImplementation(() => ({
exp: {},
}));
asMock(getUserAsync).mockImplementation((): Actor | undefined => ({
__typename: 'User',
id: 'user_id',
@@ -137,25 +134,19 @@ describe(getProjectAccountNameAsync, () => {
],
}));
const projectAccountName = await getProjectAccountNameAsync('/app');
const projectAccountName = await getProjectAccountNameAsync(expWithoutOwner);
expect(projectAccountName).toBe('notnotbrent');
});
it(`throws an error if the user is not logged in`, async () => {
asMock(getConfig).mockImplementation(() => ({
exp: {
owner: 'dominik',
},
}));
asMock(getUserAsync).mockImplementation((): Actor | undefined => undefined);
await expect(getProjectAccountNameAsync('/app')).rejects.toThrow(/Failed to access user data/);
await expect(getProjectAccountNameAsync(expWithOwner)).rejects.toThrow(
/Failed to access user data/
);
});
it(`throws when project owner is undefined for robot actors`, async () => {
asMock(getConfig).mockImplementation(() => ({
exp: {},
}));
asMock(getUserAsync).mockImplementation((): Actor | undefined => ({
__typename: 'Robot',
id: 'user_id',
@@ -165,7 +156,7 @@ describe(getProjectAccountNameAsync, () => {
{ id: 'account_id_2', name: 'dominik' },
],
}));
await expect(getProjectAccountNameAsync('/app')).rejects.toThrow(
await expect(getProjectAccountNameAsync(expWithoutOwner)).rejects.toThrow(
'manifest property is required'
);
});
+24 -19
View File
@@ -1,4 +1,4 @@
import { ExpoConfig, getConfig, getConfigFilePaths } from '@expo/config';
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { AndroidConfig, IOSConfig } from '@expo/config-plugins';
import { Platform } from '@expo/eas-build-job';
import fs from 'fs-extra';
@@ -26,8 +26,7 @@ export function getProjectAccountName(exp: ExpoConfig, user: Actor): string {
}
}
export async function getProjectAccountNameAsync(projectDir: string): Promise<string> {
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
export async function getProjectAccountNameAsync(exp: ExpoConfig): Promise<string> {
const user = await ensureLoggedInAsync();
return getProjectAccountName(exp, user);
}
@@ -37,8 +36,7 @@ export async function findProjectRootAsync(cwd?: string): Promise<string | null>
return projectRootDir ?? null;
}
export async function getProjectIdAsync(projectDir: string): Promise<string> {
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
export async function getProjectIdAsync(exp: ExpoConfig): Promise<string> {
return await ensureProjectExistsAsync({
accountName: getProjectAccountName(exp, await ensureLoggedInAsync()),
projectName: exp.slug,
@@ -46,10 +44,8 @@ export async function getProjectIdAsync(projectDir: string): Promise<string> {
});
}
export async function getProjectFullNameAsync(projectDir: string): Promise<string> {
const accountName = await getProjectAccountNameAsync(projectDir);
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
export async function getProjectFullNameAsync(exp: ExpoConfig): Promise<string> {
const accountName = await getProjectAccountNameAsync(exp);
return `@${accountName}/${exp.slug}`;
}
@@ -65,11 +61,15 @@ export async function getAndroidApplicationIdAsync(projectDir: string): Promise<
return matchResult?.[1] ?? null;
}
export async function getAppIdentifierAsync(
projectDir: string,
platform: Platform
): Promise<string | null> {
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
export async function getAppIdentifierAsync({
projectDir,
platform,
exp,
}: {
projectDir: string;
platform: Platform;
exp: ExpoConfig;
}): Promise<string | null> {
switch (platform) {
case Platform.ANDROID: {
const packageNameFromConfig = AndroidConfig.Package.getPackage(exp);
@@ -89,11 +89,16 @@ export async function getAppIdentifierAsync(
}
}
export async function ensureAppIdentifierIsDefinedAsync(
projectDir: string,
platform: Platform
): Promise<string> {
const appIdentifier = await getAppIdentifierAsync(projectDir, platform);
export async function ensureAppIdentifierIsDefinedAsync({
projectDir,
platform,
exp,
}: {
projectDir: string;
platform: Platform;
exp: ExpoConfig;
}): Promise<string> {
const appIdentifier = await getAppIdentifierAsync({ projectDir, platform, exp });
if (!appIdentifier) {
const desc = getProjectConfigDescription(projectDir);
const fieldStr = platform === Platform.ANDROID ? 'android.package' : 'ios.bundleIdentifier';
@@ -21,7 +21,7 @@ abstract class BaseSubmitter<SubmissionContext, SubmissionOptions> {
protected async startSubmissionAsync(
submissionConfig: SubmissionConfig,
verbose: boolean = false
) {
): Promise<SubmissionStatus> {
Log.addNewLineIfNone();
const scheduleSpinner = ora('Scheduling submission').start();
let submissionId: string;
@@ -65,6 +65,7 @@ abstract class BaseSubmitter<SubmissionContext, SubmissionOptions> {
}
await displayLogs(submission, submissionStatus, verbose);
return submissionStatus ?? SubmissionStatus.ERRORED;
}
private getStatusText(status: SubmissionStatus): string {
@@ -8,7 +8,11 @@ import {
ArchiveType,
SubmissionPlatform,
} from '../types';
import { printSummary } from '../utils/summary';
import {
ArchiveSourceSummaryFields,
formatArchiveSourceSummary,
printSummary,
} from '../utils/summary';
import { AndroidPackageSource, getAndroidPackageAsync } from './AndroidPackageSource';
import { AndroidSubmissionConfig, ReleaseStatus, ReleaseTrack } from './AndroidSubmissionConfig';
import { ServiceAccountSource, getServiceAccountAsync } from './ServiceAccountSource';
@@ -35,10 +39,14 @@ class AndroidSubmitter extends BaseSubmitter<AndroidSubmissionContext, AndroidSu
async submitAsync(): Promise<void> {
const resolvedSourceOptions = await this.resolveSourceOptions();
const submissionConfig = await this.formatSubmissionConfigAndPrintSummary(
this.options,
resolvedSourceOptions
const submissionConfig = await this.formatSubmissionConfig(this.options, resolvedSourceOptions);
printSummary(
this.prepareSummaryData(this.options, resolvedSourceOptions),
SummaryHumanReadableKeys,
SummaryHumanReadableValues
);
await this.startSubmissionAsync(submissionConfig, this.ctx.commandFlags.verbose);
}
@@ -53,71 +61,66 @@ class AndroidSubmitter extends BaseSubmitter<AndroidSubmissionContext, AndroidSu
};
}
private async formatSubmissionConfigAndPrintSummary(
private async formatSubmissionConfig(
options: AndroidSubmissionOptions,
{ archive, androidPackage, serviceAccountPath }: ResolvedSourceOptions
): Promise<AndroidSubmissionConfig> {
const serviceAccount = await fs.readFile(serviceAccountPath, 'utf-8');
const { track, releaseStatus, projectId } = options;
const submissionConfig = {
// structuring order affects table rows order
return {
androidPackage,
archiveUrl: archive.location,
archiveType: archive.type as AndroidArchiveType,
track,
releaseStatus,
projectId,
serviceAccount,
};
}
printSummaryAndroid({
...submissionConfig,
private prepareSummaryData(
options: AndroidSubmissionOptions,
{ archive, androidPackage, serviceAccountPath }: ResolvedSourceOptions
): Summary {
const { projectId, track, releaseStatus } = options;
return {
projectId,
androidPackage,
track,
releaseStatus,
archiveType: archive.type as AndroidArchiveType,
...formatArchiveSourceSummary(archive),
serviceAccountPath,
});
return { ...submissionConfig, serviceAccount };
};
}
}
/**
* Log the summary as a table. Exported for testing locally.
*
* @example
* printSummaryAndroid({
* androidPackage: 'com.expo.demoapp',
* archivePath: '/Users/example/Documents/863f9337-65d2-40c6-acb3-c1054c5c09f8.apk',
* archiveUrl: 'https://turtle-v2-artifacts.s3.amazonaws.com/ios/6420592d-5b5d-439b-aed4-ccd278647138-ca4145d8468947df9ded737248a1a238.aab',
* archiveType: 'apk' as any,
* serviceAccountPath: '/Users/example/Documents/gsa.json',
* track: ReleaseTrack.production,
* releaseStatus: ReleaseStatus.completed,
* projectId: '863f9337-65d2-40c6-acb3-c1054c5c09f8',
* });
* @param submissionConfig
*/
export function printSummaryAndroid(submissionConfig: Summary) {
printSummary(submissionConfig, SummaryHumanReadableKeys, SummaryHumanReadableValues);
}
interface Summary {
type Summary = {
androidPackage: string;
archivePath?: string;
archiveUrl?: string;
archiveType: ArchiveType;
serviceAccountPath: string;
track: ReleaseTrack;
releaseStatus?: ReleaseStatus;
projectId?: string;
}
} & ArchiveSourceSummaryFields;
const SummaryHumanReadableKeys: Record<keyof Summary, string> = {
androidPackage: 'Android package',
archivePath: 'Archive path',
archiveUrl: 'Download URL',
archiveType: 'Archive type',
serviceAccountPath: 'Google Service Account',
buildId: 'Build ID',
serviceAccountPath: 'Google Service Key',
track: 'Release track',
releaseStatus: 'Release status',
projectId: 'Project ID',
};
const SummaryHumanReadableValues: Partial<Record<keyof Summary, Function>> = {};
const SummaryHumanReadableValues: Partial<Record<keyof Summary, Function>> = {
archiveType: (type: string) => type.toUpperCase(),
};
export default AndroidSubmitter;
@@ -53,6 +53,11 @@ interface ArchiveFilePromptSource extends ArchiveFileSourceBase {
projectDir: string;
}
interface ResolvedArchive {
location: string;
realSource: ArchiveFileSource;
}
export type ArchiveFileSource =
| ArchiveFileUrlSource
| ArchiveFileLatestSource
@@ -60,25 +65,31 @@ export type ArchiveFileSource =
| ArchiveFileBuildIdSource
| ArchiveFilePromptSource;
export async function getArchiveFileLocationAsync(source: ArchiveFileSource): Promise<string> {
export async function getArchiveFileLocationAsync(
source: ArchiveFileSource
): Promise<ResolvedArchive> {
switch (source.sourceType) {
case ArchiveFileSourceType.prompt:
return await handlePromptSourceAsync(source);
case ArchiveFileSourceType.url: {
const url = await handleUrlSourceAsync(source);
return await getArchiveLocationForUrlAsync(url);
const urlArchive = await handleUrlSourceAsync(source);
const resolvedUrl = await getArchiveLocationForUrlAsync(urlArchive.location);
return { ...urlArchive, location: resolvedUrl };
}
case ArchiveFileSourceType.latest: {
const url = await handleLatestSourceAsync(source);
return await getArchiveLocationForUrlAsync(url);
const urlArchive = await handleLatestSourceAsync(source);
const resolvedUrl = await getArchiveLocationForUrlAsync(urlArchive.location);
return { ...urlArchive, location: resolvedUrl };
}
case ArchiveFileSourceType.path: {
const path = await handlePathSourceAsync(source);
return getArchiveLocationForPathAsync(path);
const pathArchive = await handlePathSourceAsync(source);
const resolvedUrl = await getArchiveLocationForPathAsync(pathArchive.location);
return { ...pathArchive, location: resolvedUrl };
}
case ArchiveFileSourceType.buildId: {
const url = await handleBuildIdSourceAsync(source);
return await getArchiveLocationForUrlAsync(url);
const urlArchive = await handleBuildIdSourceAsync(source);
const resolvedUrl = await getArchiveLocationForUrlAsync(urlArchive.location);
return { ...urlArchive, location: resolvedUrl };
}
}
}
@@ -102,11 +113,14 @@ async function getArchiveLocationForPathAsync(path: string): Promise<string> {
return await uploadAppArchiveAsync(resolvedPath);
}
async function handleUrlSourceAsync(source: ArchiveFileUrlSource): Promise<string> {
return source.url;
async function handleUrlSourceAsync(source: ArchiveFileUrlSource): Promise<ResolvedArchive> {
return {
location: source.url,
realSource: source,
};
}
async function handleLatestSourceAsync(source: ArchiveFileLatestSource): Promise<string> {
async function handleLatestSourceAsync(source: ArchiveFileLatestSource): Promise<ResolvedArchive> {
try {
const artifactUrl = await getLatestBuildArtifactUrlAsync(source.platform, source.projectId);
@@ -122,14 +136,17 @@ async function handleLatestSourceAsync(source: ArchiveFileLatestSource): Promise
});
}
return artifactUrl;
return {
location: artifactUrl,
realSource: source,
};
} catch (err) {
Log.error(err);
throw err;
}
}
async function handlePathSourceAsync(source: ArchiveFilePathSource): Promise<string> {
async function handlePathSourceAsync(source: ArchiveFilePathSource): Promise<ResolvedArchive> {
if (!(await isExistingFile(source.path))) {
Log.error(chalk.bold(`${source.path} doesn't exist`));
return getArchiveFileLocationAsync({
@@ -137,12 +154,20 @@ async function handlePathSourceAsync(source: ArchiveFilePathSource): Promise<str
sourceType: ArchiveFileSourceType.prompt,
});
}
return source.path;
return {
location: source.path,
realSource: source,
};
}
async function handleBuildIdSourceAsync(source: ArchiveFileBuildIdSource): Promise<string> {
async function handleBuildIdSourceAsync(
source: ArchiveFileBuildIdSource
): Promise<ResolvedArchive> {
try {
return await getBuildArtifactUrlByIdAsync(source.platform, source.id);
return {
location: await getBuildArtifactUrlByIdAsync(source.platform, source.id),
realSource: source,
};
} catch (err) {
Log.error(chalk.bold(`Couldn't find build for id ${source.id}`));
Log.error(err);
@@ -153,7 +178,7 @@ async function handleBuildIdSourceAsync(source: ArchiveFileBuildIdSource): Promi
}
}
async function handlePromptSourceAsync(source: ArchiveFilePromptSource): Promise<string> {
async function handlePromptSourceAsync(source: ArchiveFilePromptSource): Promise<ResolvedArchive> {
const { sourceType: sourceTypeRaw } = await promptAsync({
name: 'sourceType',
type: 'select',
@@ -185,7 +210,7 @@ async function handlePromptSourceAsync(source: ArchiveFilePromptSource): Promise
});
}
case ArchiveFileSourceType.path: {
const path = await askForArchivePathAsync();
const path = await askForArchivePathAsync(source.platform);
return getArchiveFileLocationAsync({
...source,
sourceType: ArchiveFileSourceType.path,
@@ -211,6 +236,8 @@ async function handlePromptSourceAsync(source: ArchiveFilePromptSource): Promise
}
}
/* PROMPTS */
async function askForArchiveUrlAsync(): Promise<string> {
const defaultArchiveUrl = 'https://url.to/your/archive.aab';
const { url } = await promptAsync({
@@ -231,11 +258,12 @@ async function askForArchiveUrlAsync(): Promise<string> {
return url;
}
async function askForArchivePathAsync(): Promise<string> {
const defaultArchivePath = '/path/to/your/archive.aab';
async function askForArchivePathAsync(platform: SubmissionPlatform): Promise<string> {
const isIos = platform === SubmissionPlatform.iOS;
const defaultArchivePath = `/path/to/your/archive.${isIos ? 'ipa' : 'aab'}`;
const { path } = await promptAsync({
name: 'path',
message: 'Path to the app archive file (aab or apk):',
message: `Path to the app archive file (${isIos ? 'ipa' : 'aab or apk'}):`,
initial: defaultArchivePath,
type: 'text',
validate: async (path: string): Promise<boolean | string> => {
@@ -14,17 +14,19 @@ export interface ArchiveSource {
export interface Archive {
location: string;
type: ArchiveType;
realFileSource: ArchiveFileSource;
}
export async function getArchiveAsync(
platform: SubmissionPlatform,
source: ArchiveSource
): Promise<Archive> {
const location = await getArchiveFileLocationAsync(source.archiveFile);
const { location, realSource } = await getArchiveFileLocationAsync(source.archiveFile);
const type = await getArchiveTypeAsync(platform, source.archiveType, location);
return {
location,
type,
realFileSource: realSource,
};
}
@@ -39,7 +39,8 @@ export async function ensureAppStoreConnectAppExistsAsync(
const { bundleIdentifier, appName, language } = ctx.commandFlags;
const resolvedBundleId =
bundleIdentifier ?? (await getAppIdentifierAsync(ctx.projectDir, Platform.IOS));
bundleIdentifier ??
(await getAppIdentifierAsync({ projectDir: ctx.projectDir, platform: Platform.IOS, exp }));
if (!resolvedBundleId) {
throw new Error(
`Please define "expo.ios.bundleIdentifier" in your app config or provide it using --bundle-identifier param.
@@ -1,9 +1,15 @@
import chalk from 'chalk';
import Log, { learnMore } from '../../log';
import BaseSubmitter from '../BaseSubmitter';
import { SubmissionStatus } from '../SubmissionService.types';
import { Archive, ArchiveSource, getArchiveAsync } from '../archiveSource';
import { IosSubmissionContext, SubmissionPlatform } from '../types';
import { printSummary } from '../utils/summary';
import {
ArchiveSourceSummaryFields,
formatArchiveSourceSummary,
printSummary,
} from '../utils/summary';
import {
AppSpecificPasswordSource,
getAppSpecificPasswordAsync,
@@ -21,6 +27,9 @@ interface ResolvedSourceOptions {
appSpecificPassword: string;
}
type SummaryData = Pick<IosSubmissionOptions, 'ascAppId' | 'appleId' | 'projectId'> &
ArchiveSourceSummaryFields;
class IosSubmitter extends BaseSubmitter<IosSubmissionContext, IosSubmissionOptions> {
protected readonly appStoreName: string = 'Apple App Store';
@@ -35,9 +44,26 @@ class IosSubmitter extends BaseSubmitter<IosSubmissionContext, IosSubmissionOpti
resolvedSourceOptions
);
printSummaryIOS(submissionConfig);
printSummary(
this.prepareSummaryData(this.options, resolvedSourceOptions),
SummaryHumanReadableKeys,
SummaryHumanReadableValues
);
const result = await this.startSubmissionAsync(submissionConfig, this.ctx.commandFlags.verbose);
await this.startSubmissionAsync(submissionConfig, this.ctx.commandFlags.verbose);
if (result === SubmissionStatus.FINISHED) {
Log.addNewLineIfNone();
Log.log(
chalk.bold('Your binary has been successfully uploaded to App Store Connect!\n') +
'- It is now being processed by Apple - you will receive an e-mail when the processing finishes.\n' +
'- It usually takes about 5-10 minutes depending on how busy Apple servers are.\n' +
'- When it’s done, you can see your build here' +
learnMore(
`https://appstoreconnect.apple.com/apps/${this.options.ascAppId}/appstore/ios`,
{ learnMoreMessage: '' }
)
);
}
}
private async resolveSourceOptions(): Promise<ResolvedSourceOptions> {
@@ -58,48 +84,40 @@ class IosSubmitter extends BaseSubmitter<IosSubmissionContext, IosSubmissionOpti
): Promise<IosSubmissionConfig> {
const { projectId, appleId, ascAppId } = options;
const submissionConfig = {
archiveUrl: archive.location,
appleId,
appSpecificPassword,
appAppleId: ascAppId, //ASC App ID is called "appAppleId" on server side
appleId,
projectId,
archiveUrl: archive.location,
appSpecificPassword,
};
return submissionConfig;
}
private prepareSummaryData(
options: IosSubmissionOptions,
{ archive }: ResolvedSourceOptions
): SummaryData {
const { appleId, ascAppId, projectId } = options;
// structuring order affects table rows order
return {
ascAppId,
appleId,
projectId,
...formatArchiveSourceSummary(archive),
};
}
}
/**
* Log the summary as a table. Exported for testing locally.
* @example
* printSummaryIOS({
* appleId: '[email protected]',
* appSpecificPassword: 'Apple Pie^',
* appAppleId: '1234567890',
* projectId: '863f9337-65d2-40c6-acb3-c1054c5c09f8',
* archiveUrl: 'https://turtle-v2-artifacts.s3.amazonaws.com/ios/6420592d-5b5d-439b-aed4-ccd278647138-ca4145d8468947df9ded737248a1a238.ipa',
* });
* @param submissionConfig
*/
export function printSummaryIOS(submissionConfig: IosSubmissionConfig) {
const { appSpecificPassword, projectId, archiveUrl, ...logConfig } = submissionConfig;
printSummary(
{ ...logConfig, projectId, archiveUrl },
SummaryHumanReadableKeys,
SummaryHumanReadableValues
);
}
const SummaryHumanReadableKeys: Record<keyof IosSubmissionConfig, string> = {
const SummaryHumanReadableKeys: Record<keyof SummaryData, string> = {
ascAppId: 'ASC App ID',
appleId: 'Apple ID',
archiveUrl: 'Download URL',
appSpecificPassword: 'Apple app-specific password',
appAppleId: 'ASC App ID',
projectId: 'Project ID',
archiveUrl: 'Archive URL',
archivePath: 'Archive Path',
buildId: 'Build ID',
};
const SummaryHumanReadableValues: Partial<Record<keyof IosSubmissionConfig, Function>> = {
appSpecificPassword: () => chalk.italic('[hidden]'),
};
const SummaryHumanReadableValues: Partial<Record<keyof SummaryData, Function>> = {};
export default IosSubmitter;
@@ -1,31 +1,51 @@
import chalk from 'chalk';
import Log from '../../log';
import formatFields from '../../utils/formatFields';
import { Archive, ArchiveFileSourceType } from '../archiveSource';
export interface ArchiveSourceSummaryFields {
archiveUrl?: string;
archivePath?: string;
buildId?: string;
}
export function formatArchiveSourceSummary({
realFileSource,
}: Archive): ArchiveSourceSummaryFields {
const summarySlice: ArchiveSourceSummaryFields = {};
switch (realFileSource.sourceType) {
case ArchiveFileSourceType.path:
summarySlice.archivePath = realFileSource.path;
break;
case ArchiveFileSourceType.url:
summarySlice.archiveUrl = realFileSource.url;
break;
case ArchiveFileSourceType.buildId:
summarySlice.buildId = realFileSource.id;
break;
case ArchiveFileSourceType.latest:
// TODO: Resolve real build ID here
summarySlice.buildId = '[latest]';
break;
}
return summarySlice;
}
export function printSummary<T>(
summary: T,
keyMap: Record<keyof T, string>,
valueRemap: Partial<Record<keyof T, Function>>
): void {
const padWidth = longestStringLength(Object.keys(summary).map(key => keyMap[key as keyof T]));
const tableFormat = (name: string, msg: string) =>
`${chalk.bold.cyan(pad(`${name}:`, padWidth + 1))} ${msg}`;
const fields = [];
for (const [key, entryValue] of Object.entries(summary)) {
const label = `${keyMap[key as keyof T]}:`;
const value = valueRemap[key as keyof T]?.(entryValue) ?? entryValue;
fields.push({ label, value });
}
Log.newLine();
for (const [key, value] of Object.entries(summary)) {
const displayKey = keyMap[key as keyof T];
const displayValue = valueRemap[key as keyof T]?.(value) ?? value;
Log.log(tableFormat(displayKey, displayValue));
}
Log.log(formatFields(fields, { labelFormat: chalk.bold.cyan }));
Log.addNewLineIfNone();
}
function pad(str: string, width: number): string {
const len = Math.max(0, width - str.length);
return str + Array(len + 1).join(' ');
}
function longestStringLength(values: string[]): number {
return values.reduce((max, option) => Math.max(max, option.length), 0);
}
+29
View File
@@ -26,3 +26,32 @@ export async function maybeRenameExistingFileAsync(projectDir: string, filename:
await fs.rename(desiredFilePath, path.resolve(projectDir, getRenamedFilename(filename, num)));
}
}
export function formatBytes(bytes: number): string {
if (bytes === 0) {
return `0`;
}
let multiplier = 1;
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes)} B`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} KB`;
}
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes / 1024)} KB`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} MB`;
}
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes / multiplier)} MB`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} GB`;
}
return `${Math.floor(bytes / 1024)} GB`;
}
+11 -8
View File
@@ -1,21 +1,24 @@
import chalk from 'chalk';
export default function formatFields(fields: { label: string; value: string }[]) {
type FormatFieldsOptions = {
labelFormat: (raw: string) => string;
};
export default function formatFields(
fields: { label: string; value: string }[],
options: FormatFieldsOptions = { labelFormat: chalk.dim }
) {
const columnWidth = fields.reduce((a, b) => (a.label.length > b.label.length ? a : b)).label
.length;
return fields
.map(({ label, value }) => {
let line = '';
line += chalk.dim(
// make all labels fixed-width
const formattedLabel = options.labelFormat(
label.length < columnWidth ? `${label}${' '.repeat(columnWidth - label.length)}` : label
);
line += ' ';
line += value;
return line;
return `${formattedLabel} ${value}`;
})
.join('\n');
}
+22 -21
View File
@@ -4,20 +4,25 @@ interface GitStatusOptions {
showUntracked?: boolean;
}
async function gitStatusAsync({ showUntracked }: GitStatusOptions = {}): Promise<string> {
export async function gitStatusAsync({ showUntracked }: GitStatusOptions = {}): Promise<string> {
return (await spawnAsync('git', ['status', '-s', showUntracked ? '-uall' : '-uno'])).stdout;
}
async function gitDiffAsync({ withPager = false }: { withPager?: boolean } = {}): Promise<void> {
export async function gitDiffAsync({ withPager = false }: { withPager?: boolean } = {}): Promise<
void
> {
const options = withPager ? [] : ['--no-pager'];
await spawnAsync('git', [...options, 'diff'], { stdio: ['ignore', 'inherit', 'inherit'] });
}
async function getGitDiffOutputAsync(): Promise<string> {
export async function getGitDiffOutputAsync(): Promise<string> {
return (await spawnAsync('git', ['--no-pager', 'diff'])).stdout;
}
async function gitAddAsync(file: string, options?: { intentToAdd?: boolean }): Promise<void> {
export async function gitAddAsync(
file: string,
options?: { intentToAdd?: boolean }
): Promise<void> {
if (options?.intentToAdd) {
await spawnAsync('git', ['add', '--intent-to-add', file]);
} else {
@@ -25,11 +30,11 @@ async function gitAddAsync(file: string, options?: { intentToAdd?: boolean }): P
}
}
async function gitRootDirectoryAsync(): Promise<string> {
export async function gitRootDirectoryAsync(): Promise<string> {
return (await spawnAsync('git', ['rev-parse', '--show-toplevel'])).stdout.trim();
}
async function doesGitRepoExistAsync(): Promise<boolean> {
export async function doesGitRepoExistAsync(): Promise<boolean> {
try {
await spawnAsync('git', ['rev-parse', '--git-dir']);
return true;
@@ -38,7 +43,15 @@ async function doesGitRepoExistAsync(): Promise<boolean> {
}
}
async function isGitInstalledAsync(): Promise<boolean> {
export async function gitCommitHashAsync(): Promise<string | undefined> {
try {
return (await spawnAsync('git', ['rev-parse', 'HEAD'])).stdout.trim();
} catch (err) {
return undefined;
}
}
export async function isGitInstalledAsync(): Promise<boolean> {
try {
await spawnAsync('git', ['--help']);
} catch (error) {
@@ -50,7 +63,7 @@ async function isGitInstalledAsync(): Promise<boolean> {
return true;
}
async function getBranchNameAsync(): Promise<string | null> {
export async function getBranchNameAsync(): Promise<string | null> {
try {
return (await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
} catch (e) {
@@ -58,22 +71,10 @@ async function getBranchNameAsync(): Promise<string | null> {
}
}
async function getLastCommitMessageAsync(): Promise<string | null> {
export async function getLastCommitMessageAsync(): Promise<string | null> {
try {
return (await spawnAsync('git', ['--no-pager', 'log', '-1', '--pretty=%B'])).stdout.trim();
} catch (e) {
return null;
}
}
export {
gitStatusAsync,
gitDiffAsync,
getGitDiffOutputAsync,
gitAddAsync,
doesGitRepoExistAsync,
gitRootDirectoryAsync,
isGitInstalledAsync,
getBranchNameAsync,
getLastCommitMessageAsync,
};
+7 -7
View File
@@ -16,8 +16,8 @@ function createProgressTracker({
completedMessage,
}: {
total?: number;
message: string;
completedMessage?: string;
message: string | ((ratio: number) => string);
completedMessage: string;
}): ProgressHandler {
let bar: ora.Ora | null = null;
let calcTotal: number = total ?? 0;
@@ -26,17 +26,17 @@ function createProgressTracker({
const timerLabel = String(Date.now());
const withPercent = (v: number) => {
const getMessage = (v: number) => {
const ratio = Math.min(Math.max(v, 0), 1);
const percent = Math.floor(ratio * 100);
return `${message} ${percent.toFixed(0)}%`;
return typeof message === 'string' ? `${message} ${percent.toFixed(0)}%` : message(ratio);
};
return ({ progress, isComplete, error }) => {
if (progress) {
if (!bar && (progress.total !== undefined || total !== undefined)) {
calcTotal = (total ?? progress.total) as number;
bar = ora(withPercent(0)).start();
bar = ora(getMessage(0)).start();
startTimer(timerLabel);
}
if (progress.total) {
@@ -51,7 +51,7 @@ function createProgressTracker({
percentage = current / calcTotal;
}
bar.text = withPercent(percentage);
bar.text = getMessage(percentage);
}
transferredSoFar = progress.transferred;
}
@@ -66,7 +66,7 @@ function createProgressTracker({
if (error) {
bar.fail();
} else if (isComplete) {
bar.succeed(`${completedMessage ?? message} ${chalk.dim(prettyTime)}`);
bar.succeed(`${completedMessage} ${chalk.dim(prettyTime)}`);
}
}
};
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with the eas.json",
"version": "0.5.0",
"version": "0.7.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/eas-build-job": "0.2.14",
"@expo/eas-build-job": "0.2.21",
"@hapi/joi": "^17.1.1",
"fs-extra": "^9.0.1",
"tslib": "^1"
+2 -1
View File
@@ -46,12 +46,13 @@ export interface iOSGenericBuildProfile extends Ios.BuilderEnvironment {
workflow: Workflow.GENERIC;
credentialsSource: CredentialsSource;
scheme?: string;
schemeBuildConfiguration?: Ios.SchemeBuildConfiguration;
schemeBuildConfiguration?: Ios.SchemeBuildConfiguration | 'Auto';
releaseChannel?: string;
artifactPath?: string;
distribution?: iOSDistributionType;
autoIncrement: VersionAutoIncrement;
cache: Cache | null;
disableIosBundleIdentifierValidation?: boolean;
}
export type AndroidBuildProfile = AndroidManagedBuildProfile | AndroidGenericBuildProfile;
+13 -5
View File
@@ -6,6 +6,9 @@ import { AndroidBuildProfile, BuildProfile, EasConfig, iOSBuildProfile } from '.
import { EasJsonSchema, schemaBuildProfileMap } from './EasJsonSchema';
interface EasJson {
experimental?: {
disableIosBundleIdentifierValidation?: boolean;
};
builds: {
android?: { [key: string]: BuildProfilePreValidation };
ios?: { [key: string]: BuildProfilePreValidation };
@@ -39,10 +42,16 @@ export class EasJsonReader {
easJson.builds?.ios || {}
);
}
const iosExperimental = easJson.experimental?.disableIosBundleIdentifierValidation
? {
disableIosBundleIdentifierValidation:
easJson.experimental.disableIosBundleIdentifierValidation,
}
: {};
return {
builds: {
...(androidConfig ? { android: androidConfig } : {}),
...(iosConfig ? { ios: iosConfig } : {}),
...(iosConfig ? { ios: { ...iosConfig, ...iosExperimental } } : {}),
},
};
}
@@ -141,15 +150,14 @@ export class EasJsonReader {
if (!buildProfile) {
throw new Error(`There is no profile named ${buildProfileName} for platform ${platform}`);
}
const baseProfileName = buildProfile.extends;
delete buildProfile.extends;
const { extends: baseProfileName, ...buildProfileRest } = buildProfile;
if (baseProfileName) {
return deepMerge(
this.resolveBuildProfile(platform, baseProfileName, buildProfiles, depth + 1),
buildProfile
buildProfileRest
);
} else {
return buildProfile;
return buildProfileRest;
}
}
}
+5 -1
View File
@@ -25,6 +25,7 @@ const IosBuilderEnvironmentSchema = Joi.object({
.default('default'),
node: Joi.string().empty(null).custom(semverSchemaCheck),
yarn: Joi.string().empty(null).custom(semverSchemaCheck),
bundler: Joi.string().empty(null).custom(semverSchemaCheck),
fastlane: Joi.string().empty(null).custom(semverSchemaCheck),
cocoapods: Joi.string().empty(null).custom(semverSchemaCheck),
env: Joi.object().pattern(Joi.string(), Joi.string().empty(null)).default({}),
@@ -68,7 +69,7 @@ const iOSGenericSchema = Joi.object({
workflow: Joi.string().valid('generic').required(),
credentialsSource: Joi.string().valid('local', 'remote', 'auto').default('auto'),
scheme: Joi.string(),
schemeBuildConfiguration: Joi.string().valid('Debug', 'Release'),
schemeBuildConfiguration: Joi.string().valid('Debug', 'Release', 'Auto').default('Release'),
releaseChannel: Joi.string(),
artifactPath: Joi.string(),
distribution: Joi.string().valid('store', 'internal', 'simulator').default('store'),
@@ -102,6 +103,9 @@ const schemaBuildProfileMap: Record<string, Record<string, Joi.Schema>> = {
};
const EasJsonSchema = Joi.object({
experimental: Joi.object({
disableIosBundleIdentifierValidation: Joi.boolean(),
}),
builds: Joi.object({
android: Joi.object().pattern(
Joi.string(),
@@ -57,6 +57,7 @@ test('minimal valid ios eas.json', async () => {
env: {},
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
image: 'default',
schemeBuildConfiguration: 'Release',
},
},
}).toEqual(easJson);
@@ -95,6 +96,7 @@ test('minimal valid eas.json for both platforms', async () => {
env: {},
cache: { key: '', cacheDefaultPaths: true, customPaths: [] },
image: 'default',
schemeBuildConfiguration: 'Release',
},
},
}).toEqual(easJson);
+8 -8
View File
@@ -1093,10 +1093,10 @@
"@babel/preset-env" "^7.4.4"
"@babel/preset-typescript" "^7.3.3"
"@expo/[email protected]9-alpha.0":
version "1.0.19-alpha.0"
resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-1.0.19-alpha.0.tgz#3d57c266bfae0c7b310ac99e9949c9d03120c103"
integrity sha512-mOPIvi1tanMftgeEjDB4vckLKY5dKdJ80R2cg27YyaP4IS1pYGaHtJKBKb6Pr1w5xvyRxd2AlX6C7L8lEzTBaw==
"@expo/[email protected].21-alpha.0":
version "1.0.21-alpha.0"
resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-1.0.21-alpha.0.tgz#cc6d428809a8b1f483de1f4ca6b8219308bb3680"
integrity sha512-npi9sttSQOxl/NWc/FSlIy+ws9HgUoX3vsbu+rgTmD2ARtF5D8x5NR5upnWWpwoq6ZXaYXwDXNcKZHWLGrCfjA==
dependencies:
"@expo/config-types" "^40.0.0-beta.2"
"@expo/configure-splash-screen" "0.3.4"
@@ -1151,10 +1151,10 @@
xcode "^3.0.0"
xml-js "^1.6.11"
"@expo/[email protected]4":
version "0.2.14"
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-0.2.14.tgz#10402f8c2cdd3e8c7ebc6f8773270295e7993771"
integrity sha512-+msVy5CgyQwZrUJeiV6hvg3Sjx4Fc1CHDF0jY0LxXSsRDGQDM7qde6ukHRyPvGuGxv/+PzwlWolv2kOP6GFcFQ==
"@expo/[email protected].21":
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==
dependencies:
"@hapi/joi" "^17.1.1"