Compare commits

...
18 Commits
Author SHA1 Message Date
Dominik Sokal 72283cdda2 v2.8.0 2022-11-28 12:13:38 +01:00
Dominik Sokal 9874ff26b3 upgrade eas-cli-local-build-plugin to 0.0.120 2022-11-28 12:13:16 +01:00
Dominik Sokal 85d64b7ad3 [ENG-6491] remove internal expo id from device:list (#1541)
* remove internal expo id from device:list

* update CHANGELOG.md
2022-11-28 09:34:36 +01:00
Szymon Dziedzic 2dba2705b7 [ENG-6238][eas-cli] add build:run support for Android (#1485)
* [eas-cli] add build:run support for Android

* add assertions about android executables

* use spawnAsync instead of spawn

* minor refactor

* minor improvements to prompts

* improve download function name

* refactor code after the review

* apply suggestions from the review

* fix commit message formatting

* use progress tracker instead of progress bar

* improve formatting of build choices

* fix typo in function name

* requested improvements

* test using path.join for fast-lob

* fix windows glob issues

* fix glob on windows

* allow users to run ios builds only on apple device

* use path.join instead of interpolation

* address feedback

* auto select android for non mac users

* minor improvements

* improve error messages

* update CHANGELOG.md
2022-11-25 13:57:19 +01:00
Cedric van Putten d1bec6e00a [eas-cli] Only create a channel with branch name when branch is created (#1507)
* fix(update): only create a channel with branch name when branch is created

* refactor(update): rename branchIsCreated to createdBranch

* update CHANGELOG.md
2022-11-23 18:28:08 +01:00
Szymon Dziedzic 597e012ca3 [ENG-6941][eas-json] add better error message for when invalid image is set in the eas.json (#1531)
* [eas-json] add custom error message handler for invalid image validation error

* update CHANGELOG.md
2022-11-21 15:52:30 +01:00
Wojciech Kozyra 8632b8ada7 [eas-cli] Upload project sources and submit archives to GCS (#1524) 2022-11-17 11:57:41 +01:00
Wojciech Kozyra b288879838 [eas-cli] Add SDK version to tracking context (#1529) 2022-11-17 11:34:24 +01:00
Dominik Sokal 8beb8f46da change wording: "Submission Service" -> "EAS Submit" (#1527)
* change wording: "Submission Service" -> "EAS Submit"

* update CHANGELOG.md
2022-11-17 09:53:44 +01:00
Expo CI 9120bf5777 update CHANGELOG.md 2022-11-16 14:58:36 +00:00
Szymon Dziedzic 01619d4182 v2.7.1 2022-11-16 15:49:27 +01:00
Szymon Dziedzic aa4ea4ae2a update eas-build-job version in yarn.lock 2022-11-16 15:48:54 +01:00
Szymon Dziedzic c236c2ffb5 upgrade eas-cli-local-build-plugin to 0.0.119 2022-11-16 15:45:52 +01:00
Szymon Dziedzic b5a3a513de [ENG-6892][eas-json] add Xcode 14.1 image (#1511)
* [eas-json] add Xcode 14.1 image

* bump build tools version

* update CHANGELOG.md
2022-11-16 15:30:15 +01:00
Wojciech Kozyra 6058183c45 [eas-cli] Show what prompt failed when running in non interactive shell. (#1523) 2022-11-16 09:57:22 +01:00
Wojciech Kozyra a042d27573 [eas-cli] Pass env variables when resolving config in credentials manager. (#1520) 2022-11-15 11:19:26 +01:00
Dominik Sokal 88b21e476d [scripts] run yarn install before running scripts (#1519) 2022-11-14 10:34:02 +01:00
Expo CI 3d497d4f05 update CHANGELOG.md 2022-11-11 17:26:16 +00:00
54 changed files with 2267 additions and 416 deletions

No files matched your search

+29
View File
@@ -8,6 +8,35 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Add `eas build:run` support for Android. ([#1485](https://github.com/expo/eas-cli/pull/1485) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- Only create a new channel when update branch is new. ([#1507](https://github.com/expo/eas-cli/pull/1507) by [@byCedric](https://github.com/byCedric))
### 🧹 Chores
- Better wording in eas submit. ([#1527](https://github.com/expo/eas-cli/pull/1527) by [@dsokal](https://github.com/dsokal))
- Upload project sources for EAS Build and archives for EAS Submit to GCS. ([#1524](https://github.com/expo/eas-cli/pull/1524) by [@wkozyra95](https://github.com/wkozyra95))
- Add a better error message for when an invalid `image` is set in `eas-json`. ([#1531](https://github.com/expo/eas-cli/pull/1531) by [@szdziedzic](https://github.com/szdziedzic))
- Remove internal expo id from device formatter. ([#1541](https://github.com/expo/eas-cli/pull/1541) by [@dsokal](https://github.com/dsokal))
## [2.7.1](https://github.com/expo/eas-cli/releases/tag/v2.7.1) - 2022-11-16
### 🐛 Bug fixes
- Use envs from the build profile to resolve credentials in `eas credentials`. ([#1520](https://github.com/expo/eas-cli/pull/1520) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
- Print prompt message when failing because of the non-interactive shell. ([#1523](https://github.com/expo/eas-cli/pull/1523) by [@wkozyra95](https://github.com/wkozyra95))
- Add SDK version to the analytics build events. ([#1529](https://github.com/expo/eas-cli/pull/1529) by [@wkozyra95](https://github.com/wkozyra95))
- Add Xcode 14.1 image to `eas-json`. ([#1511](https://github.com/expo/eas-cli/pull/1511) by [@szdziedzic](https://github.com/szdziedzic))
## [2.7.0](https://github.com/expo/eas-cli/releases/tag/v2.7.0) - 2022-11-11
### 🎉 New features
- Support uploading different platform combinations in `eas update`. ([#1461](https://github.com/expo/eas-cli/pull/1461) by [@EvanBacon](https://github.com/EvanBacon))
- Add Git commit to EAS Update record. ([#1499](https://github.com/expo/eas-cli/pull/1499) by [@fiberjw](https://github.com/fiberjw))
- Use local CLI for `expo export` in SDK +46. ([#1474](https://github.com/expo/eas-cli/pull/1474) by [@EvanBacon](https://github.com/EvanBacon))
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "2.7.0"
"version": "2.8.0"
}
+1 -1
View File
@@ -8,7 +8,7 @@
"build:tarballs:linux": "yarn build-for-size-check",
"build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64",
"typecheck": "lerna run typecheck",
"start": "lerna run watch --parallel",
"start": "lerna run watch --stream --parallel",
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"lint": "eslint .",
File diff suppressed because it is too large. Load diff
+91
View File
@@ -17413,6 +17413,35 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "GitHubAppEnvironment",
"description": "",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "DEVELOPMENT",
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "PRODUCTION",
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "STAGING",
"description": "",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "GitHubAppInstallation",
@@ -17870,6 +17899,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environment",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "GitHubAppEnvironment",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "installation",
"description": "",
@@ -17903,6 +17948,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "searchRepositories",
"description": "",
@@ -18066,6 +18127,18 @@
"name": "GitHubRepositoryMetadata",
"description": "",
"fields": [
{
"name": "defaultBranch",
"description": "",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "githubRepoDescription",
"description": "",
@@ -18158,6 +18231,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "openGraphImageUrl",
"description": "",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "private",
"description": "",
@@ -22847,6 +22932,12 @@
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "GCS",
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "S3",
"description": "",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "2.7.0",
"version": "2.8.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -13,8 +13,8 @@
"@expo/config": "7.0.1",
"@expo/config-plugins": "5.0.1",
"@expo/config-types": "46.0.2",
"@expo/eas-build-job": "0.2.96",
"@expo/eas-json": "2.7.0",
"@expo/eas-build-job": "0.2.97",
"@expo/eas-json": "2.8.0",
"@expo/json-file": "8.2.36",
"@expo/multipart-body-parser": "1.1.0",
"@expo/osascript": "2.0.33",
@@ -1,17 +0,0 @@
import { fixS3Url } from '../uploads';
describe(fixS3Url, () => {
it('fixes broken links', () => {
const input =
'https://submission-service-archives.s3.amazonaws.com/production%2Fdc98ca84-1473-4cb3-ae81-8c7b291cb27e%2F4424aa95-b985-4e2f-8755-9507b1037c1c';
const expectedOutput =
'https://submission-service-archives.s3.amazonaws.com/production/dc98ca84-1473-4cb3-ae81-8c7b291cb27e/4424aa95-b985-4e2f-8755-9507b1037c1c';
expect(fixS3Url(input)).toBe(expectedOutput);
});
it('does not change correct urls', () => {
const correctUrl =
'https://submission-service-archives.s3.amazonaws.com/production/dc98ca84-1473-4cb3-ae81-8c7b291cb27e/4424aa95-b985-4e2f-8755-9507b1037c1c';
expect(fixS3Url(correctUrl)).toBe(correctUrl);
});
});
+3 -3
View File
@@ -160,7 +160,7 @@ export async function ensureBranchExistsAsync(
appId: string;
branchName: string;
}
): Promise<{ branchId: string }> {
): Promise<{ branchId: string; createdBranch: boolean }> {
try {
const updateBranch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId,
@@ -168,14 +168,14 @@ export async function ensureBranchExistsAsync(
});
const { id } = updateBranch;
return { branchId: id };
return { branchId: id, createdBranch: false };
} catch (error) {
if (error instanceof BranchNotFoundError) {
const newUpdateBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
appId,
name: branchName,
});
return { branchId: newUpdateBranch.id };
return { branchId: newUpdateBranch.id, createdBranch: true };
} else {
throw error;
}
+4 -4
View File
@@ -26,7 +26,7 @@ import {
appPlatformEmojis,
requestedPlatformDisplayNames,
} from '../platform';
import { uploadFileAtPathToS3Async } from '../uploads';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise';
@@ -113,7 +113,7 @@ export async function prepareBuildRequestForPlatformAsync<
path: (await makeProjectTarballAsync()).path,
} as const)
: ({
type: ArchiveSourceType.S3,
type: ArchiveSourceType.GCS,
bucketKey: await uploadProjectAsync(ctx),
} as const);
@@ -219,9 +219,9 @@ async function uploadProjectAsync<TPlatform extends Platform>(
projectTarballPath = projectTarball.path;
const { bucketKey } = await uploadFileAtPathToS3Async(
const bucketKey = await uploadFileAtPathToGCSAsync(
ctx.graphqlClient,
UploadSessionType.EasBuildProjectSources,
UploadSessionType.EasBuildGcsProjectSources,
projectTarball.path,
createProgressTracker({
total: projectTarball.size,
@@ -78,6 +78,7 @@ export async function createBuildContextAsync<T extends Platform>({
const analyticsEventProperties = {
tracking_id: uuidv4(),
platform,
...(exp.sdkVersion && { sdk_version: exp.sdkVersion }),
...(accountId && { account_id: accountId }),
project_id: projectId,
project_type: workflow,
+5
View File
@@ -16,6 +16,11 @@ export function transformProjectArchive(archiveSource: ArchiveSource): ProjectAr
type: ProjectArchiveSourceType.S3,
bucketKey: archiveSource.bucketKey,
};
} else if (archiveSource.type === ArchiveSourceType.GCS) {
return {
type: ProjectArchiveSourceType.Gcs,
bucketKey: archiveSource.bucketKey,
};
} else if (archiveSource.type === ArchiveSourceType.URL) {
return {
type: ProjectArchiveSourceType.Url,
+1 -1
View File
@@ -6,7 +6,7 @@ import semver from 'semver';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '0.0.118';
const PLUGIN_PACKAGE_VERSION = '0.0.120';
export interface LocalBuildOptions {
enable: boolean;
+48 -9
View File
@@ -2,11 +2,11 @@ import chalk from 'chalk';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { BuildFilter, BuildFragment } from '../graphql/generated';
import { AppPlatform, BuildFilter, BuildFragment } from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log from '../log';
import { appPlatformDisplayNames } from '../platform';
import { promptAsync } from '../prompts';
import { fromNow } from '../utils/date';
import { printJsonOnlyOutput } from '../utils/json';
import { paginatedQueryWithConfirmPromptAsync } from '../utils/queries';
import { formatGraphQLBuild } from './utils/formatBuild';
@@ -55,18 +55,51 @@ export async function listAndRenderBuildsOnAppAsync(
}
}
function formatBuildChoiceValue(value: string | undefined | null): string {
return value ? chalk.bold(value) : chalk.dim('Unknown');
}
function formatBuildChoiceTitleAndDescription(build: BuildFragment): {
title: string;
description: string;
} {
const splitCommitMessage = build.gitCommitMessage?.split('\n');
const formattedCommitData =
build.gitCommitHash && splitCommitMessage && splitCommitMessage.length > 0
? `${chalk.dim(build.gitCommitHash.slice(0, 7))} "${chalk.bold(
splitCommitMessage[0] + (splitCommitMessage.length > 1 ? '…' : '')
)}"`
: 'Unknown';
return {
title: `ID: ${chalk.dim(build.id)} (${chalk.dim(`${fromNow(new Date(build.updatedAt))} ago`)})`,
description: [
`\tVersion: ${formatBuildChoiceValue(build.appVersion)}`,
`\t${
build.platform === AppPlatform.Ios ? 'Build number' : 'Version code'
}: ${formatBuildChoiceValue(build.appBuildVersion)}`,
`\tCommit: ${formattedCommitData}`,
].join('\n'),
};
}
export async function listAndSelectBuildsOnAppAsync(
graphqlClient: ExpoGraphqlClient,
selectedPlatform: AppPlatform,
{
projectId,
projectDisplayName,
filter,
queryOptions,
selectPromptDisabledFunction,
warningMessage,
}: {
projectId: string;
projectDisplayName: string;
filter?: BuildFilter;
queryOptions: PaginatedQueryOptions;
selectPromptDisabledFunction?: (build: BuildFragment) => boolean;
warningMessage?: string;
}
): Promise<BuildFragment> {
const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
@@ -82,14 +115,20 @@ export async function listAndSelectBuildsOnAppAsync(
const { selectedSimulatorBuild } = await promptAsync({
type: 'select',
message: `Select simulator build to run for ${projectDisplayName} app`,
message: `Select ${selectedPlatform === AppPlatform.Ios ? 'iOS' : 'Android'} ${
selectedPlatform === AppPlatform.Ios ? 'simulator' : 'emulator'
} build to run for ${projectDisplayName} app`,
name: 'selectedSimulatorBuild',
choices: builds.map(build => ({
title: `id: ${build.id}, platform: ${appPlatformDisplayNames[build.platform]}, version: ${
build.appVersion
}, build number: ${build.appBuildVersion}`,
value: build,
})),
choices: builds.map(build => {
const buildChoice = formatBuildChoiceTitleAndDescription(build);
return {
title: buildChoice.title,
description: buildChoice.description,
value: build,
disabled: selectPromptDisabledFunction?.(build),
};
}),
warn: warningMessage,
});
return selectedSimulatorBuild;
}
+36 -19
View File
@@ -1,9 +1,8 @@
import { Errors, Flags } from '@oclif/core';
import { existsSync } from 'fs-extra';
import { pathExists } from 'fs-extra';
import assert from 'node:assert';
import { getLatestBuildAsync, listAndSelectBuildsOnAppAsync } from '../../build/queries';
import { BuildDistributionType } from '../../build/types';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
@@ -11,13 +10,15 @@ import {
PaginatedQueryOptions,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { AppPlatform, BuildFragment, BuildStatus } from '../../graphql/generated';
import { AppPlatform, BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { RunArchiveFlags, runAsync } from '../../run/run';
import { buildDistributionTypeToGraphQLDistributionType } from '../../utils/buildDistribution';
import { downloadAndExtractAppAsync, extractAppFromLocalArchiveAsync } from '../../utils/download';
import {
downloadAndMaybeExtractAppAsync,
extractAppFromLocalArchiveAsync,
} from '../../utils/download';
interface RawRunFlags {
latest?: boolean;
@@ -51,7 +52,7 @@ export default class Run extends EasCommand {
exclusive: ['latest', 'id', 'path'],
}),
path: Flags.string({
description: 'Path to the simulator/emulator build archive or simulator build app',
description: 'Path to the simulator/emulator build archive or app',
exclusive: ['latest', 'id', 'url'],
}),
id: Flags.string({
@@ -97,13 +98,19 @@ export default class Run extends EasCommand {
const selectedPlatform = await resolvePlatformAsync(platform);
if (platform === 'ios' && process.platform !== 'darwin') {
Errors.error('You can only use an iOS simulator to run apps on macOS devices', {
exit: 1,
});
}
if (
runArchiveFlags.path &&
!(
(runArchiveFlags.path.endsWith('.tar.gz') ||
runArchiveFlags.path.endsWith('.app') ||
runArchiveFlags.path.endsWith('.apk')) &&
existsSync(runArchiveFlags.path)
(await pathExists(runArchiveFlags.path))
)
) {
Errors.error('The path must point to a .tar.gz archive, .apk file, or .app directory', {
@@ -121,6 +128,10 @@ export default class Run extends EasCommand {
}
async function resolvePlatformAsync(platform?: string): Promise<AppPlatform> {
if (process.platform !== 'darwin') {
return AppPlatform.Android;
}
if (platform && Object.values(AppPlatform).includes(platform.toUpperCase() as AppPlatform)) {
return platform.toUpperCase() as AppPlatform;
}
@@ -143,6 +154,9 @@ async function maybeGetBuildAsync(
projectId: string,
paginatedQueryOptions: PaginatedQueryOptions
): Promise<BuildFragment | null> {
const distributionType =
flags.selectedPlatform === AppPlatform.Ios ? DistributionType.Simulator : undefined;
if (flags.runArchiveFlags.id) {
return BuildQuery.byIdAsync(graphqlClient, flags.runArchiveFlags.id);
} else if (
@@ -151,26 +165,27 @@ async function maybeGetBuildAsync(
!flags.runArchiveFlags.url &&
!flags.runArchiveFlags.latest
) {
return await listAndSelectBuildsOnAppAsync(graphqlClient, {
return await listAndSelectBuildsOnAppAsync(graphqlClient, flags.selectedPlatform, {
projectId,
projectDisplayName: await getDisplayNameForProjectIdAsync(graphqlClient, projectId),
filter: {
platform: flags.selectedPlatform,
distribution: buildDistributionTypeToGraphQLDistributionType(
BuildDistributionType.SIMULATOR
),
distribution: distributionType,
status: BuildStatus.Finished,
},
queryOptions: paginatedQueryOptions,
selectPromptDisabledFunction: build =>
build.platform === AppPlatform.Ios
? false
: !build.artifacts?.applicationArchiveUrl?.endsWith('.apk') ?? false,
warningMessage: 'This is not a simulator/emulator build',
});
} else if (flags.runArchiveFlags.latest) {
return await getLatestBuildAsync(graphqlClient, {
projectId,
filter: {
platform: flags.selectedPlatform,
distribution: buildDistributionTypeToGraphQLDistributionType(
BuildDistributionType.SIMULATOR
),
distribution: distributionType,
status: BuildStatus.Finished,
},
});
@@ -186,25 +201,27 @@ async function getPathToSimulatorBuildAppAsync(
queryOptions: PaginatedQueryOptions
): Promise<string> {
const maybeBuild = await maybeGetBuildAsync(graphqlClient, flags, projectId, queryOptions);
const appExtension = flags.selectedPlatform === AppPlatform.Ios ? 'app' : 'apk';
if (maybeBuild) {
if (!maybeBuild.artifacts?.applicationArchiveUrl) {
throw new Error('Build does not have an application archive url');
}
return await downloadAndExtractAppAsync(
return await downloadAndMaybeExtractAppAsync(
maybeBuild.artifacts.applicationArchiveUrl,
appExtension
flags.selectedPlatform
);
}
if (flags.runArchiveFlags.url) {
return await downloadAndExtractAppAsync(flags.runArchiveFlags.url, appExtension);
return await downloadAndMaybeExtractAppAsync(flags.runArchiveFlags.url, flags.selectedPlatform);
}
if (flags.runArchiveFlags.path?.endsWith('.tar.gz')) {
return await extractAppFromLocalArchiveAsync(flags.runArchiveFlags.path!, appExtension);
return await extractAppFromLocalArchiveAsync(
flags.runArchiveFlags.path,
flags.selectedPlatform
);
}
// this should never fail, due to the validation in sanitizeFlagsAsync
@@ -13,6 +13,7 @@ export default class Credentials extends EasCommand {
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.OptionalProjectConfig,
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.Analytics,
};
@@ -21,6 +22,7 @@ export default class Credentials extends EasCommand {
const {
loggedIn: { actor, graphqlClient },
projectConfig,
getDynamicProjectConfigAsync,
analytics,
} = await this.getContextAsync(Credentials, {
nonInteractive: false,
@@ -31,6 +33,7 @@ export default class Credentials extends EasCommand {
graphqlClient,
analytics,
projectConfig ?? null,
getDynamicProjectConfigAsync,
flags.platform
).runAsync();
}
+1 -1
View File
@@ -75,7 +75,7 @@ export default class Submit extends EasCommand {
exclusive: ['latest', 'id', 'path'],
}),
verbose: Flags.boolean({
description: 'Always print logs from Submission Service',
description: 'Always print logs from EAS Submit',
default: false,
}),
wait: Flags.boolean({
@@ -412,15 +412,18 @@ export default class UpdatePublish extends EasCommand {
}
}
const { branchId } = await ensureBranchExistsAsync(graphqlClient, {
const { branchId, createdBranch } = await ensureBranchExistsAsync(graphqlClient, {
appId: projectId,
branchName,
});
await ensureChannelExistsAsync(graphqlClient, {
appId: projectId,
branchId,
channelName: branchName,
});
if (createdBranch) {
await ensureChannelExistsAsync(graphqlClient, {
appId: projectId,
branchId,
channelName: branchName,
});
}
Log.withTick(`Channel: ${chalk.bold(branchName)} pointed at branch: ${chalk.bold(branchName)}`);
const gitCommitHash = await getVcsClient().getCommitHashAsync();
@@ -1,4 +1,5 @@
import { Analytics } from '../../analytics/AnalyticsManager';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import Log from '../../log';
import { pressAnyKeyToContinueAsync } from '../../prompts';
@@ -10,6 +11,7 @@ export interface Action<T = void> {
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
projectInfo: CredentialsContextProjectInfo | null;
getDynamicProjectConfigAsync: DynamicConfigContextFn;
runAsync(ctx: CredentialsContext): Promise<T>;
}
@@ -27,7 +27,7 @@ import {
displayAndroidAppCredentials,
displayEmptyAndroidCredentials,
} from '../android/utils/printCredentials';
import { CredentialsContext } from '../context';
import { CredentialsContext, CredentialsContextProjectInfo } from '../context';
import { ActionInfo, AndroidActionType, Scope } from './Actions';
import {
buildCredentialsActions,
@@ -52,9 +52,16 @@ export class ManageAndroid {
const buildProfile = hasProjectContext
? await new SelectBuildProfileFromEasJson(this.projectDir, Platform.ANDROID).runAsync()
: null;
let projectInfo: CredentialsContextProjectInfo | null = null;
if (hasProjectContext) {
const { exp, projectId } = await this.callingAction.getDynamicProjectConfigAsync({
env: buildProfile?.env,
});
projectInfo = { exp, projectId };
}
const ctx = new CredentialsContext({
projectDir: process.cwd(),
projectInfo: this.callingAction.projectInfo,
projectInfo,
user: this.callingAction.actor,
graphqlClient: this.callingAction.graphqlClient,
analytics: this.callingAction.analytics,
@@ -15,7 +15,7 @@ import { resolveTargetsAsync } from '../../project/ios/target';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../prompts';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { CredentialsContext } from '../context';
import { CredentialsContext, CredentialsContextProjectInfo } from '../context';
import {
AppStoreApiKeyPurpose,
selectAscApiKeysFromAccountAsync,
@@ -61,9 +61,18 @@ export class ManageIos {
const buildProfile = this.callingAction.projectInfo
? await new SelectBuildProfileFromEasJson(this.projectDir, Platform.IOS).runAsync()
: null;
let projectInfo: CredentialsContextProjectInfo | null = null;
if (this.callingAction.projectInfo) {
const { exp, projectId } = await this.callingAction.getDynamicProjectConfigAsync({
env: buildProfile?.env,
});
projectInfo = { exp, projectId };
}
const ctx = new CredentialsContext({
projectDir: process.cwd(),
projectInfo: this.callingAction.projectInfo,
projectInfo,
user: this.callingAction.actor,
graphqlClient: this.callingAction.graphqlClient,
analytics: this.callingAction.analytics,
@@ -1,4 +1,5 @@
import { Analytics } from '../../analytics/AnalyticsManager';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { selectPlatformAsync } from '../../platform';
import { Actor } from '../../user/User';
@@ -12,6 +13,7 @@ export class SelectPlatform {
public readonly graphqlClient: ExpoGraphqlClient,
public readonly analytics: Analytics,
public readonly projectInfo: CredentialsContextProjectInfo | null,
public readonly getDynamicProjectConfigAsync: DynamicConfigContextFn,
private readonly flagPlatform?: string
) {}
@@ -23,13 +23,12 @@ function formatDeviceClass(device: Device | NewDevice): string {
export default function formatDevice(device: Device, team?: AppleTeamIdAndName): string {
const fields: FormatFieldsItem[] = [
{ label: 'ID', value: device.id },
{ label: 'UDID', value: device.identifier },
{ label: 'Name', value: device.name ?? 'Unknown' },
{
label: 'Class',
value: formatDeviceClass(device),
},
{ label: 'UDID', value: device.identifier },
];
if (team) {
+13
View File
@@ -2523,6 +2523,12 @@ export type GetSignedAssetUploadSpecificationsResult = {
specifications: Array<Scalars['String']>;
};
export enum GitHubAppEnvironment {
Development = 'DEVELOPMENT',
Production = 'PRODUCTION',
Staging = 'STAGING'
}
export type GitHubAppInstallation = {
__typename?: 'GitHubAppInstallation';
accessibleRepositories: GitHubRepositoryPaginationResult;
@@ -2586,7 +2592,9 @@ export type GitHubAppQuery = {
__typename?: 'GitHubAppQuery';
appIdentifier: Scalars['String'];
clientIdentifier: Scalars['String'];
environment: GitHubAppEnvironment;
installation: GitHubAppInstallation;
name: Scalars['String'];
searchRepositories: GitHubRepositoryPaginationResult;
};
@@ -2613,12 +2621,14 @@ export type GitHubRepository = {
export type GitHubRepositoryMetadata = {
__typename?: 'GitHubRepositoryMetadata';
defaultBranch?: Maybe<Scalars['String']>;
githubRepoDescription?: Maybe<Scalars['String']>;
githubRepoName: Scalars['String'];
githubRepoOwnerName: Scalars['String'];
githubRepoUrl: Scalars['String'];
lastPushed: Scalars['DateTime'];
lastUpdated: Scalars['DateTime'];
openGraphImageUrl?: Maybe<Scalars['String']>;
private: Scalars['Boolean'];
};
@@ -3287,6 +3297,7 @@ export type ProjectArchiveSourceInput = {
};
export enum ProjectArchiveSourceType {
Gcs = 'GCS',
S3 = 'S3',
Url = 'URL'
}
@@ -5072,6 +5083,7 @@ export type CreateAndroidSubmissionMutationVariables = Exact<{
appId: Scalars['ID'];
config: AndroidSubmissionConfigInput;
submittedBuildId?: InputMaybe<Scalars['ID']>;
archiveSource?: InputMaybe<SubmissionArchiveSourceInput>;
}>;
@@ -5081,6 +5093,7 @@ export type CreateIosSubmissionMutationVariables = Exact<{
appId: Scalars['ID'];
config: IosSubmissionConfigInput;
submittedBuildId?: InputMaybe<Scalars['ID']>;
archiveSource?: InputMaybe<SubmissionArchiveSourceInput>;
}>;
@@ -28,10 +28,16 @@ export const SubmissionMutation = {
$appId: ID!
$config: AndroidSubmissionConfigInput!
$submittedBuildId: ID
$archiveSource: SubmissionArchiveSourceInput
) {
submission {
createAndroidSubmission(
input: { appId: $appId, config: $config, submittedBuildId: $submittedBuildId }
input: {
appId: $appId
config: $config
submittedBuildId: $submittedBuildId
archiveSource: $archiveSource
}
) {
submission {
id
@@ -60,10 +66,16 @@ export const SubmissionMutation = {
$appId: ID!
$config: IosSubmissionConfigInput!
$submittedBuildId: ID
$archiveSource: SubmissionArchiveSourceInput
) {
submission {
createIosSubmission(
input: { appId: $appId, config: $config, submittedBuildId: $submittedBuildId }
input: {
appId: $appId
config: $config
submittedBuildId: $submittedBuildId
archiveSource: $archiveSource
}
) {
submission {
id
@@ -8,16 +8,17 @@ import {
UploadSessionType,
} from '../generated';
export interface PresignedPost {
export interface SignedUrl {
url: string;
fields: Record<string, string>;
headers: Record<string, string>;
bucketKey: string;
}
export const UploadSessionMutation = {
async createUploadSessionAsync(
graphqlClient: ExpoGraphqlClient,
type: UploadSessionType
): Promise<PresignedPost> {
): Promise<SignedUrl> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateUploadSessionMutation, CreateUploadSessionMutationVariables>(
+1 -2
View File
@@ -10,10 +10,9 @@ import promiseLimit from 'promise-limit';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { AssetMetadataStatus, PartialManifestAsset } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import { PresignedPost } from '../graphql/mutations/UploadSessionMutation';
import { PublishQuery } from '../graphql/queries/PublishQuery';
import Log from '../log';
import { uploadWithPresignedPostWithRetryAsync } from '../uploads';
import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../uploads';
import { expoCommandAsync, shouldUseVersionedExpoCLI } from '../utils/expoCli';
import chunk from '../utils/expodash/chunk';
import uniqBy from '../utils/expodash/uniqBy';
+4 -1
View File
@@ -14,7 +14,10 @@ export async function promptAsync<T extends string = string>(
options: Options = {}
): Promise<Answers<T>> {
if (!process.stdin.isTTY && !global.test) {
throw new Error('Input is required, but stdin is not readable.');
const message = Array.isArray(questions) ? questions[0]?.message : questions.message;
throw new Error(
`Input is required, but stdin is not readable. Failed to display prompt: ${message}`
);
}
return await prompts<T>(questions, {
onCancel() {
+57
View File
@@ -0,0 +1,57 @@
import spawnAsync, { SpawnResult } from '@expo/spawn-async';
import glob from 'fast-glob';
import path from 'path';
import Log from '../../log';
import { getAndroidSdkRootAsync } from './sdk';
async function aaptAsync(...options: string[]): Promise<SpawnResult> {
try {
return await spawnAsync(await getAaptExecutableAsync(), options);
} catch (error: any) {
if (error.stderr) {
Log.error(error.stderr);
}
throw error;
}
}
export async function getAaptExecutableAsync(): Promise<string> {
const sdkRoot = await getAndroidSdkRootAsync();
if (!sdkRoot) {
Log.debug('Failed to resolve the Android SDK path, falling back to global aapt executable');
return 'aapt';
}
const aaptPaths = await glob(
path.posix.join('build-tools/**', process.platform === 'win32' ? 'aapt.exe' : 'aapt'),
{ cwd: sdkRoot, absolute: true }
);
if (aaptPaths.length === 0) {
throw new Error('Failed to resolve the Android aapt path');
}
const sorted = aaptPaths.sort();
return sorted[sorted.length - 1];
}
export async function getAptParametersAsync(
appPath: string
): Promise<{ packageName: string; activityName: string }> {
const { stdout } = await aaptAsync('dump', 'badging', appPath);
const packageNameMatch = stdout.match(/package: name='([^']+)'/);
if (!packageNameMatch) {
throw new Error(`Could not read package name from ${appPath}`);
}
// get activity name
const activityNameMatch = stdout.match(/launchable-activity: name='([^']+)'/);
if (!activityNameMatch) {
throw new Error(`Could not read activity name from ${appPath}`);
}
return {
packageName: packageNameMatch[1],
activityName: activityNameMatch[1],
};
}
+132
View File
@@ -0,0 +1,132 @@
import spawnAsync, { SpawnResult } from '@expo/spawn-async';
import os from 'os';
import path from 'path';
import Log from '../../log';
import { truthy } from '../../utils/expodash/filter';
import { sleepAsync } from '../../utils/promise';
import { getAndroidSdkRootAsync } from './sdk';
export interface AndroidEmulator {
pid?: string;
name: string;
}
const BEGINNING_OF_ADB_ERROR_MESSAGE = 'error: ';
export async function adbAsync(...args: string[]): Promise<SpawnResult> {
const adbExecutable = await getAdbExecutableAsync();
try {
return await spawnAsync(adbExecutable, args);
} catch (error: any) {
let errorMessage = (error.stderr || error.stdout || error.message).trim();
if (errorMessage.startsWith(BEGINNING_OF_ADB_ERROR_MESSAGE)) {
errorMessage = errorMessage.substring(BEGINNING_OF_ADB_ERROR_MESSAGE.length);
}
error.message = errorMessage;
throw error;
}
}
export async function getAdbExecutableAsync(): Promise<string> {
const sdkRoot = await getAndroidSdkRootAsync();
if (!sdkRoot) {
Log.debug('Failed to resolve the Android SDK path, falling back to global adb executable');
return 'adb';
}
return path.join(sdkRoot, 'platform-tools/adb');
}
export function sanitizeAdbDeviceName(deviceName: string): string | undefined {
return deviceName.trim().split(/[\r\n]+/)[0];
}
/**
* Return the Emulator name for an emulator ID, this can be used to determine if an emulator is booted.
*
* @param devicePid a value like `emulator-5554` from `abd devices`
*/
export async function getAdbNameForDeviceIdAsync(emulatorPid: string): Promise<string | null> {
const { stdout } = await adbAsync('-s', emulatorPid, 'emu', 'avd', 'name');
if (stdout.match(/could not connect to TCP port .*: Connection refused/)) {
// Can also occur when the emulator does not exist.
throw new Error(`Emulator not found: ${stdout}`);
}
return sanitizeAdbDeviceName(stdout) ?? null;
}
// TODO: This is very expensive for some operations.
export async function getRunningEmulatorsAsync(): Promise<AndroidEmulator[]> {
const { stdout } = await adbAsync('devices', '-l');
const splitItems = stdout.trim().split(os.EOL);
const attachedDevices: {
pid: string;
type: string;
}[] = splitItems
// First line is `"List of devices attached"`, remove it
.slice(1, splitItems.length)
.map(line => {
// unauthorized: ['FA8251A00719', 'unauthorized', 'usb:338690048X', 'transport_id:5']
// authorized: ['FA8251A00719', 'device', 'usb:336592896X', 'product:walleye', 'model:Pixel_2', 'device:walleye', 'transport_id:4']
// emulator: ['emulator-5554', 'offline', 'transport_id:1']
const [pid] = line.split(' ').filter(truthy);
const type = line.includes('emulator') ? 'emulator' : 'device';
return { pid, type };
})
.filter(({ pid, type }) => !!pid && type === 'emulator');
const devicePromises = attachedDevices.map<Promise<AndroidEmulator>>(async ({ pid }) => {
const name = (await getAdbNameForDeviceIdAsync(pid)) ?? '';
return {
pid,
name,
};
});
return Promise.all(devicePromises);
}
export async function getFirstRunningEmulatorAsync(): Promise<AndroidEmulator | null> {
const emulators = await getRunningEmulatorsAsync();
return emulators[0] ?? null;
}
/**
* Returns true if emulator is booted
*
* @param emulatorPid
*/
export async function isEmulatorBootedAsync(emulatorPid: string): Promise<boolean> {
try {
const { stdout } = await adbAsync('-s', emulatorPid, 'shell', 'getprop', 'sys.boot_completed');
if (stdout.trim() === '1') {
return true;
}
return false;
} catch {
return false;
}
}
export async function waitForEmulatorToBeBootedAsync(
maxWaitTimeMs: number,
intervalMs: number
): Promise<AndroidEmulator> {
Log.newLine();
Log.log('Waiting for the Android emulator to start...');
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTimeMs) {
const emulator = await getFirstRunningEmulatorAsync();
if (emulator?.pid && (await isEmulatorBootedAsync(emulator.pid))) {
return emulator;
}
await sleepAsync(intervalMs);
}
throw new Error('Timed out waiting for the Android emulator to start.');
}
@@ -0,0 +1,160 @@
import spawnAsync, { SpawnResult } from '@expo/spawn-async';
import chalk from 'chalk';
import assert from 'node:assert';
import os from 'os';
import path from 'path';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { truthy } from '../../utils/expodash/filter';
import {
AndroidEmulator,
adbAsync,
getFirstRunningEmulatorAsync,
isEmulatorBootedAsync,
waitForEmulatorToBeBootedAsync,
} from './adb';
import { getAndroidSdkRootAsync } from './sdk';
export const EMULATOR_MAX_WAIT_TIMEOUT_MS = 60 * 1000 * 3;
export async function getEmulatorExecutableAsync(): Promise<string> {
const sdkRoot = await getAndroidSdkRootAsync();
if (sdkRoot) {
return path.join(sdkRoot, 'emulator', 'emulator');
}
return 'emulator';
}
async function emulatorAsync(...options: string[]): Promise<SpawnResult> {
const emulatorExecutable = await getEmulatorExecutableAsync();
try {
return await spawnAsync(emulatorExecutable, options);
} catch (error: any) {
if (error.stderr) {
Log.error(error.stderr);
}
throw error;
}
}
async function getAvaliableAndroidEmulatorsAsync(): Promise<AndroidEmulator[]> {
try {
const { stdout } = await emulatorAsync('-list-avds');
return stdout
.split(os.EOL)
.filter(truthy)
.map(name => ({
name,
}));
} catch {
return [];
}
}
/** Start an Android device and wait until it is booted. */
async function bootEmulatorAsync(
emulator: AndroidEmulator,
{
timeout = EMULATOR_MAX_WAIT_TIMEOUT_MS,
interval = 1000,
}: {
/** Time in milliseconds to wait before asserting a timeout error. */
timeout?: number;
interval?: number;
} = {}
): Promise<AndroidEmulator> {
Log.newLine();
Log.log(`Opening emulator ${chalk.bold(emulator.name)}`);
const emulatorExecutable = await getEmulatorExecutableAsync();
// Start a process to open an emulator
const emulatorProcess = spawnAsync(emulatorExecutable, [`@${emulator.name}`], {
stdio: 'ignore',
detached: true,
});
// we don't want to wait for the emulator process to exit before we can finish `eas build:run` command
// https://github.com/expo/eas-cli/pull/1485#discussion_r1007935871
emulatorProcess.child.unref();
return await waitForEmulatorToBeBootedAsync(timeout, interval);
}
export async function selectEmulatorAsync(): Promise<AndroidEmulator> {
const runningEmulator = await getFirstRunningEmulatorAsync();
if (runningEmulator) {
Log.newLine();
Log.log(`Using open emulator: ${chalk.bold(runningEmulator.name)}`);
return runningEmulator;
}
const emulators = await getAvaliableAndroidEmulatorsAsync();
Log.newLine();
const { selectedEmulator } = await promptAsync({
type: 'select',
message: `Select an emulator to run your app on`,
name: 'selectedEmulator',
choices: emulators.map(emulator => ({
title: emulator.name,
value: emulator,
})),
});
return selectedEmulator;
}
export async function ensureEmulatorBootedAsync(
emulator: AndroidEmulator
): Promise<AndroidEmulator> {
if (!emulator.pid || !(await isEmulatorBootedAsync(emulator.pid))) {
return await bootEmulatorAsync(emulator);
}
return emulator;
}
export async function installAppAsync(
emulator: AndroidEmulator,
apkFilePath: string
): Promise<void> {
Log.newLine();
Log.log('Installing your app...');
assert(emulator.pid);
await adbAsync('-s', emulator.pid, 'install', '-r', '-d', apkFilePath);
Log.succeed('Successfully installed your app!');
}
export async function startAppAsync(
emulator: AndroidEmulator,
packageName: string,
activityName: string
): Promise<void> {
Log.newLine();
Log.log('Starting your app...');
assert(emulator.pid);
await adbAsync(
'-s',
emulator.pid,
'shell',
'am',
'start',
'-a',
'android.intent.action.RUN',
'-f',
'0x20000000', // FLAG_ACTIVITY_SINGLE_TOP -- If set, the activity will not be launched if it is already running at the top of the history stack.
'-n',
`${packageName}/${activityName}`
);
Log.succeed('Successfully started your app!');
}
+10 -3
View File
@@ -1,5 +1,12 @@
import Log from '../../log';
import { getAptParametersAsync } from './aapt';
import * as emulator from './emulator';
import { assertExecutablesExistAsync } from './systemRequirements';
export async function runAppOnAndroidEmulatorAsync(_appPath: string): Promise<void> {
Log.error('Not implemented!');
export async function runAppOnAndroidEmulatorAsync(appPath: string): Promise<void> {
await assertExecutablesExistAsync();
const selectedEmulator = await emulator.selectEmulatorAsync();
const bootedEmulator = await emulator.ensureEmulatorBootedAsync(selectedEmulator);
await emulator.installAppAsync(bootedEmulator, appPath);
const { packageName, activityName } = await getAptParametersAsync(appPath);
await emulator.startAppAsync(bootedEmulator, packageName, activityName);
}
+26
View File
@@ -0,0 +1,26 @@
import { pathExists } from 'fs-extra';
import os from 'os';
import path from 'path';
export const ANDROID_DEFAULT_LOCATION: Readonly<Partial<Record<NodeJS.Platform, string>>> = {
darwin: path.join(os.homedir(), 'Library', 'Android', 'sdk'),
linux: path.join(os.homedir(), 'Android', 'Sdk'),
win32: path.join(os.homedir(), 'AppData', 'Local', 'Android', 'Sdk'),
};
const ANDROID_DEFAULT_LOCATION_FOR_CURRENT_PLATFORM = ANDROID_DEFAULT_LOCATION[process.platform];
export async function getAndroidSdkRootAsync(): Promise<string | null> {
if (process.env.ANDROID_HOME && (await pathExists(process.env.ANDROID_HOME))) {
return process.env.ANDROID_HOME;
} else if (process.env.ANDROID_SDK_ROOT && (await pathExists(process.env.ANDROID_SDK_ROOT))) {
return process.env.ANDROID_SDK_ROOT;
} else if (
ANDROID_DEFAULT_LOCATION_FOR_CURRENT_PLATFORM &&
(await pathExists(ANDROID_DEFAULT_LOCATION_FOR_CURRENT_PLATFORM))
) {
return ANDROID_DEFAULT_LOCATION_FOR_CURRENT_PLATFORM;
} else {
return null;
}
}
@@ -0,0 +1,27 @@
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import { getAaptExecutableAsync } from './aapt';
import { getAdbExecutableAsync } from './adb';
import { getEmulatorExecutableAsync } from './emulator';
async function assertExecutableExistsAsync(executable: string, options?: string[]): Promise<void> {
try {
await spawnAsync(executable, options);
} catch (err: any) {
throw new Error(
`${chalk.bold(
executable
)} executable doesn't seem to work. Please make sure Android Studio is installed on your device and ${chalk.bold(
'ANDROID_HOME'
)} or ${chalk.bold('ANDROID_SDK_ROOT')} env variables are set.
${err.message}`
);
}
}
export async function assertExecutablesExistAsync(): Promise<void> {
await assertExecutableExistsAsync(await getAdbExecutableAsync(), ['--version']);
await assertExecutableExistsAsync(await getEmulatorExecutableAsync(), ['-list-avds']);
await assertExecutableExistsAsync(await getAaptExecutableAsync(), ['version']);
}
+88 -78
View File
@@ -22,15 +22,21 @@ export enum ArchiveSourceType {
latest,
path,
buildId,
build,
buildList,
prompt,
gcs,
}
export interface ArchiveResolverContext {
platform: Platform;
projectId: string;
nonInteractive: boolean;
graphqlClient: ExpoGraphqlClient;
}
interface ArchiveSourceBase {
sourceType: ArchiveSourceType;
platform: Platform;
projectId: string;
nonInteractive: boolean;
}
interface ArchiveUrlSource extends ArchiveSourceBase {
@@ -52,6 +58,11 @@ interface ArchiveBuildIdSource extends ArchiveSourceBase {
id: string;
}
interface ArchiveBuildSource extends ArchiveSourceBase {
sourceType: ArchiveSourceType.build;
build: BuildFragment;
}
interface ArchiveBuildListSource extends ArchiveSourceBase {
sourceType: ArchiveSourceType.buildList;
}
@@ -60,10 +71,10 @@ interface ArchivePromptSource extends ArchiveSourceBase {
sourceType: ArchiveSourceType.prompt;
}
export interface Archive {
build?: BuildFragment;
source: ArchiveSource;
url?: string;
interface ArchiveGCSSource extends ArchiveSourceBase {
sourceType: ArchiveSourceType.gcs;
bucketKey: string;
localSource: ArchivePathSource;
}
export type ArchiveSource =
@@ -71,54 +82,62 @@ export type ArchiveSource =
| ArchiveLatestSource
| ArchivePathSource
| ArchiveBuildIdSource
| ArchiveBuildSource
| ArchiveBuildListSource
| ArchivePromptSource;
| ArchivePromptSource
| ArchiveGCSSource;
export type ResolvedArchiveSource = ArchiveUrlSource | ArchiveGCSSource | ArchiveBuildSource;
export async function getArchiveAsync(
graphqlClient: ExpoGraphqlClient,
ctx: ArchiveResolverContext,
source: ArchiveSource
): Promise<Archive> {
): Promise<ResolvedArchiveSource> {
switch (source.sourceType) {
case ArchiveSourceType.prompt: {
return await handlePromptSourceAsync(graphqlClient, source);
return await handlePromptSourceAsync(ctx);
}
case ArchiveSourceType.url: {
return await handleUrlSourceAsync(graphqlClient, source);
return await handleUrlSourceAsync(ctx, source);
}
case ArchiveSourceType.latest: {
return await handleLatestSourceAsync(graphqlClient, source);
return await handleLatestSourceAsync(ctx);
}
case ArchiveSourceType.path: {
return await handlePathSourceAsync(graphqlClient, source);
return await handlePathSourceAsync(ctx, source);
}
case ArchiveSourceType.buildId: {
return await handleBuildIdSourceAsync(graphqlClient, source);
return await handleBuildIdSourceAsync(ctx, source);
}
case ArchiveSourceType.buildList: {
return await handleBuildListSourceAsync(graphqlClient, source);
return await handleBuildListSourceAsync(ctx);
}
case ArchiveSourceType.gcs: {
return source;
}
case ArchiveSourceType.build: {
return source;
}
}
}
async function handleUrlSourceAsync(
graphqlClient: ExpoGraphqlClient,
ctx: ArchiveResolverContext,
source: ArchiveUrlSource
): Promise<Archive> {
): Promise<ResolvedArchiveSource> {
const { url } = source;
if (!validateUrl(url)) {
Log.error(chalk.bold(`The URL you provided is invalid: ${url}`));
return getArchiveAsync(graphqlClient, {
...source,
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.prompt,
});
}
const maybeBuildId = isBuildDetailsPage(url);
if (maybeBuildId) {
if (await askIfUseBuildIdFromUrlAsync(source, maybeBuildId)) {
return getArchiveAsync(graphqlClient, {
...source,
if (await askIfUseBuildIdFromUrlAsync(ctx, source, maybeBuildId)) {
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.buildId,
id: maybeBuildId,
});
@@ -126,20 +145,19 @@ async function handleUrlSourceAsync(
}
return {
sourceType: ArchiveSourceType.url,
url,
source,
};
}
async function handleLatestSourceAsync(
graphqlClient: ExpoGraphqlClient,
source: ArchiveLatestSource
): Promise<Archive> {
ctx: ArchiveResolverContext
): Promise<ResolvedArchiveSource> {
try {
const [latestBuild] = await getRecentBuildsForSubmissionAsync(
graphqlClient,
toAppPlatform(source.platform),
source.projectId
ctx.graphqlClient,
toAppPlatform(ctx.platform),
ctx.projectId
);
if (!latestBuild) {
@@ -148,15 +166,14 @@ async function handleLatestSourceAsync(
"Couldn't find any builds for this project on EAS servers. It looks like you haven't run 'eas build' yet."
)
);
return getArchiveAsync(graphqlClient, {
...source,
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.prompt,
});
}
return {
sourceType: ArchiveSourceType.build,
build: latestBuild,
source,
};
} catch (err) {
Log.error(err);
@@ -165,34 +182,37 @@ async function handleLatestSourceAsync(
}
async function handlePathSourceAsync(
graphqlClient: ExpoGraphqlClient,
ctx: ArchiveResolverContext,
source: ArchivePathSource
): Promise<Archive> {
): Promise<ResolvedArchiveSource> {
if (!(await isExistingFileAsync(source.path))) {
Log.error(chalk.bold(`${source.path} doesn't exist`));
return getArchiveAsync(graphqlClient, {
...source,
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.prompt,
});
}
Log.log('Uploading your app archive to the Expo Submission Service');
const uploadUrl = await uploadAppArchiveAsync(graphqlClient, source.path);
Log.log('Uploading your app archive to EAS Submit');
const bucketKey = await uploadAppArchiveAsync(ctx.graphqlClient, source.path);
return {
url: uploadUrl,
source,
sourceType: ArchiveSourceType.gcs,
bucketKey,
localSource: {
sourceType: ArchiveSourceType.path,
path: source.path,
},
};
}
async function handleBuildIdSourceAsync(
graphqlClient: ExpoGraphqlClient,
ctx: ArchiveResolverContext,
source: ArchiveBuildIdSource
): Promise<Archive> {
): Promise<ResolvedArchiveSource> {
try {
const build = await BuildQuery.byIdAsync(graphqlClient, source.id);
const build = await BuildQuery.byIdAsync(ctx.graphqlClient, source.id);
if (build.platform !== toAppPlatform(source.platform)) {
const expectedPlatformName = appPlatformDisplayNames[toAppPlatform(source.platform)];
if (build.platform !== toAppPlatform(ctx.platform)) {
const expectedPlatformName = appPlatformDisplayNames[toAppPlatform(ctx.platform)];
const receivedPlatformName = appPlatformDisplayNames[build.platform];
Log.error(
chalk.bold(
@@ -200,15 +220,14 @@ async function handleBuildIdSourceAsync(
)
);
return getArchiveAsync(graphqlClient, {
...source,
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.prompt,
});
}
return {
sourceType: ArchiveSourceType.build,
build,
source,
};
} catch (err) {
Log.error(chalk.bold(`Could not find build with ID ${source.id}`));
@@ -220,26 +239,24 @@ async function handleBuildIdSourceAsync(
);
Log.debug('Original error:', err);
return getArchiveAsync(graphqlClient, {
...source,
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.prompt,
});
}
}
async function handleBuildListSourceAsync(
graphqlClient: ExpoGraphqlClient,
source: ArchiveBuildListSource
): Promise<Archive> {
ctx: ArchiveResolverContext
): Promise<ResolvedArchiveSource> {
try {
const appPlatform = toAppPlatform(source.platform);
const appPlatform = toAppPlatform(ctx.platform);
const expiryDate = new Date(); // artifacts expire after 30 days
expiryDate.setDate(expiryDate.getDate() - 30);
const recentBuilds = await getRecentBuildsForSubmissionAsync(
graphqlClient,
ctx.graphqlClient,
appPlatform,
source.projectId,
ctx.projectId,
{
limit: BUILD_LIST_ITEM_COUNT,
}
@@ -252,8 +269,7 @@ async function handleBuildListSourceAsync(
"It looks like you haven't run 'eas build' yet."
)
);
return getArchiveAsync(graphqlClient, {
...source,
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.prompt,
});
}
@@ -265,8 +281,7 @@ async function handleBuildListSourceAsync(
'EAS keeps your build artifacts only for 30 days.'
)
);
return getArchiveAsync(graphqlClient, {
...source,
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.prompt,
});
}
@@ -286,15 +301,14 @@ async function handleBuildListSourceAsync(
});
if (selectedBuild == null) {
return getArchiveAsync(graphqlClient, {
...source,
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.prompt,
});
}
return {
sourceType: ArchiveSourceType.build,
build: selectedBuild,
source,
};
} catch (err) {
Log.error(err);
@@ -334,9 +348,8 @@ function formatBuildChoice(build: BuildFragment, expiryDate: Date): prompts.Choi
}
async function handlePromptSourceAsync(
graphqlClient: ExpoGraphqlClient,
source: ArchivePromptSource
): Promise<Archive> {
ctx: ArchiveResolverContext
): Promise<ResolvedArchiveSource> {
const { sourceType: sourceTypeRaw } = await promptAsync({
name: 'sourceType',
type: 'select',
@@ -360,31 +373,27 @@ async function handlePromptSourceAsync(
const sourceType = sourceTypeRaw as ArchiveSourceType;
switch (sourceType) {
case ArchiveSourceType.url: {
const url = await askForArchiveUrlAsync(source.platform);
return getArchiveAsync(graphqlClient, {
...source,
const url = await askForArchiveUrlAsync(ctx.platform);
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.url,
url,
});
}
case ArchiveSourceType.path: {
const path = await askForArchivePathAsync(source.platform);
return getArchiveAsync(graphqlClient, {
...source,
const path = await askForArchivePathAsync(ctx.platform);
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.path,
path,
});
}
case ArchiveSourceType.buildList: {
return getArchiveAsync(graphqlClient, {
...source,
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.buildList,
});
}
case ArchiveSourceType.buildId: {
const id = await askForBuildIdAsync();
return getArchiveAsync(graphqlClient, {
...source,
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.buildId,
id,
});
@@ -454,13 +463,14 @@ async function askForBuildIdAsync(): Promise<string> {
}
async function askIfUseBuildIdFromUrlAsync(
ctx: ArchiveResolverContext,
source: ArchiveUrlSource,
buildId: string
): Promise<boolean> {
const { url } = source;
Log.warn(`It seems that you provided a build details page URL: ${url}`);
Log.warn('We expected to see the build artifact URL.');
if (!source.nonInteractive) {
if (!ctx.nonInteractive) {
const useAsBuildId = await confirmAsync({
message: `Do you want to submit build ${buildId} instead?`,
});
@@ -5,12 +5,15 @@ import { withAnalyticsAsync } from '../analytics/common';
import {
AndroidSubmissionConfigInput,
IosSubmissionConfigInput,
SubmissionArchiveSourceInput,
SubmissionArchiveSourceType,
SubmissionFragment,
} from '../graphql/generated';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import { ora } from '../ora';
import { appPlatformDisplayNames } from '../platform';
import { ArchiveSourceType, ResolvedArchiveSource } from './ArchiveSource';
import { SubmissionContext } from './context';
export interface SubmissionInput<P extends Platform> {
@@ -19,6 +22,7 @@ export interface SubmissionInput<P extends Platform> {
? AndroidSubmissionConfigInput
: IosSubmissionConfigInput;
buildId?: string;
archiveSource?: SubmissionArchiveSourceInput;
}
interface AnalyticEvents {
@@ -71,6 +75,27 @@ export default abstract class BaseSubmitter<
resolvedOptions: ResolvedSourceOptions
): Promise<SubmissionInput<P>>;
public formatArchive(
archive: ResolvedArchiveSource
): Pick<SubmissionInput<P>, 'archiveSource' | 'buildId'> {
switch (archive.sourceType) {
case ArchiveSourceType.url: {
return { archiveSource: { type: SubmissionArchiveSourceType.Url, url: archive.url } };
}
case ArchiveSourceType.gcs: {
return {
archiveSource: {
type: SubmissionArchiveSourceType.GcsSubmitArchive,
bucketKey: archive.bucketKey,
},
};
}
case ArchiveSourceType.build: {
return { buildId: archive.build.id };
}
}
}
private async createSubmissionAsync(
submissionInput: SubmissionInput<P>
): Promise<SubmissionFragment> {
@@ -4,17 +4,17 @@ import { vol } from 'memfs';
import { v4 as uuidv4 } from 'uuid';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform, BuildFragment, UploadSessionType } from '../../graphql/generated';
import {
AppPlatform,
BuildFragment,
SubmissionArchiveSourceType,
UploadSessionType,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
import { confirmAsync, promptAsync } from '../../prompts';
import { uploadFileAtPathToS3Async } from '../../uploads';
import {
Archive,
ArchiveSourceType,
BUILD_LIST_ITEM_COUNT,
getArchiveAsync,
} from '../ArchiveSource';
import { uploadFileAtPathToGCSAsync } from '../../uploads';
import { ArchiveSourceType, BUILD_LIST_ITEM_COUNT, getArchiveAsync } from '../ArchiveSource';
import { getRecentBuildsForSubmissionAsync } from '../utils/builds';
jest.mock('fs');
@@ -28,12 +28,15 @@ jest.mock('../../graphql/queries/BuildQuery', () => ({
},
}));
const ARCHIVE_URL = 'https://url.to/archive.tar.gz';
const ARCHIVE_SOURCE = {
type: SubmissionArchiveSourceType.Url,
url: 'https://url.to/archive.tar.gz',
};
const MOCK_BUILD_FRAGMENT: Partial<BuildFragment> = {
id: uuidv4(),
artifacts: {
buildUrl: ARCHIVE_URL,
buildUrl: ARCHIVE_SOURCE.url,
},
appVersion: '1.2.3',
platform: AppPlatform.Android,
@@ -67,72 +70,82 @@ describe(getArchiveAsync, () => {
});
it('handles URL source', async () => {
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.url,
url: ARCHIVE_URL,
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.url,
url: ARCHIVE_SOURCE.url,
}
);
assertArchiveResult(archive, ArchiveSourceType.url);
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
it('prompts again if provided URL is invalid', async () => {
jest
.mocked(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
.mockResolvedValueOnce({ url: ARCHIVE_SOURCE.url });
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.url,
url: 'invalid',
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.url,
url: 'invalid',
}
);
expect(promptAsync).toHaveBeenCalledTimes(2);
assertArchiveResult(archive, ArchiveSourceType.url);
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
it('asks the user if use build id instead of build details page url', async () => {
jest.mocked(confirmAsync).mockResolvedValueOnce(true);
jest.mocked(BuildQuery.byIdAsync).mockResolvedValueOnce(MOCK_BUILD_FRAGMENT as BuildFragment);
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.url,
url: 'https://expo.dev/accounts/turtle/projects/blah/builds/81da6b36-efe4-4262-8970-84f03efeec81',
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.url,
url: 'https://expo.dev/accounts/turtle/projects/blah/builds/81da6b36-efe4-4262-8970-84f03efeec81',
}
);
expect(confirmAsync).toHaveBeenCalled();
assertArchiveResult(archive, ArchiveSourceType.buildId);
expect((archive.source as any).id).toBe('81da6b36-efe4-4262-8970-84f03efeec81');
expect(archive.sourceType).toBe(ArchiveSourceType.build);
expect((archive as any).build.id).toBe(MOCK_BUILD_FRAGMENT.id);
});
it('handles prompt source', async () => {
jest
.mocked(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
.mockResolvedValueOnce({ url: ARCHIVE_SOURCE.url });
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.prompt,
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.prompt,
}
);
assertArchiveResult(archive, ArchiveSourceType.url);
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
it('handles Build ID source', async () => {
const buildId = uuidv4();
jest.mocked(BuildQuery.byIdAsync).mockResolvedValueOnce(MOCK_BUILD_FRAGMENT as BuildFragment);
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.buildId,
id: buildId,
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.buildId,
id: buildId,
}
);
expect(BuildQuery.byIdAsync).toBeCalledWith(graphqlClient, buildId);
assertArchiveResult(archive, ArchiveSourceType.buildId);
expect(archive.sourceType).toBe(ArchiveSourceType.build);
});
it('prompts again if build with provided ID doesnt exist', async () => {
@@ -140,15 +153,17 @@ describe(getArchiveAsync, () => {
jest
.mocked(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
.mockResolvedValueOnce({ url: ARCHIVE_SOURCE.url });
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.buildId,
id: uuidv4(),
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.buildId,
id: uuidv4(),
}
);
assertArchiveResult(archive, ArchiveSourceType.url);
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
it('handles latest build source', async () => {
@@ -157,18 +172,19 @@ describe(getArchiveAsync, () => {
.mocked(getRecentBuildsForSubmissionAsync)
.mockResolvedValueOnce([MOCK_BUILD_FRAGMENT as BuildFragment]);
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
projectId,
sourceType: ArchiveSourceType.latest,
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient, projectId },
{
sourceType: ArchiveSourceType.latest,
}
);
expect(getRecentBuildsForSubmissionAsync).toBeCalledWith(
graphqlClient,
toAppPlatform(SOURCE_STUB_INPUT.platform),
projectId
);
assertArchiveResult(archive, ArchiveSourceType.latest);
expect(archive.sourceType).toBe(ArchiveSourceType.build);
});
it('prompts again if no builds exists when selected latest', async () => {
@@ -176,14 +192,16 @@ describe(getArchiveAsync, () => {
jest
.mocked(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
.mockResolvedValueOnce({ url: ARCHIVE_SOURCE.url });
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.latest,
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.latest,
}
);
assertArchiveResult(archive, ArchiveSourceType.url);
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
it('handles build-list-select source', async () => {
@@ -193,11 +211,12 @@ describe(getArchiveAsync, () => {
.mockResolvedValueOnce([MOCK_BUILD_FRAGMENT as BuildFragment]);
jest.mocked(promptAsync).mockResolvedValueOnce({ selectedBuild: MOCK_BUILD_FRAGMENT });
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
projectId,
sourceType: ArchiveSourceType.buildList,
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient, projectId },
{
sourceType: ArchiveSourceType.buildList,
}
);
expect(getRecentBuildsForSubmissionAsync).toBeCalledWith(
graphqlClient,
@@ -205,7 +224,7 @@ describe(getArchiveAsync, () => {
projectId,
{ limit: BUILD_LIST_ITEM_COUNT }
);
assertArchiveResult(archive, ArchiveSourceType.buildList);
expect(archive.sourceType).toBe(ArchiveSourceType.build);
});
it('prompts again if all builds have expired', async () => {
@@ -218,14 +237,16 @@ describe(getArchiveAsync, () => {
jest
.mocked(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
.mockResolvedValueOnce({ url: ARCHIVE_SOURCE.url });
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.buildList,
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.buildList,
}
);
assertArchiveResult(archive, ArchiveSourceType.url);
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
it('falls back to prompt if user selected "None of the above"', async () => {
@@ -236,62 +257,62 @@ describe(getArchiveAsync, () => {
.mocked(promptAsync)
.mockResolvedValueOnce({ selectedBuild: null })
.mockResolvedValueOnce({ sourceType: ArchiveSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
.mockResolvedValueOnce({ url: ARCHIVE_SOURCE.url });
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.buildList,
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.buildList,
}
);
assertArchiveResult(archive, ArchiveSourceType.url);
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
it('handles path source', async () => {
const path = '/archive.apk';
await fs.writeFile(path, 'some content');
jest
.mocked(uploadFileAtPathToS3Async)
.mockResolvedValueOnce({ url: ARCHIVE_URL, bucketKey: 'wat' });
jest.mocked(uploadFileAtPathToGCSAsync).mockResolvedValueOnce('wat');
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.path,
path,
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.path,
path,
}
);
expect(uploadFileAtPathToS3Async).toBeCalledWith(
expect(uploadFileAtPathToGCSAsync).toBeCalledWith(
graphqlClient,
UploadSessionType.EasSubmitAppArchive,
UploadSessionType.EasSubmitGcsAppArchive,
path,
expect.anything()
);
assertArchiveResult(archive, ArchiveSourceType.path);
expect(archive).toMatchObject({
sourceType: ArchiveSourceType.gcs,
bucketKey: 'wat',
localSource: {
sourceType: ArchiveSourceType.path,
path,
},
});
});
it('prompts again if provided path doesnt exist', async () => {
jest
.mocked(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_URL });
.mockResolvedValueOnce({ url: ARCHIVE_SOURCE.url });
const archive = await getArchiveAsync(graphqlClient, {
...SOURCE_STUB_INPUT,
sourceType: ArchiveSourceType.path,
path: './doesnt/exist.aab',
});
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.path,
path: './doesnt/exist.aab',
}
);
assertArchiveResult(archive, ArchiveSourceType.url);
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
});
function assertArchiveResult(
archive: Archive,
expectedSourceType: ArchiveSourceType,
expectedUrl: string = ARCHIVE_URL
): void {
expect(archive.source.sourceType).toBe(expectedSourceType);
if (archive.url) {
expect(archive.url).toBe(expectedUrl);
}
}
@@ -114,7 +114,7 @@ export default class AndroidSubmitCommand {
private resolveArchiveSource(): Result<ArchiveSource> {
try {
return result(resolveArchiveSource(this.ctx, Platform.ANDROID));
return result(resolveArchiveSource(this.ctx));
} catch (err: any) {
return result(err);
}
@@ -10,7 +10,7 @@ import {
} from '../../graphql/generated';
import { SubmissionMutation } from '../../graphql/mutations/SubmissionMutation';
import formatFields from '../../utils/formatFields';
import { Archive, ArchiveSource, getArchiveAsync } from '../ArchiveSource';
import { ArchiveSource, ResolvedArchiveSource, getArchiveAsync } from '../ArchiveSource';
import BaseSubmitter, { SubmissionInput } from '../BaseSubmitter';
import { SubmissionContext } from '../context';
import {
@@ -35,7 +35,7 @@ export interface AndroidSubmissionOptions
}
interface ResolvedSourceOptions {
archive: Archive;
archive: ResolvedArchiveSource;
serviceAccountKeyResult: ServiceAccountKeyResult;
}
@@ -47,7 +47,16 @@ export default class AndroidSubmitter extends BaseSubmitter<
constructor(ctx: SubmissionContext<Platform.ANDROID>, options: AndroidSubmissionOptions) {
const sourceOptionsResolver = {
// eslint-disable-next-line async-protect/async-suffix
archive: async () => await getArchiveAsync(ctx.graphqlClient, this.options.archiveSource),
archive: async () =>
await getArchiveAsync(
{
graphqlClient: ctx.graphqlClient,
platform: Platform.ANDROID,
projectId: ctx.projectId,
nonInteractive: ctx.nonInteractive,
},
this.options.archiveSource
),
// eslint-disable-next-line async-protect/async-suffix
serviceAccountKeyResult: async () => {
return await getServiceAccountKeyResultAsync(this.ctx, this.options.serviceAccountSource);
@@ -71,7 +80,7 @@ export default class AndroidSubmitter extends BaseSubmitter<
public async createSubmissionInputAsync(
resolvedSourceOptions: ResolvedSourceOptions
): Promise<SubmissionInput<Platform.ANDROID>> {
const submissionConfig = await this.formatSubmissionConfig(this.options, resolvedSourceOptions);
const submissionConfig = this.formatSubmissionConfig(this.options, resolvedSourceOptions);
printSummary(
this.prepareSummaryData(this.options, resolvedSourceOptions),
@@ -80,7 +89,7 @@ export default class AndroidSubmitter extends BaseSubmitter<
return {
projectId: this.options.projectId,
submissionConfig,
buildId: resolvedSourceOptions.archive.build?.id,
...this.formatArchive(resolvedSourceOptions.archive),
};
}
@@ -88,21 +97,22 @@ export default class AndroidSubmitter extends BaseSubmitter<
projectId,
submissionConfig,
buildId,
archiveSource,
}: SubmissionInput<Platform.ANDROID>): Promise<SubmissionFragment> {
return await SubmissionMutation.createAndroidSubmissionAsync(this.ctx.graphqlClient, {
appId: projectId,
config: submissionConfig,
submittedBuildId: buildId,
archiveSource,
});
}
private formatSubmissionConfig(
options: AndroidSubmissionOptions,
{ archive, serviceAccountKeyResult }: ResolvedSourceOptions
{ serviceAccountKeyResult }: ResolvedSourceOptions
): AndroidSubmissionConfigInput {
const { track, releaseStatus, changesNotSentForReview } = options;
return {
archiveUrl: archive.url,
track,
changesNotSentForReview,
releaseStatus,
@@ -15,6 +15,7 @@ import {
BuildFragment,
SubmissionAndroidReleaseStatus,
SubmissionAndroidTrack,
SubmissionArchiveSourceType,
} from '../../../graphql/generated';
import { SubmissionMutation } from '../../../graphql/mutations/SubmissionMutation';
import { createTestProject } from '../../../project/__tests__/project-utils';
@@ -104,7 +105,7 @@ describe(AndroidSubmitCommand, () => {
});
describe('sending submission', () => {
it('sends a request to Submission Service', async () => {
it('sends a request to EAS Submit', async () => {
const projectId = uuidv4();
const graphqlClient = {} as any as ExpoGraphqlClient;
const analytics = instance(mock<Analytics>());
@@ -134,8 +135,8 @@ describe(AndroidSubmitCommand, () => {
expect(SubmissionMutation.createAndroidSubmissionAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
archiveSource: { type: SubmissionArchiveSourceType.Url, url: 'http://expo.dev/fake.apk' },
config: {
archiveUrl: 'http://expo.dev/fake.apk',
googleServiceAccountKeyJson: fakeFiles['/google-service-account.json'],
releaseStatus: SubmissionAndroidReleaseStatus.Draft,
track: SubmissionAndroidTrack.Internal,
+1 -19
View File
@@ -3,10 +3,7 @@ import { Platform } from '@expo/eas-build-job';
import { ArchiveSource, ArchiveSourceType, isUuidV4 } from './ArchiveSource';
import { SubmissionContext } from './context';
export function resolveArchiveSource<T extends Platform>(
ctx: SubmissionContext<T>,
platform: T
): ArchiveSource {
export function resolveArchiveSource<T extends Platform>(ctx: SubmissionContext<T>): ArchiveSource {
const { url, path, id, latest } = ctx.archiveFlags;
const chosenOptions = [url, path, id, latest];
if (chosenOptions.filter(opt => opt).length > 1) {
@@ -17,17 +14,11 @@ export function resolveArchiveSource<T extends Platform>(
return {
sourceType: ArchiveSourceType.url,
url,
platform,
projectId: ctx.projectId,
nonInteractive: ctx.nonInteractive,
};
} else if (path) {
return {
sourceType: ArchiveSourceType.path,
path,
platform,
projectId: ctx.projectId,
nonInteractive: ctx.nonInteractive,
};
} else if (id) {
if (!isUuidV4(id)) {
@@ -36,25 +27,16 @@ export function resolveArchiveSource<T extends Platform>(
return {
sourceType: ArchiveSourceType.buildId,
id,
platform,
projectId: ctx.projectId,
nonInteractive: ctx.nonInteractive,
};
} else if (latest) {
return {
sourceType: ArchiveSourceType.latest,
platform,
projectId: ctx.projectId,
nonInteractive: ctx.nonInteractive,
};
} else if (ctx.nonInteractive) {
throw new Error('You need to specify the archive source when running in non-interactive mode ');
} else {
return {
sourceType: ArchiveSourceType.prompt,
platform,
projectId: ctx.projectId,
nonInteractive: ctx.nonInteractive,
};
}
}
@@ -196,7 +196,7 @@ export default class IosSubmitCommand {
private resolveArchiveSource(): Result<ArchiveSource> {
try {
return result(resolveArchiveSource(this.ctx, Platform.IOS));
return result(resolveArchiveSource(this.ctx));
} catch (err: any) {
return result(err);
}
@@ -6,7 +6,7 @@ import { MinimalAscApiKey } from '../../credentials/ios/credentials';
import { IosSubmissionConfigInput, SubmissionFragment } from '../../graphql/generated';
import { SubmissionMutation } from '../../graphql/mutations/SubmissionMutation';
import formatFields from '../../utils/formatFields';
import { Archive, ArchiveSource, getArchiveAsync } from '../ArchiveSource';
import { ArchiveSource, ResolvedArchiveSource, getArchiveAsync } from '../ArchiveSource';
import BaseSubmitter, { SubmissionInput } from '../BaseSubmitter';
import { SubmissionContext } from '../context';
import {
@@ -40,7 +40,7 @@ export interface IosSubmissionOptions
}
interface ResolvedSourceOptions {
archive: Archive;
archive: ResolvedArchiveSource;
credentials: {
appSpecificPassword?: AppSpecificPasswordCredentials;
ascApiKeyResult?: AscApiKeyResult;
@@ -55,7 +55,16 @@ export default class IosSubmitter extends BaseSubmitter<
constructor(ctx: SubmissionContext<Platform.IOS>, options: IosSubmissionOptions) {
const sourceOptionsResolver = {
// eslint-disable-next-line async-protect/async-suffix
archive: async () => await getArchiveAsync(ctx.graphqlClient, this.options.archiveSource),
archive: async () =>
await getArchiveAsync(
{
graphqlClient: ctx.graphqlClient,
platform: Platform.IOS,
projectId: ctx.projectId,
nonInteractive: ctx.nonInteractive,
},
this.options.archiveSource
),
// eslint-disable-next-line async-protect/async-suffix
credentials: async () => {
const maybeAppSpecificPassword = this.options.appSpecificPasswordSource
@@ -105,7 +114,7 @@ export default class IosSubmitter extends BaseSubmitter<
return {
projectId: this.options.projectId,
submissionConfig,
buildId: resolvedSourceOptions.archive.build?.id,
...this.formatArchive(resolvedSourceOptions.archive),
};
}
@@ -113,24 +122,25 @@ export default class IosSubmitter extends BaseSubmitter<
projectId,
submissionConfig,
buildId,
archiveSource,
}: SubmissionInput<Platform.IOS>): Promise<SubmissionFragment> {
return await SubmissionMutation.createIosSubmissionAsync(this.ctx.graphqlClient, {
appId: projectId,
config: submissionConfig,
submittedBuildId: buildId,
archiveSource,
});
}
private formatSubmissionConfig(
options: IosSubmissionOptions,
{ archive, credentials }: ResolvedSourceOptions
{ credentials }: ResolvedSourceOptions
): IosSubmissionConfigInput {
const { appSpecificPassword, ascApiKeyResult } = credentials;
const { appleIdUsername, ascAppIdentifier } = options;
return {
ascAppIdentifier,
appleIdUsername,
archiveUrl: archive.url,
...(appSpecificPassword ? this.formatAppSpecificPassword(appSpecificPassword) : null),
...(ascApiKeyResult?.result ? this.formatAscApiKeyResult(ascApiKeyResult.result) : null),
};
@@ -9,6 +9,7 @@ import {
jester as mockJester,
testProjectId,
} from '../../../credentials/__tests__/fixtures-constants';
import { SubmissionArchiveSourceType } from '../../../graphql/generated';
import { SubmissionMutation } from '../../../graphql/mutations/SubmissionMutation';
import { createTestProject } from '../../../project/__tests__/project-utils';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
@@ -80,7 +81,7 @@ describe(IosSubmitCommand, () => {
});
describe('sending submission', () => {
it('sends a request to Submission Service', async () => {
it('sends a request to EAS Submit', async () => {
const projectId = uuidv4();
const graphqlClient = {} as any as ExpoGraphqlClient;
const analytics = instance(mock<Analytics>());
@@ -110,8 +111,8 @@ describe(IosSubmitCommand, () => {
expect(SubmissionMutation.createIosSubmissionAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
archiveSource: { type: SubmissionArchiveSourceType.Url, url: 'http://expo.dev/fake.ipa' },
config: {
archiveUrl: 'http://expo.dev/fake.ipa',
appleIdUsername: '[email protected]',
appleAppSpecificPassword: 'supersecret',
ascAppIdentifier: '12345678',
+4 -4
View File
@@ -2,7 +2,7 @@ import fs from 'fs-extra';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { UploadSessionType } from '../../graphql/generated';
import { uploadFileAtPathToS3Async } from '../../uploads';
import { uploadFileAtPathToGCSAsync } from '../../uploads';
import { createProgressTracker } from '../../utils/progress';
export async function isExistingFileAsync(filePath: string): Promise<boolean> {
@@ -19,9 +19,9 @@ export async function uploadAppArchiveAsync(
path: string
): Promise<string> {
const fileSize = (await fs.stat(path)).size;
const { url } = await uploadFileAtPathToS3Async(
const bucketKey = await uploadFileAtPathToGCSAsync(
graphqlClient,
UploadSessionType.EasSubmitAppArchive,
UploadSessionType.EasSubmitGcsAppArchive,
path,
createProgressTracker({
total: fileSize,
@@ -29,5 +29,5 @@ export async function uploadAppArchiveAsync(
completedMessage: 'Uploaded to EAS Submit',
})
);
return url;
return bucketKey;
}
+10 -9
View File
@@ -3,7 +3,7 @@ import chalk from 'chalk';
import { AppPlatform, BuildFragment } from '../../graphql/generated';
import Log from '../../log';
import formatFields from '../../utils/formatFields';
import { Archive, ArchiveSourceType } from '../ArchiveSource';
import { ArchiveSourceType, ResolvedArchiveSource } from '../ArchiveSource';
export interface ArchiveSourceSummaryFields {
archiveUrl?: string;
archivePath?: string;
@@ -43,19 +43,20 @@ function formatSubmissionBuildSummary(build: BuildFragment): string {
);
}
export function formatArchiveSourceSummary({ source, build }: Archive): ArchiveSourceSummaryFields {
export function formatArchiveSourceSummary(
archive: ResolvedArchiveSource
): ArchiveSourceSummaryFields {
const summarySlice: ArchiveSourceSummaryFields = {};
switch (source.sourceType) {
case ArchiveSourceType.path:
summarySlice.archivePath = source.path;
switch (archive.sourceType) {
case ArchiveSourceType.gcs:
summarySlice.archivePath = archive.localSource.path;
break;
case ArchiveSourceType.url:
summarySlice.archiveUrl = source.url;
summarySlice.archiveUrl = archive.url;
break;
case ArchiveSourceType.buildId:
case ArchiveSourceType.latest:
summarySlice.formattedBuild = formatSubmissionBuildSummary(build!);
case ArchiveSourceType.build:
summarySlice.formattedBuild = formatSubmissionBuildSummary(archive.build!);
break;
}
return summarySlice;
+24 -49
View File
@@ -1,37 +1,29 @@
import assert from 'assert';
import FormData from 'form-data';
import fs from 'fs-extra';
import { Response } from 'node-fetch';
import nullthrows from 'nullthrows';
import promiseRetry from 'promise-retry';
import { URL } from 'url';
import { ExpoGraphqlClient } from './commandUtils/context/contextUtils/createGraphqlClient';
import fetch from './fetch';
import { UploadSessionType } from './graphql/generated';
import { PresignedPost, UploadSessionMutation } from './graphql/mutations/UploadSessionMutation';
import { SignedUrl, UploadSessionMutation } from './graphql/mutations/UploadSessionMutation';
import { ProgressHandler } from './utils/progress';
export async function uploadFileAtPathToS3Async(
export interface PresignedPost {
url: string;
fields: Record<string, string>;
}
export async function uploadFileAtPathToGCSAsync(
graphqlClient: ExpoGraphqlClient,
type: UploadSessionType,
path: string,
handleProgressEvent: ProgressHandler
): Promise<{ url: string; bucketKey: string }> {
const presignedPost = await UploadSessionMutation.createUploadSessionAsync(graphqlClient, type);
assert(presignedPost.fields.key, 'key is not specified in in presigned post');
): Promise<string> {
const signedUrl = await UploadSessionMutation.createUploadSessionAsync(graphqlClient, type);
const response = await uploadWithPresignedPostWithProgressAsync(
path,
presignedPost,
handleProgressEvent
);
const location = nullthrows(
response.headers.get('location'),
`location does not exist in response headers (make sure you're uploading to AWS S3)`
);
const url = fixS3Url(location);
return { url, bucketKey: presignedPost.fields.key };
await uploadWithSignedUrlWithProgressAsync(path, signedUrl, handleProgressEvent);
return signedUrl.bucketKey;
}
export async function uploadWithPresignedPostWithRetryAsync(
@@ -72,10 +64,10 @@ export async function uploadWithPresignedPostWithRetryAsync(
);
}
async function createPresignedPostFormDataAsync(
async function uploadWithPresignedPostAsync(
file: string,
presignedPost: PresignedPost
): Promise<{ form: FormData; fileSize: number }> {
): Promise<Response> {
const fileStat = await fs.stat(file);
const fileSize = fileStat.size;
const form = new FormData();
@@ -83,14 +75,6 @@ async function createPresignedPostFormDataAsync(
form.append(fieldKey, fieldValue);
}
form.append('file', fs.createReadStream(file), { knownLength: fileSize });
return { form, fileSize };
}
async function uploadWithPresignedPostAsync(
file: string,
presignedPost: PresignedPost
): Promise<Response> {
const { form } = await createPresignedPostFormDataAsync(file, presignedPost);
const formHeaders = form.getHeaders();
return await fetch(presignedPost.url, {
method: 'POST',
@@ -101,23 +85,25 @@ async function uploadWithPresignedPostAsync(
});
}
async function uploadWithPresignedPostWithProgressAsync(
async function uploadWithSignedUrlWithProgressAsync(
file: string,
presignedPost: PresignedPost,
signedUrl: SignedUrl,
handleProgressEvent: ProgressHandler
): Promise<Response> {
const { form, fileSize } = await createPresignedPostFormDataAsync(file, presignedPost);
const formHeaders = form.getHeaders();
const uploadPromise = fetch(presignedPost.url, {
method: 'POST',
body: form,
const fileStat = await fs.stat(file);
const fileSize = fileStat.size;
const readStream = fs.createReadStream(file);
const uploadPromise = fetch(signedUrl.url, {
method: 'PUT',
body: readStream,
headers: {
...formHeaders,
...signedUrl.headers,
},
});
let currentSize = 0;
form.addListener('data', (chunk: Buffer) => {
readStream.addListener('data', (chunk: Buffer) => {
currentSize += Buffer.byteLength(chunk);
handleProgressEvent({
progress: {
@@ -136,14 +122,3 @@ async function uploadWithPresignedPostWithProgressAsync(
throw error;
}
}
/**
* S3 returns broken URLs, sth like:
* https://submission-service-archives.s3.amazonaws.com/production%2Fdc98ca84-1473-4cb3-ae81-8c7b291cb27e%2F4424aa95-b985-4e2f-8755-9507b1037c1c
* This function replaces %2F with /.
*/
export function fixS3Url(archiveUrl: string): string {
const parsed = new URL(archiveUrl);
parsed.pathname = decodeURIComponent(parsed.pathname);
return parsed.toString();
}
+48 -33
View File
@@ -1,5 +1,4 @@
import spawnAsync from '@expo/spawn-async';
import cliProgress from 'cli-progress';
import glob from 'fast-glob';
import fs from 'fs';
import path from 'path';
@@ -9,15 +8,18 @@ import { promisify } from 'util';
import { v4 as uuidv4 } from 'uuid';
import fetch, { RequestInit } from '../fetch';
import { AppPlatform } from '../graphql/generated';
import Log from '../log';
import { formatBytes } from './files';
import { getTmpDirectory } from './paths';
import { ProgressHandler, createProgressTracker } from './progress';
const pipeline = promisify(Stream.pipeline);
type ProgressCallback = (progress: number, total: number, loaded: number) => void;
let didProgressBarFinish = false;
function wrapFetchWithProgress() {
return async (url: string, init: RequestInit, onProgressCallback: ProgressCallback) => {
return async (url: string, init: RequestInit, progressHandler: ProgressHandler) => {
const response = await fetch(url, init);
if (response.ok) {
@@ -40,7 +42,15 @@ function wrapFetchWithProgress() {
const progress = length / total;
onProgressCallback(progress, total, length);
if (!didProgressBarFinish) {
progressHandler({
progress: { total, percent: progress, transferred: length },
isComplete: total === length,
});
if (total === length) {
didProgressBarFinish = true;
}
}
};
response.body.on('data', chunk => {
@@ -56,35 +66,23 @@ function wrapFetchWithProgress() {
};
}
async function downloadFileWithProgressBarAsync(
async function downloadFileWithProgressTrackerAsync(
url: string,
outputPath: string,
infoMessage?: string
progressTrackerMessage: string | ((ratio: number, total: number) => string),
progressTrackerCompletedMessage: string
): Promise<void> {
Log.newLine();
Log.log(infoMessage ? infoMessage : `Downloading file from ${url}...`);
const downloadProgressBar = new cliProgress.SingleBar(
{ format: '|{bar}|' },
cliProgress.Presets.rect
);
let downloadProgressBarStarted = false;
const response = await wrapFetchWithProgress()(
url,
{
timeout: 1000 * 60 * 5, // 5 minutes
},
(_progress: number, total: number, loaded: number): void => {
if (!downloadProgressBarStarted) {
downloadProgressBar.start(total, loaded);
downloadProgressBarStarted = true;
} else if (loaded < total) {
downloadProgressBar.update(loaded);
} else {
downloadProgressBar.stop();
}
}
createProgressTracker({
message: progressTrackerMessage,
completedMessage: progressTrackerCompletedMessage,
})
);
if (!response.ok) {
@@ -94,34 +92,51 @@ async function downloadFileWithProgressBarAsync(
await pipeline(response.body, fs.createWriteStream(outputPath));
}
export async function downloadAndExtractAppAsync(
export async function downloadAndMaybeExtractAppAsync(
url: string,
applicationExtension: string
platform: AppPlatform
): Promise<string> {
const outputDir = path.join(getTmpDirectory(), uuidv4());
await fs.promises.mkdir(outputDir, { recursive: true });
const tmpArchivePathDir = path.join(getTmpDirectory(), uuidv4());
await fs.promises.mkdir(tmpArchivePathDir, { recursive: true });
if (url.endsWith('apk')) {
const apkFilePath = path.join(outputDir, `${uuidv4()}.apk`);
await downloadFileWithProgressTrackerAsync(
url,
apkFilePath,
(ratio, total) => `Downloading app (${formatBytes(total * ratio)} / ${formatBytes(total)})`,
'Successfully downloaded app'
);
return apkFilePath;
} else {
const tmpArchivePathDir = path.join(getTmpDirectory(), uuidv4());
await fs.promises.mkdir(tmpArchivePathDir, { recursive: true });
const tmpArchivePath = path.join(tmpArchivePathDir, `${uuidv4()}.tar.gz`);
const tmpArchivePath = path.join(tmpArchivePathDir, `${uuidv4()}.tar.gz`);
await downloadFileWithProgressBarAsync(url, tmpArchivePath, 'Downloading app archive...');
await tarExtractAsync(tmpArchivePath, outputDir);
await downloadFileWithProgressTrackerAsync(
url,
tmpArchivePath,
(ratio, total) =>
`Downloading app archive (${formatBytes(total * ratio)} / ${formatBytes(total)})`,
'Successfully downloaded app archive'
);
await tarExtractAsync(tmpArchivePath, outputDir);
return await getAppPathAsync(outputDir, applicationExtension);
return await getAppPathAsync(outputDir, platform === AppPlatform.Ios ? 'app' : 'apk');
}
}
export async function extractAppFromLocalArchiveAsync(
appArchivePath: string,
applicationExtension: string
platform: AppPlatform
): Promise<string> {
const outputDir = path.join(getTmpDirectory(), uuidv4());
await fs.promises.mkdir(outputDir, { recursive: true });
await tarExtractAsync(appArchivePath, outputDir);
return await getAppPathAsync(outputDir, applicationExtension);
return await getAppPathAsync(outputDir, platform === AppPlatform.Android ? 'apk' : 'app');
}
async function getAppPathAsync(outputDir: string, applicationExtension: string): Promise<string> {
+7 -5
View File
@@ -21,7 +21,7 @@ export function createProgressTracker({
completedMessage,
}: {
total?: number;
message: string | ((ratio: number) => string);
message: string | ((ratio: number, total: number) => string);
completedMessage: string | ((duration: string) => string);
}): ProgressHandler {
let bar: Ora | null = null;
@@ -31,17 +31,19 @@ export function createProgressTracker({
const timerLabel = String(Date.now());
const getMessage = (v: number): string => {
const getMessage = (v: number, total: number): string => {
const ratio = Math.min(Math.max(v, 0), 1);
const percent = Math.floor(ratio * 100);
return typeof message === 'string' ? `${message} ${percent.toFixed(0)}%` : message(ratio);
return typeof message === 'string'
? `${message} ${percent.toFixed(0)}%`
: message(ratio, total);
};
return ({ progress, isComplete, error }) => {
if (progress) {
if (!bar && (progress.total !== undefined || total !== undefined)) {
calcTotal = (total ?? progress.total) as number;
bar = ora(getMessage(0)).start();
bar = ora(getMessage(0, calcTotal)).start();
startTimer(timerLabel);
}
if (progress.total) {
@@ -56,7 +58,7 @@ export function createProgressTracker({
percentage = current / calcTotal;
}
bar.text = getMessage(percentage);
bar.text = getMessage(percentage, calcTotal);
}
transferredSoFar = progress.transferred;
}
+3 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with eas.json",
"version": "2.7.0",
"version": "2.8.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@babel/code-frame": "7.18.6",
"@expo/eas-build-job": "0.2.96",
"@expo/eas-build-job": "0.2.97",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "10.1.0",
@@ -14,6 +14,7 @@
"joi": "17.6.4",
"log-symbols": "4.1.0",
"semver": "7.3.8",
"terminal-link": "2.1.1",
"tslib": "2.4.0"
},
"devDependencies": {
+2 -4
View File
@@ -303,6 +303,7 @@
"enum": [
"default",
"latest",
"macos-monterey-12.6-xcode-14.1",
"macos-monterey-12.6-xcode-14.0",
"macos-monterey-12.4-xcode-13.4",
"macos-monterey-12.3-xcode-13.3",
@@ -310,10 +311,7 @@
"macos-big-sur-11.4-xcode-13.0",
"macos-big-sur-11.4-xcode-12.5"
],
"markdownEnumDescriptions": [
"`macos-monterey-12.4-xcode-13.4`",
"`macos-monterey-12.6-xcode-14.0`"
]
"markdownEnumDescriptions": ["`macos-monterey-12.6-xcode-14.0`", "`macos-monterey-12.6-xcode-14.1`"]
},
{
"type": "string"
+25
View File
@@ -3,12 +3,33 @@ import assert from 'assert';
import chalk from 'chalk';
import fs from 'fs-extra';
import * as fleece from 'golden-fleece';
import { ValidationError } from 'joi';
import path from 'path';
import { InvalidEasJsonError, MissingEasJsonError } from './errors';
import { link } from './log';
import { EasJsonSchema } from './schema';
import { EasJson } from './types';
const customErrorMessageHandlers: ((err: ValidationError) => void)[] = [
// Ask user to upgrade eas-cli version or check the docs when image is invalid.
(err: ValidationError) => {
for (const detail of err.details) {
if (detail.path[detail.path.length - 1] === 'image') {
throw new InvalidEasJsonError(
chalk.red(
`Specified build image '${
detail?.context?.value
}' is not recognized. Please update your EAS CLI and see ${link(
'https://docs.expo.dev/build-reference/infrastructure/'
)} for supported build images.`
)
);
}
}
},
];
export class EasJsonAccessor {
private easJsonPath: string;
@@ -39,6 +60,10 @@ export class EasJsonAccessor {
noDefaults: true,
});
if (error) {
for (const handler of customErrorMessageHandlers) {
handler(error);
}
const errorMessages = error.message.split('. ');
throw new InvalidEasJsonError(
`${chalk.bold('eas.json')} is not valid.\n- ${errorMessages.join('\n- ')}`
+16
View File
@@ -0,0 +1,16 @@
import chalk from 'chalk';
import terminalLink from 'terminal-link';
// link function from packages/eas-cli/src/log.ts
export function link(
url: string,
{ text = url, dim = true }: { text?: string; dim?: boolean } = {}
): string {
let output: string;
if (terminalLink.isSupported) {
output = terminalLink(text, url);
} else {
output = `${text === url ? '' : text + ': '}${chalk.underline(url)}`;
}
return dim ? chalk.dim(output) : output;
}
+3
View File
@@ -5,5 +5,8 @@ set -eo pipefail
SCRIPTS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/.. && pwd )"
pushd $SCRIPTS_DIR >/dev/null 2>&1
# Install modules
yarn --silent
# Run script
yarn --silent $@
popd >/dev/null 2>&1
Generated
+4 -4
View File
@@ -1378,10 +1378,10 @@
slugify "^1.3.4"
sucrase "^3.20.0"
"@expo/[email protected]6":
version "0.2.96"
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-0.2.96.tgz#711d0a03e078d7e03e9514a5c7acdea0d593d110"
integrity sha512-Y8jeEEwzU/GfYNSq+5Snbl5NFpXSgqtRCvPIpWUWka0XsPf4qhU9e6iWjOJboi5VbQIAQfZbXMjW/OZByjT/OA==
"@expo/[email protected]7":
version "0.2.97"
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-0.2.97.tgz#8f0884c498c17bb3d3e19cad767ee7f3b6b81c05"
integrity sha512-1OtHIoJbMQSsp0jEqBLI7nsqqhy1Hv38H0Ogznyr3uFqk5zWLX57pmSx4wWYv9R8M6SXsgJJq/nNa5RAopsipA==
dependencies:
joi "^17.4.2"
semver "^7.3.5"