mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72283cdda2 | ||
|
|
9874ff26b3 | ||
|
|
85d64b7ad3 | ||
|
|
2dba2705b7 | ||
|
|
d1bec6e00a | ||
|
|
597e012ca3 | ||
|
|
8632b8ada7 | ||
|
|
b288879838 | ||
|
|
8beb8f46da | ||
|
|
9120bf5777 | ||
|
|
01619d4182 | ||
|
|
aa4ea4ae2a | ||
|
|
c236c2ffb5 | ||
|
|
b5a3a513de | ||
|
|
6058183c45 | ||
|
|
a042d27573 | ||
|
|
88b21e476d | ||
|
|
3d497d4f05 |
No files matched your search
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "2.7.0"
|
||||
"version": "2.8.0"
|
||||
}
|
||||
+1
-1
@@ -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
Generated
+91
@@ -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": "",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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!');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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- ')}`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user