mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca718d8da9 | ||
|
|
3e7b849ad1 | ||
|
|
2ec646659d | ||
|
|
c33e3d8a4c | ||
|
|
d65c180746 | ||
|
|
754af79e0c | ||
|
|
3733cc4cf8 | ||
|
|
9e5c1740b4 | ||
|
|
58919d37ab | ||
|
|
2ec4279ac7 | ||
|
|
3052276c3b | ||
|
|
65a9238d48 | ||
|
|
5a1bb94563 | ||
|
|
1094d3f9f7 | ||
|
|
d8452ce6f8 | ||
|
|
a5be039fd2 | ||
|
|
6178a54292 |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.1.0"
|
||||
"version": "0.1.2"
|
||||
}
|
||||
+13
-13
@@ -21,7 +21,7 @@ $ npm install -g eas-cli
|
||||
$ eas COMMAND
|
||||
running command...
|
||||
$ eas (-v|--version|version)
|
||||
eas-cli/0.1.0 darwin-x64 node-v14.15.0
|
||||
eas-cli/0.1.2 darwin-x64 node-v14.15.0
|
||||
$ eas --help [COMMAND]
|
||||
USAGE
|
||||
$ eas COMMAND
|
||||
@@ -58,7 +58,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/account/login.ts)_
|
||||
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -72,7 +72,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/account/logout.ts)_
|
||||
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -86,7 +86,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/account/view.ts)_
|
||||
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -105,7 +105,7 @@ OPTIONS
|
||||
--wait Wait for build(s) to complete
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/build/index.ts)_
|
||||
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/index.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -120,7 +120,7 @@ OPTIONS
|
||||
--allow-experimental Enable experimental configuration steps.
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/build/configure.ts)_
|
||||
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -136,7 +136,7 @@ OPTIONS
|
||||
--status=(in-queue|in-progress|errored|finished)
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/build/list.ts)_
|
||||
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/list.ts)_
|
||||
|
||||
## `eas build:view [BUILDID]`
|
||||
|
||||
@@ -147,7 +147,7 @@ USAGE
|
||||
$ eas build:view [BUILDID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/build/view.ts)_
|
||||
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/view.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -158,7 +158,7 @@ USAGE
|
||||
$ eas credentials
|
||||
```
|
||||
|
||||
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/credentials.ts)_
|
||||
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/credentials.ts)_
|
||||
|
||||
## `eas device:create`
|
||||
|
||||
@@ -169,7 +169,7 @@ USAGE
|
||||
$ eas device:create
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/device/create.ts)_
|
||||
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/create.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -183,7 +183,7 @@ OPTIONS
|
||||
--apple-team-id=apple-team-id
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/device/list.ts)_
|
||||
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/list.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -194,7 +194,7 @@ USAGE
|
||||
$ eas device:view [UDID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/device/view.ts)_
|
||||
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/view.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -291,5 +291,5 @@ EXAMPLES
|
||||
and provide its App ID
|
||||
```
|
||||
|
||||
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.1.0/build/commands/submit.ts)_
|
||||
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/submit.ts)_
|
||||
<!-- commandsstop -->
|
||||
@@ -3,4 +3,5 @@ export default jest.fn().mockReturnValue({
|
||||
fail: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
}),
|
||||
succeed: jest.fn(),
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
@@ -14,7 +14,7 @@
|
||||
"@expo/eas-build-job": "0.1.4",
|
||||
"@expo/eas-json": "^0.1.0-alpha.20",
|
||||
"@expo/json-file": "^8.2.24",
|
||||
"@expo/plist": "^0.0.10",
|
||||
"@expo/plist": "^0.0.11",
|
||||
"@expo/plugin-warn-if-update-available": "^1.7.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "^1.5.0",
|
||||
@@ -39,6 +39,7 @@
|
||||
"keychain": "^1.3.0",
|
||||
"lodash": "^4.17.20",
|
||||
"md5-file": "^5.0.0",
|
||||
"mime": "^2.4.7",
|
||||
"minimatch": "^3.0.4",
|
||||
"node-fetch": "^2.6.1",
|
||||
"node-forge": "^0.10.0",
|
||||
@@ -70,6 +71,7 @@
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/getenv": "^1.0.0",
|
||||
"@types/lodash": "^4.14.161",
|
||||
"@types/mime": "^2.0.3",
|
||||
"@types/node": "^12",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"@types/node-forge": "^0.9.5",
|
||||
@@ -83,6 +85,7 @@
|
||||
"axios": "^0.21.0",
|
||||
"babel-jest": "^26.3.0",
|
||||
"memfs": "^3.2.0",
|
||||
"mockdate": "^3.0.2",
|
||||
"ts-node": "^8",
|
||||
"typescript": "^3.9.7",
|
||||
"wonka": "^4.0.14"
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void
|
||||
if (isExpoUpdatesInstalled(ctx.projectDir)) {
|
||||
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
|
||||
}
|
||||
log.withTick('Successfully configured the Android project.');
|
||||
log.withTick('Android project configured');
|
||||
}
|
||||
|
||||
export async function validateAndSyncProjectConfigurationAsync(
|
||||
|
||||
@@ -212,7 +212,7 @@ async function reviewAndCommitChangesAsync(
|
||||
);
|
||||
} else if (selected === ShouldCommitChanges.Yes) {
|
||||
await commitPromptAsync(commitMessage);
|
||||
log.withTick('Successfully committed changes.');
|
||||
log.withTick('Committed changes.');
|
||||
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
|
||||
await showDiffAsync();
|
||||
await reviewAndCommitChangesAsync(commitMessage, { nonInteractive, askedFirstTime: false });
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<v
|
||||
await reader.validateAsync();
|
||||
|
||||
existingEasJson = await reader.readRawAsync();
|
||||
log.withTick('Validated eas.json.');
|
||||
log.withTick('Validated eas.json');
|
||||
|
||||
// If we have already populated eas.json with the default fields for the
|
||||
// platform then proceed
|
||||
@@ -122,7 +122,7 @@ export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<v
|
||||
|
||||
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
|
||||
await gitAddAsync(easJsonPath, { intentToAdd: true });
|
||||
log.withTick(`${existingEasJson ? 'Updated' : 'Generated'} eas.json.`);
|
||||
log.withTick(`${existingEasJson ? 'Updated' : 'Generated'} eas.json`);
|
||||
}
|
||||
|
||||
enum ShouldCommitChanges {
|
||||
@@ -153,7 +153,7 @@ async function reviewAndCommitChangesAsync(
|
||||
|
||||
if (selected === ShouldCommitChanges.Yes) {
|
||||
await commitPromptAsync(commitMessage);
|
||||
log.withTick('Committed changes.');
|
||||
log.withTick('Committed changes');
|
||||
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
|
||||
await showDiffAsync();
|
||||
await reviewAndCommitChangesAsync(commitMessage, false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -7,8 +7,12 @@ import log from '../log';
|
||||
import { confirmAsync, promptAsync } from '../prompts';
|
||||
import { platformDisplayNames } from './constants';
|
||||
|
||||
const USING_CREDENTIALS_JSON_MSG = 'Using credentials from the local credentials.json file';
|
||||
const USING_REMOTE_CREDENTIALS_MSG = 'Using credentials stored on the Expo servers';
|
||||
function logCredentials(target: 'local' | 'remote', platform: Platform) {
|
||||
let message = `Using ${target} ${platformDisplayNames[platform]} credentials`;
|
||||
if (target === 'local') message += ` ${chalk.dim('(credentials.json)')}`;
|
||||
if (target === 'remote') message += ` ${chalk.dim('(Expo server)')}`;
|
||||
log.succeed(message);
|
||||
}
|
||||
|
||||
async function ensureCredentialsAutoAsync(
|
||||
provider: CredentialsProvider,
|
||||
@@ -41,7 +45,7 @@ async function ensureCredentialsAutoAsync(
|
||||
const { select } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'select',
|
||||
message: 'Which credentials you want to use for this build?',
|
||||
message: 'Which credentials do you want to use for this build?',
|
||||
choices: [
|
||||
{ title: 'Local credentials.json', value: CredentialsSource.LOCAL },
|
||||
{ title: 'Credentials stored on Expo servers.', value: CredentialsSource.REMOTE },
|
||||
@@ -52,10 +56,10 @@ async function ensureCredentialsAutoAsync(
|
||||
return CredentialsSource.LOCAL;
|
||||
}
|
||||
} else if (hasLocal) {
|
||||
log(chalk.bold(USING_CREDENTIALS_JSON_MSG));
|
||||
logCredentials('local', provider.platform);
|
||||
return CredentialsSource.LOCAL;
|
||||
} else if (hasRemote) {
|
||||
log(chalk.bold(USING_REMOTE_CREDENTIALS_MSG));
|
||||
logCredentials('remote', provider.platform);
|
||||
return CredentialsSource.REMOTE;
|
||||
} else {
|
||||
if (nonInteractive) {
|
||||
@@ -69,7 +73,7 @@ async function ensureCredentialsAutoAsync(
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: 'Do you want to generate new credentials?',
|
||||
message: `${platform} credentials not found, generate now?`,
|
||||
});
|
||||
if (confirm) {
|
||||
return CredentialsSource.REMOTE;
|
||||
@@ -89,10 +93,10 @@ export async function ensureCredentialsAsync(
|
||||
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
|
||||
switch (src) {
|
||||
case CredentialsSource.LOCAL:
|
||||
log(chalk.bold(USING_CREDENTIALS_JSON_MSG));
|
||||
logCredentials('local', provider.platform);
|
||||
return CredentialsSource.LOCAL;
|
||||
case CredentialsSource.REMOTE:
|
||||
log(chalk.bold(USING_REMOTE_CREDENTIALS_MSG));
|
||||
logCredentials('remote', provider.platform);
|
||||
return CredentialsSource.REMOTE;
|
||||
case CredentialsSource.AUTO:
|
||||
return await ensureCredentialsAutoAsync(provider, workflow, nonInteractive);
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
|
||||
if (isExpoUpdatesInstalled(ctx.projectDir)) {
|
||||
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
|
||||
}
|
||||
log.withTick('Successfully configured the iOS project.');
|
||||
log.withTick('iOS project configured');
|
||||
}
|
||||
|
||||
export async function validateAndSyncProjectConfigurationAsync(
|
||||
|
||||
@@ -20,6 +20,8 @@ export default function formatBuild(build: Build, { accountName }: Options) {
|
||||
label: 'Status',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case BuildStatus.IN_QUEUE:
|
||||
return chalk.blue('in queue');
|
||||
case BuildStatus.IN_PROGRESS:
|
||||
return chalk.blue('in progress');
|
||||
case BuildStatus.FINISHED:
|
||||
@@ -38,7 +40,7 @@ export default function formatBuild(build: Build, { accountName }: Options) {
|
||||
{
|
||||
label: 'Artifact',
|
||||
get value() {
|
||||
if (build.status === BuildStatus.IN_PROGRESS) {
|
||||
if (build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS) {
|
||||
return '<in progress>';
|
||||
}
|
||||
|
||||
@@ -55,7 +57,7 @@ export default function formatBuild(build: Build, { accountName }: Options) {
|
||||
{
|
||||
label: 'Finished at',
|
||||
value:
|
||||
build.status === BuildStatus.IN_PROGRESS
|
||||
build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS
|
||||
? '<in progress>'
|
||||
: new Date(build.updatedAt).toLocaleString(),
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
isGitInstalledAsync,
|
||||
} from '../../utils/git';
|
||||
import { getTmpDirectory } from '../../utils/paths';
|
||||
import { endTimer, formatMilliseconds, startTimer } from '../../utils/timer';
|
||||
|
||||
async function ensureGitRepoExistsAsync(): Promise<void> {
|
||||
if (!(await isGitInstalledAsync())) {
|
||||
@@ -95,17 +96,41 @@ async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void
|
||||
}
|
||||
|
||||
async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
|
||||
const spinner = ora('Preparing project to be uploaded').start();
|
||||
const spinner = ora('Compressing project files');
|
||||
|
||||
await fs.mkdirp(getTmpDirectory());
|
||||
const tarPath = path.join(getTmpDirectory(), `${uuidv4()}.tar.gz`);
|
||||
|
||||
await spawnAsync(
|
||||
'git',
|
||||
['archive', '--format=tar.gz', '--prefix', 'project/', '-o', tarPath, 'HEAD'],
|
||||
{ cwd: await gitRootDirectoryAsync() }
|
||||
// If the compression takes longer then a second, show the spinner.
|
||||
// This can happen when the user has a lot of resources or doesn't ignore their CocoaPods.
|
||||
// A basic project on a Mac can compress in roughly ~40ms.
|
||||
// A fairly complex project without CocoaPods ignored can take up to 30s.
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
spinner.start();
|
||||
},
|
||||
log.isDebug ? 1 : 1000
|
||||
);
|
||||
spinner.succeed('Project ready to be uploaded');
|
||||
// TODO: Possibly warn after more time about unoptimized assets.
|
||||
const compressTimerLabel = 'makeProjectTarballAsync';
|
||||
startTimer(compressTimerLabel);
|
||||
|
||||
try {
|
||||
await spawnAsync(
|
||||
'git',
|
||||
['archive', '--format=tar.gz', '--prefix', 'project/', '-o', tarPath, 'HEAD'],
|
||||
{ cwd: await gitRootDirectoryAsync() }
|
||||
);
|
||||
} finally {
|
||||
// Stop the timer
|
||||
clearTimeout(timer);
|
||||
|
||||
const duration = endTimer(compressTimerLabel);
|
||||
if (spinner.isSpinning) {
|
||||
const prettyTime = formatMilliseconds(duration);
|
||||
spinner.succeed(`Compressed project files ${chalk.dim(prettyTime)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { size } = await fs.stat(tarPath);
|
||||
return { size, path: tarPath };
|
||||
|
||||
@@ -4,19 +4,28 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import { configureAsync } from '../../build/configure';
|
||||
import { createCommandContextAsync } from '../../build/context';
|
||||
import { buildAsync } from '../../build/create';
|
||||
import { AnalyticsEvent, RequestedPlatform } from '../../build/types';
|
||||
import { AnalyticsEvent, Build as BuildType, RequestedPlatform } from '../../build/types';
|
||||
import Analytics from '../../build/utils/analytics';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import { isGitStatusCleanAsync } from '../../build/utils/repository';
|
||||
import { learnMore } from '../../log';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import log, { learnMore } from '../../log';
|
||||
import {
|
||||
isEasEnabledForProjectAsync,
|
||||
warnEasUnavailable,
|
||||
} from '../../project/isEasEnabledForProject';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
export default class Build extends Command {
|
||||
@@ -48,7 +57,7 @@ export default class Build extends Command {
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = this.parse(Build);
|
||||
await ensureLoggedInAsync();
|
||||
const user = await ensureLoggedInAsync();
|
||||
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
if (!flags.platform && nonInteractive) {
|
||||
@@ -66,6 +75,17 @@ export default class Build extends Command {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
|
||||
accountName,
|
||||
platform,
|
||||
projectId,
|
||||
user,
|
||||
});
|
||||
if (accountHasPendingBuilds) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureProjectConfiguredAsync(projectDir);
|
||||
|
||||
const trackingCtx = {
|
||||
@@ -89,10 +109,68 @@ export default class Build extends Command {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureNoPendingBuildsExistAsync({
|
||||
user,
|
||||
platform,
|
||||
accountName,
|
||||
projectId,
|
||||
}: {
|
||||
user: Actor;
|
||||
platform: RequestedPlatform;
|
||||
accountName: string;
|
||||
projectId: string;
|
||||
}): Promise<boolean> {
|
||||
// allow expo admins to run as many builds as they wish
|
||||
if (user.isExpoAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appPlatforms = toAppPlatforms(platform);
|
||||
const maybePendingBuilds = await Promise.all(
|
||||
appPlatforms.map(appPlatform => BuildQuery.getPendingBuildIdAsync(accountName, appPlatform))
|
||||
);
|
||||
const pendingBuilds = maybePendingBuilds.filter(i => i !== null);
|
||||
if (pendingBuilds.length > 0) {
|
||||
log.newLine();
|
||||
log.error(
|
||||
'Your other builds are still pending. Wait for them to complete before running this command again.'
|
||||
);
|
||||
log.newLine();
|
||||
const results = await Promise.all(
|
||||
pendingBuilds.map(pendingBuild => {
|
||||
return apiClient.get<{ data: BuildType }>(
|
||||
`projects/${projectId}/builds/${pendingBuild?.id}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
log(formatBuild(result.body.data, { accountName }));
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toAppPlatforms(requestedPlatform: RequestedPlatform): AppPlatform[] {
|
||||
if (requestedPlatform === RequestedPlatform.All) {
|
||||
return [AppPlatform.Android, AppPlatform.Ios];
|
||||
} else if (requestedPlatform === RequestedPlatform.Android) {
|
||||
return [AppPlatform.Android];
|
||||
} else {
|
||||
return [AppPlatform.Ios];
|
||||
}
|
||||
}
|
||||
|
||||
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
const { platform } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Choose the platforms to build',
|
||||
message: 'Build for platforms',
|
||||
name: 'platform',
|
||||
choices: [
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ If you are not sure what is the UDID of the device you are looking for, run:
|
||||
const device = await AppleDeviceQuery.getByDeviceIdentifier(accountName, UDID);
|
||||
|
||||
if (device) {
|
||||
spinner.succeed('Successfully fetched device details');
|
||||
spinner.succeed('Fetched device details');
|
||||
log(`\n${formatDevice(device, device.appleTeam)}`);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find a device with the UDID ${UDID}`);
|
||||
|
||||
@@ -214,7 +214,7 @@ export default class BuildSubmit extends Command {
|
||||
async function promptForPlatformAsync(): Promise<SubmissionPlatform> {
|
||||
const { platform } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Choose a platform to submit to',
|
||||
message: 'Submit to platform',
|
||||
name: 'platform',
|
||||
choices: [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../../log';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
@@ -19,6 +17,6 @@ export class UpdateFcmKey implements Action {
|
||||
]);
|
||||
|
||||
await ctx.android.updateFcmKeyAsync(this.experienceName, fcmApiKey);
|
||||
log(chalk.green('Updated successfully'));
|
||||
log.succeed('Updated successfully');
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export class RemoveKeystore implements Action {
|
||||
await manager.runActionAsync(new BackupKeystore(this.projectFullName));
|
||||
|
||||
await ctx.android.removeKeystoreAsync(this.projectFullName);
|
||||
log(chalk.green('Keystore removed successfully.'));
|
||||
log.succeed('Keystore removed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../../log';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
import { Context } from '../../context';
|
||||
@@ -42,6 +40,6 @@ export class SetupBuildCredentialsFromCredentialsJson implements Action {
|
||||
await validateKeystoreAsync(localCredentials.keystore);
|
||||
}
|
||||
await ctx.android.updateKeystoreAsync(this.projectFullName, localCredentials.keystore);
|
||||
log(chalk.green('Keystore updated successfully.'));
|
||||
log.succeed('Keystore updated');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../../log';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
import { Context } from '../../context';
|
||||
@@ -11,10 +9,8 @@ export class UpdateCredentialsJson implements Action {
|
||||
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
log('Updating Android credentials in credentials.json');
|
||||
await updateAndroidCredentialsAsync(ctx);
|
||||
log(
|
||||
chalk.green(
|
||||
'Android part of your local credentials.json is synced with values stored on Expo servers.'
|
||||
)
|
||||
log.succeed(
|
||||
'Android part of your local credentials.json is synced with values stored on Expo servers.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export class UpdateKeystore implements Action {
|
||||
const keystore = await this.provideOrGenerateAsync();
|
||||
|
||||
await ctx.android.updateKeystoreAsync(this.projectFullName, keystore);
|
||||
log(chalk.green('Keystore updated successfully.'));
|
||||
log.succeed('Keystore updated');
|
||||
}
|
||||
|
||||
private async provideOrGenerateAsync(): Promise<Keystore> {
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('run UpdateKeystore when keystore exist on www', () => {
|
||||
});
|
||||
const manager = createManagerMock();
|
||||
|
||||
(prompts as any).mockImplementationOnce(() => ({ answer: false })); // promptForCredentials: let expo handle
|
||||
(prompts as any).mockImplementationOnce(() => ({ value: true })); // promptForCredentials: let expo handle
|
||||
|
||||
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('run UpdateKeystore when keystore exist on www', () => {
|
||||
|
||||
await fs.writeFile('/keystore.jks', testKeystore.keystore, 'base64');
|
||||
(prompts as any)
|
||||
.mockImplementationOnce(() => ({ answer: true })) // promptForCredentials: user specified
|
||||
.mockImplementationOnce(() => ({ value: false })) // promptForCredentials: user specified
|
||||
.mockImplementationOnce(() => ({ input: '/keystore.jks' })) // keystore
|
||||
.mockImplementationOnce(() => ({ input: 'test' })) // keystore password
|
||||
.mockImplementationOnce(() => ({ input: 'test' })) // key alias
|
||||
@@ -82,7 +82,7 @@ describe('run UpdateKeystore when keystore does not exist on www', () => {
|
||||
});
|
||||
const manager = createManagerMock();
|
||||
|
||||
(prompts as any).mockImplementationOnce(() => ({ answer: false })); // Let expo handle
|
||||
(prompts as any).mockImplementationOnce(() => ({ value: true })); // Let expo handle
|
||||
|
||||
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('run UpdateKeystore when keystore does not exist on www', () => {
|
||||
|
||||
await fs.writeFile('/keystore.jks', testKeystore.keystore, 'base64');
|
||||
(prompts as any)
|
||||
.mockImplementationOnce(() => ({ answer: true })) // promptForCredentials: user specified
|
||||
.mockImplementationOnce(() => ({ value: false })) // promptForCredentials: user specified
|
||||
.mockImplementationOnce(() => ({ input: '/keystore.jks' })) // keystore
|
||||
.mockImplementationOnce(() => ({ input: 'test' })) // keystore password
|
||||
.mockImplementationOnce(() => ({ input: 'test' })) // key alias
|
||||
|
||||
@@ -20,9 +20,7 @@ export type AndroidCredentials = {
|
||||
export const keystoreSchema: CredentialSchema<Keystore> = {
|
||||
name: 'Android Keystore',
|
||||
provideMethodQuestion: {
|
||||
question: `Would you like to upload a Keystore or have us generate one for you?\nIf you don't know what this means, let us generate it! :)`,
|
||||
expoGenerated: 'Generate new keystore',
|
||||
userProvided: 'I want to upload my own file',
|
||||
question: `Generate a new Android Keystore?`,
|
||||
},
|
||||
questions: [
|
||||
{
|
||||
|
||||
@@ -13,10 +13,7 @@ export async function keytoolCommandExistsAsync(): Promise<boolean> {
|
||||
try {
|
||||
await spawnAsync('keytool');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../../log';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
@@ -41,6 +40,6 @@ export class CreateDistributionCertificate implements Action {
|
||||
this.accountName,
|
||||
distCert
|
||||
);
|
||||
log(chalk.green('Successfully created Distribution Certificate.'));
|
||||
log.succeed('Created distribution certificate');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../../log';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
@@ -29,7 +28,7 @@ export class CreateProvisioningProfile implements Action {
|
||||
const provisioningProfile = await this.provideOrGenerateAsync(ctx);
|
||||
await ctx.ios.updateProvisioningProfileAsync(this.app, provisioningProfile);
|
||||
|
||||
log(chalk.green('Successfully created Provisioning Profile.'));
|
||||
log.succeed('Created provisioning profile');
|
||||
}
|
||||
|
||||
private async provideOrGenerateAsync(ctx: Context): Promise<ProvisioningProfile> {
|
||||
|
||||
@@ -21,7 +21,7 @@ export class RemoveDistributionCertificate implements Action {
|
||||
await manager.runActionAsync(
|
||||
new RemoveSpecificDistributionCertificate(selected?.id, this.accountName, this.options)
|
||||
);
|
||||
log(chalk.green('Successfully removed Distribution Certificate.'));
|
||||
log.succeed('Removed distribution certificate');
|
||||
log.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../../log';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
@@ -32,16 +30,14 @@ export class RemoveSpecificProvisioningProfile implements Action {
|
||||
|
||||
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
await ctx.ios.deleteProvisioningProfileAsync(this.app);
|
||||
log(
|
||||
chalk.green(
|
||||
`Successfully removed Provisioning Profile for @${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`
|
||||
)
|
||||
log.succeed(
|
||||
`Removed provisioning profile for @${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`
|
||||
);
|
||||
|
||||
let { shouldRevoke } = this.options;
|
||||
if (!shouldRevoke && !ctx.nonInteractive) {
|
||||
shouldRevoke = await confirmAsync({
|
||||
message: 'Do you also want to revoke this Provisioning Profile on Apple Developer Portal?',
|
||||
message: 'Do you also want to revoke this provisioning profile on Apple Developer Portal?',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export class SetupBuildCredentials implements Action {
|
||||
await ctx.bestEffortAppStoreAuthenticateAsync();
|
||||
|
||||
if (ctx.appStore.authCtx) {
|
||||
await ctx.appStore.ensureAppExistsAsync(this.app, { enablePushNotifications: true });
|
||||
await ctx.appStore.ensureBundleIdExistsAsync(this.app, { enablePushNotifications: true });
|
||||
}
|
||||
|
||||
let iosAppBuildCredentials: IosAppBuildCredentials | null = null;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../../log';
|
||||
import { ensureAppIdentifierIsDefinedAsync } from '../../../project/projectUtils';
|
||||
@@ -15,10 +14,8 @@ export class UpdateCredentialsJson implements Action {
|
||||
const bundleIdentifer = await ensureAppIdentifierIsDefinedAsync(ctx.projectDir, Platform.iOS);
|
||||
log('Updating iOS credentials in credentials.json');
|
||||
await updateIosCredentialsAsync(ctx, bundleIdentifer);
|
||||
log(
|
||||
chalk.green(
|
||||
'iOS part of your local credentials.json is synced with values stored on Expo servers.'
|
||||
)
|
||||
log.succeed(
|
||||
'iOS part of your local credentials.json is synced with values stored on Expo servers.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,12 +72,12 @@ export class UpdateSpecificDistributionCertificate implements Action {
|
||||
this.accountName,
|
||||
newDistCert
|
||||
);
|
||||
log(chalk.green('Successfully updated Distribution Certificate'));
|
||||
log.succeed('Updated distribution certificate');
|
||||
log.newLine();
|
||||
|
||||
for (const appCredentials of apps) {
|
||||
log(
|
||||
`Removing Provisioning Profile for ${appCredentials.experienceName} (${appCredentials.bundleIdentifier})`
|
||||
`Removing provisioning profile for ${appCredentials.experienceName} (${appCredentials.bundleIdentifier})`
|
||||
);
|
||||
const appLookupParams = getAppLookupParams(
|
||||
appCredentials.experienceName,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../../log';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
import { Context } from '../../context';
|
||||
@@ -29,10 +27,8 @@ export class UseSpecificDistributionCertificate implements Action {
|
||||
|
||||
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
await ctx.ios.useDistributionCertificateAsync(this.app, this.userCredentialsId);
|
||||
log(
|
||||
chalk.green(
|
||||
`Successfully assigned Distribution Certificate to @${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`
|
||||
)
|
||||
log.succeed(
|
||||
`Assigned distribution certificate to @${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AppleDistributionCertificate } from '../../../../graphql/generated';
|
||||
import log from '../../../../log';
|
||||
@@ -31,6 +30,6 @@ export class CreateDistributionCertificate implements Action {
|
||||
this.app,
|
||||
distCert
|
||||
);
|
||||
log(chalk.green('Successfully created Distribution Certificate.'));
|
||||
log.succeed('Created distribution certificate');
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
listDistributionCertificatesAsync,
|
||||
revokeDistributionCertificateAsync,
|
||||
} from './distributionCertificate';
|
||||
import { AppLookupParams, EnsureAppExistsOptions, ensureAppExistsAsync } from './ensureAppExists';
|
||||
import {
|
||||
AppLookupParams,
|
||||
EnsureAppExistsOptions,
|
||||
ensureBundleIdExistsAsync,
|
||||
} from './ensureAppExists';
|
||||
import {
|
||||
ProfileClass,
|
||||
createProvisioningProfileAsync,
|
||||
@@ -48,12 +52,12 @@ class AppStoreApi {
|
||||
return this._authCtx;
|
||||
}
|
||||
|
||||
public async ensureAppExistsAsync(
|
||||
public async ensureBundleIdExistsAsync(
|
||||
app: AppLookupParams,
|
||||
options?: EnsureAppExistsOptions
|
||||
): Promise<void> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
return await ensureAppExistsAsync(ctx, app, options);
|
||||
return await ensureBundleIdExistsAsync(ctx, app, options);
|
||||
}
|
||||
|
||||
public async listDistributionCertificatesAsync(): Promise<DistributionCertificateStoreInfo[]> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BundleId, CapabilityType, CapabilityTypeOption } from '@expo/apple-utils';
|
||||
import { App, BundleId, CapabilityType, CapabilityTypeOption } from '@expo/apple-utils';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
import { AuthCtx, getRequestContext } from './authenticate';
|
||||
@@ -13,13 +14,28 @@ export interface AppLookupParams {
|
||||
bundleIdentifier: string;
|
||||
}
|
||||
|
||||
export async function ensureAppExistsAsync(
|
||||
export async function ensureBundleIdExistsAsync(
|
||||
authCtx: AuthCtx,
|
||||
{ accountName, projectName, bundleIdentifier }: AppLookupParams,
|
||||
options: EnsureAppExistsOptions = {}
|
||||
options?: EnsureAppExistsOptions
|
||||
) {
|
||||
return ensureBundleIdExistsWithNameAsync(
|
||||
authCtx,
|
||||
{
|
||||
name: `@${accountName}/${projectName}`,
|
||||
bundleIdentifier,
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureBundleIdExistsWithNameAsync(
|
||||
authCtx: AuthCtx,
|
||||
{ name, bundleIdentifier }: { name: string; bundleIdentifier: string },
|
||||
options?: EnsureAppExistsOptions
|
||||
) {
|
||||
const context = getRequestContext(authCtx);
|
||||
let spinner = ora(`Checking bundle identifier "${bundleIdentifier}"`).start();
|
||||
let spinner = ora(`Linking bundle identifier ${chalk.dim(bundleIdentifier)}`).start();
|
||||
|
||||
let bundleId: BundleId | null;
|
||||
try {
|
||||
@@ -27,38 +43,86 @@ export async function ensureAppExistsAsync(
|
||||
bundleId = await BundleId.findAsync(context, { identifier: bundleIdentifier });
|
||||
|
||||
if (!bundleId) {
|
||||
spinner.text = `Registering bundle identifier "${bundleIdentifier}"`;
|
||||
spinner.text = `Registering bundle identifier ${chalk.dim(bundleIdentifier)}`;
|
||||
// If it doesn't exist, create it
|
||||
bundleId = await BundleId.createAsync(context, {
|
||||
name: `@${accountName}/${projectName}`,
|
||||
name,
|
||||
identifier: bundleIdentifier,
|
||||
});
|
||||
}
|
||||
spinner.succeed(`Bundle identifier "${bundleIdentifier}" registered`);
|
||||
spinner.succeed(`Bundle identifier registered ${chalk.dim(bundleIdentifier)}`);
|
||||
} catch (err) {
|
||||
if (err.message.match(/An App ID with Identifier '(.*)' is not available/)) {
|
||||
spinner.fail(
|
||||
`The bundle identifier "${bundleIdentifier}" is not available to team "${authCtx.team.name}" (${authCtx.team.id}), please change it in your app config and try again.`
|
||||
`The bundle identifier ${chalk.bold(bundleIdentifier)} is not available to team "${
|
||||
authCtx.team.name
|
||||
}" (${authCtx.team.id}), please change it in your app config and try again.`
|
||||
);
|
||||
} else {
|
||||
spinner.fail('Failed to register bundle identifier');
|
||||
spinner.fail(`Failed to register bundle identifier ${chalk.dim(bundleIdentifier)}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
spinner = ora(`Syncing capabilities`).start();
|
||||
if (options) {
|
||||
try {
|
||||
spinner = ora(`Syncing capabilities`).start();
|
||||
|
||||
// Update the capabilities
|
||||
await bundleId.updateBundleIdCapabilityAsync({
|
||||
capabilityType: CapabilityType.PUSH_NOTIFICATIONS,
|
||||
option: options.enablePushNotifications ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF,
|
||||
// TODO: Add more capabilities
|
||||
});
|
||||
spinner.succeed(`Synced capabilities`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to sync capabilities for "${bundleIdentifier}"`);
|
||||
// Update the capabilities
|
||||
await bundleId.updateBundleIdCapabilityAsync({
|
||||
capabilityType: CapabilityType.PUSH_NOTIFICATIONS,
|
||||
option: options.enablePushNotifications
|
||||
? CapabilityTypeOption.ON
|
||||
: CapabilityTypeOption.OFF,
|
||||
// TODO: Add more capabilities
|
||||
});
|
||||
spinner.succeed(`Synced capabilities`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to sync capabilities ${chalk.dim(bundleIdentifier)}`);
|
||||
|
||||
throw err;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAppExistsAsync(
|
||||
authCtx: AuthCtx,
|
||||
{
|
||||
name,
|
||||
language,
|
||||
companyName,
|
||||
bundleIdentifier,
|
||||
}: { name: string; language?: string; companyName?: string; bundleIdentifier: string }
|
||||
) {
|
||||
const context = getRequestContext(authCtx);
|
||||
const spinner = ora(`Linking to App Store ${chalk.dim(bundleIdentifier)}`).start();
|
||||
|
||||
let app = await App.findAsync(context, { bundleId: bundleIdentifier });
|
||||
|
||||
if (!app) {
|
||||
spinner.text = `Creating App Store app ${chalk.bold(name)} ${chalk.dim(bundleIdentifier)}`;
|
||||
try {
|
||||
app = await App.createAsync(context, {
|
||||
bundleId: bundleIdentifier,
|
||||
name,
|
||||
primaryLocale: language,
|
||||
companyName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.message.match(/An App ID with Identifier '(.*)' is not available/)) {
|
||||
const providerName = authCtx.authState?.session.provider.name;
|
||||
throw new Error(
|
||||
`\nThe bundle identifier "${bundleIdentifier}" is not available to provider "${providerName}". Please change it in your app config and try again.\n`
|
||||
);
|
||||
}
|
||||
|
||||
spinner.fail(`Failed to create App Store app ${chalk.dim(name)}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// TODO: Update app name when API gives us that possibility
|
||||
}
|
||||
spinner.succeed(`Prepared App Store for ${chalk.bold(name)} ${chalk.dim(bundleIdentifier)}`);
|
||||
return app;
|
||||
}
|
||||
@@ -44,8 +44,7 @@ function getAppleIdFromEnvironmentOrOptions({
|
||||
}
|
||||
|
||||
async function promptUsernameAsync(): Promise<string> {
|
||||
const wrap = wordwrap(process.stdout.columns || 80);
|
||||
log(wrap('Log in to your Apple Developer account to continue'));
|
||||
log('\u203A Log in to your Apple Developer account to continue');
|
||||
|
||||
// Get the email address that was last used and set it as
|
||||
// the default value for quicker authentication.
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'path';
|
||||
import untildify from 'untildify';
|
||||
|
||||
import log from '../../log';
|
||||
import { Question as PromptQuestion, promptAsync } from '../../prompts';
|
||||
import { Question as PromptQuestion, confirmAsync, promptAsync } from '../../prompts';
|
||||
|
||||
export type Question = {
|
||||
field: string;
|
||||
@@ -18,8 +18,6 @@ export type CredentialSchema<T> = {
|
||||
questions: Question[];
|
||||
provideMethodQuestion?: {
|
||||
question?: string;
|
||||
expoGenerated?: string;
|
||||
userProvided?: string;
|
||||
};
|
||||
transformResultAsync?: (answers: Partial<T>) => Promise<T>;
|
||||
};
|
||||
@@ -28,7 +26,6 @@ const EXPERT_PROMPT = once(() =>
|
||||
log.warn(`
|
||||
In this mode, we won't be able to make sure that your credentials are valid.
|
||||
Please double check that you're uploading valid files for your app otherwise you may encounter strange errors!
|
||||
|
||||
When building for IOS make sure you've created your App ID on the Apple Developer Portal, that your App ID
|
||||
is in app.json as \`bundleIdentifier\`, and that the provisioning profile you
|
||||
upload matches that Team ID and App ID.
|
||||
@@ -39,11 +36,11 @@ export async function askForUserProvidedAsync<T>(
|
||||
schema: CredentialSchema<T>,
|
||||
initialValues: Partial<T> = {}
|
||||
): Promise<T | null> {
|
||||
if (await willUserProvideCredentialsAsync<T>(schema)) {
|
||||
EXPERT_PROMPT();
|
||||
return await getCredentialsFromUserAsync<T>(schema, initialValues);
|
||||
if (await shouldAutoGenerateCredentialsAsync<T>(schema)) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
EXPERT_PROMPT();
|
||||
return await getCredentialsFromUserAsync<T>(schema, initialValues);
|
||||
}
|
||||
|
||||
export async function getCredentialsFromUserAsync<T>(
|
||||
@@ -62,21 +59,10 @@ export async function getCredentialsFromUserAsync<T>(
|
||||
: (results as T);
|
||||
}
|
||||
|
||||
async function willUserProvideCredentialsAsync<T>(schema: CredentialSchema<T>) {
|
||||
const { answer } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'answer',
|
||||
message: schema?.provideMethodQuestion?.question ?? `Will you provide your own ${schema.name}?`,
|
||||
choices: [
|
||||
{
|
||||
title: schema?.provideMethodQuestion?.expoGenerated ?? 'Let EAS handle the process',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
title: schema?.provideMethodQuestion?.userProvided ?? 'I want to upload my own file',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
async function shouldAutoGenerateCredentialsAsync<T>(schema: CredentialSchema<T>) {
|
||||
const answer = await confirmAsync({
|
||||
message: schema?.provideMethodQuestion?.question ?? `Generate a new ${schema.name}?`,
|
||||
initial: true,
|
||||
});
|
||||
return answer;
|
||||
}
|
||||
|
||||
@@ -80,9 +80,7 @@ export class AccountResolver {
|
||||
)} account?`,
|
||||
});
|
||||
|
||||
if (useProjectAccount) {
|
||||
return projectAccount;
|
||||
}
|
||||
return useProjectAccount ? projectAccount : undefined;
|
||||
}
|
||||
|
||||
private async promptForAccountAsync(): Promise<Account> {
|
||||
|
||||
@@ -57,7 +57,7 @@ export type RootQuery = {
|
||||
builds: BuildQuery;
|
||||
clientBuilds: ClientBuildQuery;
|
||||
project: ProjectQuery;
|
||||
/** Search for users and apps */
|
||||
/** Search for Snacks */
|
||||
search: Array<SearchResult>;
|
||||
/** @deprecated Use 'search' root field. */
|
||||
searchUsersAndApps: Array<Maybe<SearchResult>>;
|
||||
@@ -156,6 +156,7 @@ export type Account = {
|
||||
/** @deprecated Build packs are no longer supported */
|
||||
availableBuilds?: Maybe<Scalars['Int']>;
|
||||
unlimitedBuilds?: Maybe<Scalars['Boolean']>;
|
||||
/** @deprecated No longer needed */
|
||||
subscriptionChangesPending?: Maybe<Scalars['Boolean']>;
|
||||
/** @deprecated Build packs are no longer supported */
|
||||
willAutoRenewBuilds?: Maybe<Scalars['Boolean']>;
|
||||
@@ -170,7 +171,7 @@ export type Account = {
|
||||
apps?: Maybe<Array<Maybe<App>>>;
|
||||
/** Build Jobs associated with this account */
|
||||
buildJobs?: Maybe<Array<Maybe<BuildJob>>>;
|
||||
/** (Turtle v2) Builds associated with this account */
|
||||
/** (EAS Build) Builds associated with this account */
|
||||
builds?: Maybe<Array<Maybe<Build>>>;
|
||||
/** Owning User of this account if personal account */
|
||||
owner?: Maybe<User>;
|
||||
@@ -236,6 +237,7 @@ export type AccountBuildsArgs = {
|
||||
offset: Scalars['Int'];
|
||||
limit: Scalars['Int'];
|
||||
status?: Maybe<BuildStatus>;
|
||||
platform?: Maybe<AppPlatform>;
|
||||
};
|
||||
|
||||
|
||||
@@ -372,6 +374,7 @@ export type App = Project & {
|
||||
/** @deprecated Field no longer supported */
|
||||
releases: Array<Maybe<AppRelease>>;
|
||||
builds?: Maybe<Array<Maybe<Build>>>;
|
||||
buildJobs: Array<Maybe<BuildJob>>;
|
||||
/** @deprecated Field no longer supported */
|
||||
users?: Maybe<Array<Maybe<User>>>;
|
||||
/** @deprecated Field no longer supported */
|
||||
@@ -427,6 +430,14 @@ export type AppBuildsArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppBuildJobsArgs = {
|
||||
offset: Scalars['Int'];
|
||||
limit: Scalars['Int'];
|
||||
status?: Maybe<BuildStatus>;
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppIosAppCredentialsArgs = {
|
||||
filter?: Maybe<IosAppCredentialsFilter>;
|
||||
@@ -676,7 +687,7 @@ export enum BuildStatus {
|
||||
Finished = 'FINISHED'
|
||||
}
|
||||
|
||||
/** Represents a Turtle v2 build */
|
||||
/** Represents an EAS Build */
|
||||
export type Build = ActivityTimelineProjectActivity & {
|
||||
__typename?: 'Build';
|
||||
id: Scalars['ID'];
|
||||
@@ -696,6 +707,7 @@ export type Build = ActivityTimelineProjectActivity & {
|
||||
appVersion?: Maybe<Scalars['String']>;
|
||||
sdkVersion?: Maybe<Scalars['String']>;
|
||||
releaseChannel?: Maybe<Scalars['String']>;
|
||||
metrics?: Maybe<BuildMetrics>;
|
||||
distribution?: Maybe<DistributionType>;
|
||||
};
|
||||
|
||||
@@ -711,11 +723,65 @@ export type BuildArtifacts = {
|
||||
xcodeBuildLogsUrl?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type BuildMetrics = {
|
||||
__typename?: 'BuildMetrics';
|
||||
buildQueueTime?: Maybe<Scalars['Int']>;
|
||||
buildDuration?: Maybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
export enum DistributionType {
|
||||
Store = 'STORE',
|
||||
Internal = 'INTERNAL'
|
||||
}
|
||||
|
||||
/** Represents an Standalone App build job */
|
||||
export type BuildJob = ActivityTimelineProjectActivity & {
|
||||
__typename?: 'BuildJob';
|
||||
id: Scalars['ID'];
|
||||
actor?: Maybe<Actor>;
|
||||
activityTimestamp: Scalars['DateTime'];
|
||||
app?: Maybe<App>;
|
||||
user?: Maybe<User>;
|
||||
release?: Maybe<AppRelease>;
|
||||
config?: Maybe<Scalars['JSON']>;
|
||||
artifacts?: Maybe<BuildArtifact>;
|
||||
logs?: Maybe<BuildLogs>;
|
||||
created?: Maybe<Scalars['DateTime']>;
|
||||
updated?: Maybe<Scalars['DateTime']>;
|
||||
fullExperienceName?: Maybe<Scalars['String']>;
|
||||
status?: Maybe<BuildJobStatus>;
|
||||
expirationDate?: Maybe<Scalars['DateTime']>;
|
||||
platform: AppPlatform;
|
||||
sdkVersion?: Maybe<Scalars['String']>;
|
||||
releaseChannel?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type BuildArtifact = {
|
||||
__typename?: 'BuildArtifact';
|
||||
url: Scalars['String'];
|
||||
manifestPlistUrl?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type BuildLogs = {
|
||||
__typename?: 'BuildLogs';
|
||||
url?: Maybe<Scalars['String']>;
|
||||
format?: Maybe<BuildJobLogsFormat>;
|
||||
};
|
||||
|
||||
export enum BuildJobLogsFormat {
|
||||
Raw = 'RAW',
|
||||
Json = 'JSON'
|
||||
}
|
||||
|
||||
export enum BuildJobStatus {
|
||||
Pending = 'PENDING',
|
||||
Started = 'STARTED',
|
||||
InProgress = 'IN_PROGRESS',
|
||||
Errored = 'ERRORED',
|
||||
Finished = 'FINISHED',
|
||||
SentToQueue = 'SENT_TO_QUEUE'
|
||||
}
|
||||
|
||||
export type IosAppCredentialsFilter = {
|
||||
appleAppIdentifierId?: Maybe<Scalars['String']>;
|
||||
};
|
||||
@@ -978,54 +1044,6 @@ export type Update = ActivityTimelineProjectActivity & {
|
||||
releaseName: Scalars['String'];
|
||||
};
|
||||
|
||||
export enum BuildJobStatus {
|
||||
Pending = 'PENDING',
|
||||
Started = 'STARTED',
|
||||
InProgress = 'IN_PROGRESS',
|
||||
Errored = 'ERRORED',
|
||||
Finished = 'FINISHED',
|
||||
SentToQueue = 'SENT_TO_QUEUE'
|
||||
}
|
||||
|
||||
/** Represents an Standalone App build job */
|
||||
export type BuildJob = ActivityTimelineProjectActivity & {
|
||||
__typename?: 'BuildJob';
|
||||
id: Scalars['ID'];
|
||||
actor?: Maybe<Actor>;
|
||||
activityTimestamp: Scalars['DateTime'];
|
||||
app?: Maybe<App>;
|
||||
user?: Maybe<User>;
|
||||
release?: Maybe<AppRelease>;
|
||||
config?: Maybe<Scalars['JSON']>;
|
||||
artifacts?: Maybe<BuildArtifact>;
|
||||
logs?: Maybe<BuildLogs>;
|
||||
created?: Maybe<Scalars['DateTime']>;
|
||||
updated?: Maybe<Scalars['DateTime']>;
|
||||
fullExperienceName?: Maybe<Scalars['String']>;
|
||||
status?: Maybe<BuildJobStatus>;
|
||||
expirationDate?: Maybe<Scalars['DateTime']>;
|
||||
platform: AppPlatform;
|
||||
sdkVersion?: Maybe<Scalars['String']>;
|
||||
releaseChannel?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type BuildArtifact = {
|
||||
__typename?: 'BuildArtifact';
|
||||
url: Scalars['String'];
|
||||
manifestPlistUrl?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type BuildLogs = {
|
||||
__typename?: 'BuildLogs';
|
||||
url?: Maybe<Scalars['String']>;
|
||||
format?: Maybe<BuildJobLogsFormat>;
|
||||
};
|
||||
|
||||
export enum BuildJobLogsFormat {
|
||||
Raw = 'RAW',
|
||||
Json = 'JSON'
|
||||
}
|
||||
|
||||
export type UserPermission = {
|
||||
__typename?: 'UserPermission';
|
||||
permissions?: Maybe<Array<Maybe<Permission>>>;
|
||||
@@ -1229,7 +1247,10 @@ export type ProjectPublicData = {
|
||||
|
||||
export type BuildJobQuery = {
|
||||
__typename?: 'BuildJobQuery';
|
||||
/** get all build jobs by an optional filter */
|
||||
/**
|
||||
* get all build jobs by an optional filter
|
||||
* @deprecated Prefer Account.buildJobs or App.buildJobs
|
||||
*/
|
||||
all: Array<Maybe<BuildJob>>;
|
||||
byId: BuildJob;
|
||||
};
|
||||
@@ -1251,7 +1272,7 @@ export type BuildJobQueryByIdArgs = {
|
||||
|
||||
export type BuildQuery = {
|
||||
__typename?: 'BuildQuery';
|
||||
/** Look up Turtle v2 build by build id */
|
||||
/** Look up EAS Build by build ID */
|
||||
byId: Build;
|
||||
/**
|
||||
* Get all builds for a specific app.
|
||||
@@ -2560,8 +2581,11 @@ export type AppSearchResult = SearchResult & {
|
||||
/** Represents a search result for a user */
|
||||
export type UserSearchResult = SearchResult & {
|
||||
__typename?: 'UserSearchResult';
|
||||
/** @deprecated Field no longer supported */
|
||||
id: Scalars['ID'];
|
||||
/** @deprecated Field no longer supported */
|
||||
rank?: Maybe<Scalars['Int']>;
|
||||
/** @deprecated Field no longer supported */
|
||||
user: User;
|
||||
};
|
||||
|
||||
@@ -2579,84 +2603,35 @@ export enum CacheControlScope {
|
||||
}
|
||||
|
||||
|
||||
export type AccountByNameQueryVariables = Exact<{
|
||||
accountName: Scalars['String'];
|
||||
export type GetSignedUploadMutationMutationVariables = Exact<{
|
||||
contentTypes: Array<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AccountByNameQuery = (
|
||||
{ __typename?: 'RootQuery' }
|
||||
& { account: (
|
||||
{ __typename?: 'AccountQuery' }
|
||||
& { byName: (
|
||||
{ __typename?: 'Account' }
|
||||
& Pick<Account, 'id'>
|
||||
) }
|
||||
) }
|
||||
);
|
||||
|
||||
export type AppleDevicesByAccountNameQueryVariables = Exact<{
|
||||
accountId: Scalars['ID'];
|
||||
identifier: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AppleDevicesByAccountNameQuery = (
|
||||
{ __typename?: 'RootQuery' }
|
||||
& { appleTeam: (
|
||||
{ __typename?: 'AppleTeamQuery' }
|
||||
& { byAppleTeamIdentifier?: Maybe<(
|
||||
{ __typename?: 'AppleTeam' }
|
||||
& Pick<AppleTeam, 'id' | 'appleTeamName'>
|
||||
& { appleDevices?: Maybe<Array<Maybe<(
|
||||
{ __typename?: 'AppleDevice' }
|
||||
& Pick<AppleDevice, 'id' | 'identifier' | 'name' | 'deviceClass' | 'enabled'>
|
||||
)>>> }
|
||||
export type GetSignedUploadMutationMutation = (
|
||||
{ __typename?: 'RootMutation' }
|
||||
& { asset: (
|
||||
{ __typename?: 'AssetMutation' }
|
||||
& { getSignedAssetUploadSpecifications?: Maybe<(
|
||||
{ __typename?: 'GetSignedAssetUploadSpecificationsResult' }
|
||||
& Pick<GetSignedAssetUploadSpecificationsResult, 'specifications'>
|
||||
)> }
|
||||
) }
|
||||
);
|
||||
|
||||
export type AppleDevicesByIdentifierQueryVariables = Exact<{
|
||||
accountName: Scalars['String'];
|
||||
identifier: Scalars['String'];
|
||||
export type PublishMutationMutationVariables = Exact<{
|
||||
publishUpdateGroupInput?: Maybe<PublishUpdateGroupInput>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppleDevicesByIdentifierQuery = (
|
||||
{ __typename?: 'RootQuery' }
|
||||
& { account: (
|
||||
{ __typename?: 'AccountQuery' }
|
||||
& { byName: (
|
||||
{ __typename?: 'Account' }
|
||||
& Pick<Account, 'id'>
|
||||
& { appleDevices?: Maybe<Array<Maybe<(
|
||||
{ __typename?: 'AppleDevice' }
|
||||
& Pick<AppleDevice, 'id' | 'identifier' | 'name' | 'deviceClass' | 'enabled'>
|
||||
& { appleTeam: (
|
||||
{ __typename?: 'AppleTeam' }
|
||||
& Pick<AppleTeam, 'id' | 'appleTeamIdentifier' | 'appleTeamName'>
|
||||
) }
|
||||
)>>> }
|
||||
) }
|
||||
) }
|
||||
);
|
||||
|
||||
export type AppleTeamsByAccountNameQueryVariables = Exact<{
|
||||
accountName: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AppleTeamsByAccountNameQuery = (
|
||||
{ __typename?: 'RootQuery' }
|
||||
& { account: (
|
||||
{ __typename?: 'AccountQuery' }
|
||||
& { byName: (
|
||||
{ __typename?: 'Account' }
|
||||
& { appleTeams: Array<Maybe<(
|
||||
{ __typename?: 'AppleTeam' }
|
||||
& Pick<AppleTeam, 'id' | 'appleTeamName' | 'appleTeamIdentifier'>
|
||||
)>> }
|
||||
) }
|
||||
export type PublishMutationMutation = (
|
||||
{ __typename?: 'RootMutation' }
|
||||
& { updateRelease: (
|
||||
{ __typename?: 'UpdateReleaseMutation' }
|
||||
& { publishUpdateGroup: Array<Maybe<(
|
||||
{ __typename?: 'Update' }
|
||||
& Pick<Update, 'updateGroup'>
|
||||
)>> }
|
||||
) }
|
||||
);
|
||||
|
||||
@@ -2704,6 +2679,29 @@ export type BuildsForAppQueryQuery = (
|
||||
) }
|
||||
);
|
||||
|
||||
export type PendingBuildsForAccountAndPlatformQueryVariables = Exact<{
|
||||
accountName: Scalars['String'];
|
||||
platform: AppPlatform;
|
||||
}>;
|
||||
|
||||
|
||||
export type PendingBuildsForAccountAndPlatformQuery = (
|
||||
{ __typename?: 'RootQuery' }
|
||||
& { account: (
|
||||
{ __typename?: 'AccountQuery' }
|
||||
& { byName: (
|
||||
{ __typename?: 'Account' }
|
||||
& { inQueueBuilds?: Maybe<Array<Maybe<(
|
||||
{ __typename?: 'Build' }
|
||||
& Pick<Build, 'id' | 'platform'>
|
||||
)>>>, inProgressBuilds?: Maybe<Array<Maybe<(
|
||||
{ __typename?: 'Build' }
|
||||
& Pick<Build, 'id' | 'platform'>
|
||||
)>>> }
|
||||
) }
|
||||
) }
|
||||
);
|
||||
|
||||
export type Unnamed_1_QueryVariables = Exact<{
|
||||
username: Scalars['String'];
|
||||
slug: Scalars['String'];
|
||||
@@ -2724,6 +2722,22 @@ export type Unnamed_1_Query = (
|
||||
) }
|
||||
);
|
||||
|
||||
export type GetAssetMetadataQueryQueryVariables = Exact<{
|
||||
storageKeys: Array<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetAssetMetadataQueryQuery = (
|
||||
{ __typename?: 'RootQuery' }
|
||||
& { asset: (
|
||||
{ __typename?: 'AssetQuery' }
|
||||
& { metadata: Array<(
|
||||
{ __typename?: 'AssetMetadataResult' }
|
||||
& Pick<AssetMetadataResult, 'storageKey' | 'status'>
|
||||
)> }
|
||||
) }
|
||||
);
|
||||
|
||||
export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -2731,14 +2745,14 @@ export type CurrentUserQuery = (
|
||||
{ __typename?: 'RootQuery' }
|
||||
& { meActor?: Maybe<(
|
||||
{ __typename: 'User' }
|
||||
& Pick<User, 'username' | 'id'>
|
||||
& Pick<User, 'username' | 'id' | 'isExpoAdmin'>
|
||||
& { accounts?: Maybe<Array<Maybe<(
|
||||
{ __typename?: 'Account' }
|
||||
& Pick<Account, 'id' | 'name'>
|
||||
)>>> }
|
||||
) | (
|
||||
{ __typename: 'Robot' }
|
||||
& Pick<Robot, 'firstName' | 'id'>
|
||||
& Pick<Robot, 'firstName' | 'id' | 'isExpoAdmin'>
|
||||
& { accounts?: Maybe<Array<Maybe<(
|
||||
{ __typename?: 'Account' }
|
||||
& Pick<Account, 'id' | 'name'>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../client';
|
||||
import { PublishUpdateGroupInput, Update } from '../generated';
|
||||
|
||||
const PublishMutation = {
|
||||
async getUploadURLsAsync(contentTypes: string[]): Promise<{ specifications: string[] }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ asset: { getSignedAssetUploadSpecifications: { specifications: string[] } } },
|
||||
{ contentTypes: string[] }
|
||||
>(
|
||||
gql`
|
||||
mutation GetSignedUploadMutation($contentTypes: [String!]!) {
|
||||
asset {
|
||||
getSignedAssetUploadSpecifications(assetContentTypes: $contentTypes) {
|
||||
specifications
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
contentTypes,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.asset.getSignedAssetUploadSpecifications;
|
||||
},
|
||||
|
||||
async publishUpdateGroupAsync(
|
||||
publishUpdateGroupInput: PublishUpdateGroupInput
|
||||
): Promise<Pick<Update, 'updateGroup'>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ updateRelease: { publishUpdateGroup: Pick<Update, 'updateGroup'>[] } },
|
||||
{ publishUpdateGroupInput: PublishUpdateGroupInput }
|
||||
>(
|
||||
gql`
|
||||
mutation PublishMutation($publishUpdateGroupInput: PublishUpdateGroupInput) {
|
||||
updateRelease {
|
||||
publishUpdateGroup(publishUpdateGroupInput: $publishUpdateGroupInput) {
|
||||
updateGroup
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ publishUpdateGroupInput }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateRelease.publishUpdateGroup[0];
|
||||
},
|
||||
};
|
||||
|
||||
export { PublishMutation };
|
||||
@@ -1,7 +1,7 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../client';
|
||||
import { Build } from '../generated';
|
||||
import { AppPlatform, Build } from '../generated';
|
||||
|
||||
type Filters = Partial<Pick<Build, 'platform' | 'status'>> & {
|
||||
offset?: number;
|
||||
@@ -9,6 +9,7 @@ type Filters = Partial<Pick<Build, 'platform' | 'status'>> & {
|
||||
};
|
||||
|
||||
type BuildQueryResult = Pick<Build, 'platform' | 'artifacts'>;
|
||||
type PendingBuildQueryResult = Pick<Build, 'id' | 'platform'>;
|
||||
|
||||
const BuildQuery = {
|
||||
async byIdAsync(buildId: string): Promise<BuildQueryResult> {
|
||||
@@ -70,6 +71,60 @@ const BuildQuery = {
|
||||
|
||||
return data.builds.allForApp;
|
||||
},
|
||||
|
||||
async getPendingBuildIdAsync(
|
||||
accountName: string,
|
||||
platform: AppPlatform
|
||||
): Promise<PendingBuildQueryResult | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<{
|
||||
account: {
|
||||
byName: {
|
||||
inQueueBuilds: PendingBuildQueryResult[];
|
||||
inProgressBuilds: PendingBuildQueryResult[];
|
||||
};
|
||||
};
|
||||
}>(
|
||||
gql`
|
||||
query PendingBuildsForAccountAndPlatform(
|
||||
$accountName: String!
|
||||
$platform: AppPlatform!
|
||||
) {
|
||||
account {
|
||||
byName(accountName: $accountName) {
|
||||
inQueueBuilds: builds(
|
||||
offset: 0
|
||||
limit: 1
|
||||
platform: $platform
|
||||
status: IN_QUEUE
|
||||
) {
|
||||
id
|
||||
platform
|
||||
}
|
||||
inProgressBuilds: builds(
|
||||
offset: 0
|
||||
limit: 1
|
||||
platform: $platform
|
||||
status: IN_PROGRESS
|
||||
) {
|
||||
id
|
||||
platform
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ accountName, platform }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const pendingBuilds = [
|
||||
...data.account.byName.inProgressBuilds,
|
||||
...data.account.byName.inQueueBuilds,
|
||||
];
|
||||
return pendingBuilds.length > 0 ? pendingBuilds[0] : null;
|
||||
},
|
||||
};
|
||||
|
||||
export { BuildQuery };
|
||||
@@ -0,0 +1,32 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../client';
|
||||
import { AssetMetadataResult } from '../generated';
|
||||
|
||||
const PublishQuery = {
|
||||
async getAssetMetadataAsync(storageKeys: string[]): Promise<AssetMetadataResult[]> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<{ asset: { metadata: AssetMetadataResult[] } }, { storageKeys: string[] }>(
|
||||
gql`
|
||||
query GetAssetMetadataQuery($storageKeys: [String!]!) {
|
||||
asset {
|
||||
metadata(storageKeys: $storageKeys) {
|
||||
storageKey
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
storageKeys,
|
||||
},
|
||||
{ requestPolicy: 'network-only' } // Since we reptitively query this to monitor the asset upload, we need to ensure it is not cached.
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.asset.metadata;
|
||||
},
|
||||
};
|
||||
|
||||
export { PublishQuery };
|
||||
@@ -23,6 +23,7 @@ const UserQuery = {
|
||||
id
|
||||
name
|
||||
}
|
||||
isExpoAdmin
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import chalk from 'chalk';
|
||||
import figures from 'figures';
|
||||
import { boolish } from 'getenv';
|
||||
import ora from 'ora';
|
||||
|
||||
type Color = (...text: string[]) => string;
|
||||
|
||||
@@ -68,6 +69,10 @@ log.gray = function (...args: any[]) {
|
||||
consoleLog(...withTextColor(args, chalk.gray));
|
||||
};
|
||||
|
||||
log.succeed = function (message: string) {
|
||||
ora().succeed(message);
|
||||
};
|
||||
|
||||
log.withTick = function (...args: any[]) {
|
||||
consoleLog(chalk.green(figures.tick), ...args);
|
||||
};
|
||||
|
||||
@@ -138,7 +138,7 @@ describe(getProjectAccountNameAsync, () => {
|
||||
}));
|
||||
asMock(getUserAsync).mockImplementation((): Actor | undefined => undefined);
|
||||
|
||||
expect(getProjectAccountNameAsync('/app')).rejects.toThrow(/logged in/);
|
||||
await expect(getProjectAccountNameAsync('/app')).rejects.toThrow(/Failed to access user data/);
|
||||
});
|
||||
|
||||
it(`throws when project owner is undefined for robot actors`, async () => {
|
||||
@@ -154,6 +154,8 @@ describe(getProjectAccountNameAsync, () => {
|
||||
{ id: 'account_id_2', name: 'dominik' },
|
||||
],
|
||||
}));
|
||||
expect(getProjectAccountNameAsync('/app')).rejects.toThrow('manifest property is required');
|
||||
await expect(getProjectAccountNameAsync('/app')).rejects.toThrow(
|
||||
'manifest property is required'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import fs from 'fs';
|
||||
import mockdate from 'mockdate';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { AssetMetadataStatus } from '../../graphql/generated';
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
import { PublishQuery } from '../../graphql/queries/PublishQuery';
|
||||
import {
|
||||
Platforms,
|
||||
TIMEOUT_LIMIT,
|
||||
buildUpdateInfoGroupAsync,
|
||||
collectAssets,
|
||||
convertAssetToUpdateInfoGroupFormatAsync,
|
||||
filterOutAssetsThatAlreadyExistAsync,
|
||||
getBase64URLEncoding,
|
||||
getStorageKey,
|
||||
getStorageKeyForAssetAsync,
|
||||
guessContentTypeFromExtension,
|
||||
resolveInputDirectory,
|
||||
uploadAssetsAsync,
|
||||
} from '../publish';
|
||||
|
||||
jest.mock('../../uploads');
|
||||
jest.mock('fs');
|
||||
|
||||
const dummyFileBuffer = Buffer.from('dummy-file');
|
||||
fs.mkdirSync(path.resolve(), { recursive: true });
|
||||
fs.writeFileSync(path.resolve('md5-hash-of-file'), dummyFileBuffer);
|
||||
|
||||
describe(guessContentTypeFromExtension, () => {
|
||||
it('returns the correct content type for jpg', () => {
|
||||
expect(guessContentTypeFromExtension('jpg')).toBe('image/jpeg');
|
||||
});
|
||||
it('returns application/octet-stream when an ext is not recognized', () => {
|
||||
expect(guessContentTypeFromExtension('does-not-exist')).toBe('application/octet-stream');
|
||||
});
|
||||
it('returns application/octet-stream when an ext is undefined', () => {
|
||||
expect(guessContentTypeFromExtension(undefined)).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe(getBase64URLEncoding, () => {
|
||||
it('computes the correct encoding', () => {
|
||||
expect(getBase64URLEncoding(Buffer.from('test-string'))).toBe('dGVzdC1zdHJpbmc');
|
||||
});
|
||||
});
|
||||
|
||||
describe(getStorageKey, () => {
|
||||
it('computes the correct key', () => {
|
||||
const key = getStorageKey('image/jpeg', 'blibblab');
|
||||
expect(key).toBe('j0iiW9hDbR2HKoH1nCxsKRM6QIZVtZ__2ssOiOcxlAs');
|
||||
});
|
||||
it('uses the null separator to distinguish unequal keys', () => {
|
||||
const keyOne = getStorageKey('image/jpeg', 'blibblab');
|
||||
const keyTwo = getStorageKey('image', '/jpegblibblab');
|
||||
expect(keyOne).not.toBe(keyTwo);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getStorageKeyForAssetAsync, () => {
|
||||
const pathLocation = uuidv4();
|
||||
fs.writeFileSync(pathLocation, Buffer.from('I am pretending to be a jpeg'));
|
||||
it('returns the correct key', async () => {
|
||||
const asset = {
|
||||
type: 'jpg',
|
||||
contentType: 'image/jpeg',
|
||||
path: pathLocation,
|
||||
};
|
||||
expect(await getStorageKeyForAssetAsync(asset)).toBe(
|
||||
'fo8Y08LktVk6qLtGbn8GRWpOUyD13ABMUnbtRCN1L7Y'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(convertAssetToUpdateInfoGroupFormatAsync, () => {
|
||||
it('resolves to the correct value', async () => {
|
||||
const path = uuidv4();
|
||||
fs.writeFileSync(path, 'I am pretending to be a jpeg');
|
||||
const type = 'jpg';
|
||||
const asset = {
|
||||
type,
|
||||
contentType: 'image/jpeg',
|
||||
path,
|
||||
};
|
||||
await expect(convertAssetToUpdateInfoGroupFormatAsync(asset)).resolves.toEqual({
|
||||
bundleKey: `c939e759656f577c058f445bfb19182e.${type}`,
|
||||
contentType: 'image/jpeg',
|
||||
fileHash: 'tzD6J-OQZaHCKnL3GHWV9RbnrpyojnagiOE7r3mSkU4',
|
||||
storageBucket: 'update-assets-production',
|
||||
storageKey: 'fo8Y08LktVk6qLtGbn8GRWpOUyD13ABMUnbtRCN1L7Y',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildUpdateInfoGroupAsync, () => {
|
||||
const androidBundlePath = uuidv4();
|
||||
const assetPath = uuidv4();
|
||||
fs.writeFileSync(androidBundlePath, 'I am a js bundle');
|
||||
fs.writeFileSync(assetPath, 'I am pretending to be a jpeg');
|
||||
|
||||
it('returns the correct value', async () => {
|
||||
await expect(
|
||||
buildUpdateInfoGroupAsync({
|
||||
android: {
|
||||
launchAsset: {
|
||||
type: 'bundle',
|
||||
contentType: 'bundle/javascript',
|
||||
path: androidBundlePath,
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
type: 'jpg',
|
||||
contentType: 'image/jpeg',
|
||||
path: assetPath,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
).resolves.toEqual({
|
||||
android: {
|
||||
assets: [
|
||||
{
|
||||
bundleKey: 'c939e759656f577c058f445bfb19182e.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
fileHash: 'tzD6J-OQZaHCKnL3GHWV9RbnrpyojnagiOE7r3mSkU4',
|
||||
storageBucket: 'update-assets-production',
|
||||
storageKey: 'fo8Y08LktVk6qLtGbn8GRWpOUyD13ABMUnbtRCN1L7Y',
|
||||
},
|
||||
],
|
||||
|
||||
launchAsset: {
|
||||
bundleKey: 'ec0dd14670aae108f99a810df9c1482c.bundle',
|
||||
contentType: 'bundle/javascript',
|
||||
fileHash: 'KEw79FnKTLOyVbRT1SlohSTjPe5e8FpULy2ST-I5BUg',
|
||||
storageBucket: 'update-assets-production',
|
||||
storageKey: 'aC9N6RZlcHoIYjIsoJd2KUcigBKy98RHvZacDyPNjCQ',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveInputDirectory, () => {
|
||||
it('returns the correct distRoot path', () => {
|
||||
const customDirectoryName = path.resolve(uuidv4());
|
||||
fs.mkdirSync(customDirectoryName, { recursive: true });
|
||||
expect(resolveInputDirectory(customDirectoryName)).toBe(customDirectoryName);
|
||||
});
|
||||
it('throws an error if the path does not exist', () => {
|
||||
const nonExistentPath = path.resolve(uuidv4());
|
||||
expect(() => {
|
||||
resolveInputDirectory(nonExistentPath);
|
||||
}).toThrow(`${nonExistentPath} does not exist. Please create it with your desired bundler.`);
|
||||
});
|
||||
});
|
||||
|
||||
describe(collectAssets, () => {
|
||||
it('builds an update info group', () => {
|
||||
const fakeHash = 'md5-hash-of-jpg';
|
||||
const fakeJson = { bundledAssets: [`asset_${fakeHash}.jpg`] };
|
||||
const bundles = { android: 'android-bundle-code', ios: 'ios-bundle-code' };
|
||||
const inputDir = uuidv4();
|
||||
|
||||
const userDefinedAssets = [
|
||||
{
|
||||
type: 'jpg',
|
||||
contentType: 'image/jpeg',
|
||||
path: path.resolve(`${inputDir}/assets/${fakeHash}`),
|
||||
},
|
||||
];
|
||||
|
||||
const bundleDir = path.resolve(`${inputDir}/bundles`);
|
||||
const assetDir = path.resolve(`${inputDir}/assets`);
|
||||
fs.mkdirSync(bundleDir, { recursive: true });
|
||||
fs.mkdirSync(assetDir, { recursive: true });
|
||||
Platforms.forEach(platform => {
|
||||
fs.writeFileSync(
|
||||
path.resolve(`${inputDir}/${platform}-index.json`),
|
||||
JSON.stringify(fakeJson)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.resolve(`${inputDir}/bundles/${platform}-randomHash.js`),
|
||||
bundles[platform]
|
||||
);
|
||||
});
|
||||
fs.writeFileSync(path.resolve(`${inputDir}/assets/${fakeHash}`), dummyFileBuffer);
|
||||
|
||||
expect(collectAssets(inputDir)).toEqual({
|
||||
android: {
|
||||
launchAsset: {
|
||||
type: 'bundle',
|
||||
contentType: 'application/javascript',
|
||||
path: path.resolve(`${inputDir}/bundles/android-randomHash.js`),
|
||||
},
|
||||
assets: userDefinedAssets,
|
||||
},
|
||||
ios: {
|
||||
launchAsset: {
|
||||
type: 'bundle',
|
||||
contentType: 'application/javascript',
|
||||
path: path.resolve(`${inputDir}/bundles/ios-randomHash.js`),
|
||||
},
|
||||
assets: userDefinedAssets,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(filterOutAssetsThatAlreadyExistAsync, () => {
|
||||
it('gets a missing asset', async () => {
|
||||
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
|
||||
return [
|
||||
{
|
||||
storageKey: 'blah',
|
||||
status: AssetMetadataStatus.DoesNotExist,
|
||||
__typename: 'AssetMetadataResult',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
expect(
|
||||
await (await filterOutAssetsThatAlreadyExistAsync([{ storageKey: 'blah' } as any])).length
|
||||
).toBe(1);
|
||||
});
|
||||
it('ignores an asset that exists', async () => {
|
||||
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
|
||||
return [
|
||||
{
|
||||
storageKey: 'blah',
|
||||
status: AssetMetadataStatus.Exists,
|
||||
__typename: 'AssetMetadataResult',
|
||||
},
|
||||
];
|
||||
});
|
||||
expect(
|
||||
await (await filterOutAssetsThatAlreadyExistAsync([{ storageKey: 'blah' } as any])).length
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadAssetsAsync, () => {
|
||||
const publishBundles = {
|
||||
android: {
|
||||
code: 'android bundle code',
|
||||
assets: [{ files: ['md5-hash-of-file'], type: 'jpg' }],
|
||||
map: 'dummy-string',
|
||||
},
|
||||
ios: {
|
||||
code: 'ios bundle code',
|
||||
assets: [{ files: ['md5-hash-of-file'], type: 'jpg' }],
|
||||
map: 'dummy-string',
|
||||
},
|
||||
};
|
||||
|
||||
const androidBundlePath = uuidv4();
|
||||
const iosBundlePath = uuidv4();
|
||||
const dummyFilePath = uuidv4();
|
||||
const userDefinedPath = uuidv4();
|
||||
fs.writeFileSync(androidBundlePath, publishBundles.android.code);
|
||||
fs.writeFileSync(iosBundlePath, publishBundles.ios.code);
|
||||
fs.writeFileSync(dummyFilePath, dummyFileBuffer);
|
||||
fs.writeFileSync(userDefinedPath, 'I am an octet stream');
|
||||
|
||||
const userDefinedAsset = {
|
||||
type: 'bundle',
|
||||
contentType: 'application/octet-stream',
|
||||
path: userDefinedPath,
|
||||
};
|
||||
|
||||
const assetsForUpdateInfoGroup = {
|
||||
android: {
|
||||
launchAsset: {
|
||||
type: 'bundle',
|
||||
contentType: 'application/javascript',
|
||||
path: androidBundlePath,
|
||||
},
|
||||
assets: [userDefinedAsset, { type: 'jpg', contentType: 'image/jpeg', path: dummyFilePath }],
|
||||
},
|
||||
ios: {
|
||||
launchAsset: {
|
||||
type: 'bundle',
|
||||
contentType: 'application/javascript',
|
||||
path: androidBundlePath,
|
||||
},
|
||||
assets: [userDefinedAsset, { type: 'jpg', contentType: 'image/jpeg', path: dummyFilePath }],
|
||||
},
|
||||
};
|
||||
jest.spyOn(PublishMutation, 'getUploadURLsAsync').mockImplementation(async () => {
|
||||
return { specifications: ['{}', '{}', '{}'] };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
it('throws an error if the upload exceeds TIMEOUT_LIMIT', async () => {
|
||||
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
|
||||
const status = AssetMetadataStatus.DoesNotExist;
|
||||
mockdate.set(Date.now() + TIMEOUT_LIMIT + 1);
|
||||
jest.runAllTimers();
|
||||
return [
|
||||
{
|
||||
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
|
||||
status,
|
||||
__typename: 'AssetMetadataResult',
|
||||
}, // userDefinedAsset
|
||||
{
|
||||
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
|
||||
status,
|
||||
__typename: 'AssetMetadataResult',
|
||||
}, // android.code
|
||||
{
|
||||
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
|
||||
status,
|
||||
__typename: 'AssetMetadataResult',
|
||||
}, // ios.code
|
||||
];
|
||||
});
|
||||
|
||||
mockdate.set(0);
|
||||
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).rejects.toThrow(
|
||||
'Asset upload timed out. Please try again.'
|
||||
);
|
||||
});
|
||||
it('resolves if the assets are already uploaded', async () => {
|
||||
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
|
||||
const status = AssetMetadataStatus.Exists;
|
||||
jest.runAllTimers();
|
||||
return [
|
||||
{
|
||||
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
|
||||
status,
|
||||
__typename: 'AssetMetadataResult',
|
||||
}, // userDefinedAsset
|
||||
{
|
||||
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
|
||||
status,
|
||||
__typename: 'AssetMetadataResult',
|
||||
}, // android.code
|
||||
{
|
||||
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
|
||||
status,
|
||||
__typename: 'AssetMetadataResult',
|
||||
}, // ios.code
|
||||
];
|
||||
});
|
||||
|
||||
mockdate.set(0);
|
||||
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).resolves.toBe(undefined);
|
||||
});
|
||||
it('resolves if the assets are eventually uploaded', async () => {
|
||||
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
|
||||
const status =
|
||||
Date.now() === 0 ? AssetMetadataStatus.DoesNotExist : AssetMetadataStatus.Exists;
|
||||
mockdate.set(Date.now() + 1);
|
||||
jest.runAllTimers();
|
||||
return [
|
||||
{
|
||||
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
|
||||
status,
|
||||
__typename: 'AssetMetadataResult',
|
||||
}, // userDefinedAsset
|
||||
{
|
||||
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
|
||||
status,
|
||||
__typename: 'AssetMetadataResult',
|
||||
}, // android.code
|
||||
{
|
||||
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
|
||||
status,
|
||||
__typename: 'AssetMetadataResult',
|
||||
}, // ios.code
|
||||
];
|
||||
});
|
||||
|
||||
mockdate.set(0);
|
||||
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).resolves.toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,11 @@ export async function ensureProjectExistsAsync(projectInfo: ProjectInfo): Promis
|
||||
const { accountName, projectName } = projectInfo;
|
||||
const projectFullName = `@${accountName}/${projectName}`;
|
||||
|
||||
const spinner = ora(`Ensuring ${chalk.bold(projectFullName)} is created on Expo`).start();
|
||||
const spinner = ora(`Linking to project ${chalk.bold(projectFullName)}`).start();
|
||||
|
||||
try {
|
||||
const id = await findProjectIdByAccountNameAndSlugAsync(accountName, projectName);
|
||||
spinner.succeed();
|
||||
spinner.succeed(`Linked to project ${chalk.bold(projectFullName)}`);
|
||||
return id;
|
||||
} catch (err) {
|
||||
if (err.graphQLErrors?.some((it: any) => it.extensions?.errorCode !== 'EXPERIENCE_NOT_FOUND')) {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Platform } from '@expo/config';
|
||||
import JsonFile from '@expo/json-file';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import { uniqBy } from 'lodash';
|
||||
import mime from 'mime';
|
||||
import path from 'path';
|
||||
|
||||
import { AssetMetadataStatus, PartialManifestAsset } from '../graphql/generated';
|
||||
import { PublishMutation } from '../graphql/mutations/PublishMutation';
|
||||
import { PublishQuery } from '../graphql/queries/PublishQuery';
|
||||
import { PresignedPost, uploadWithPresignedPostAsync } from '../uploads';
|
||||
|
||||
export const TIMEOUT_LIMIT = 60_000; // 1 minute
|
||||
const STORAGE_BUCKET = getStorageBucket();
|
||||
export const Platforms: PublishPlatform[] = ['android', 'ios']; // TODO-JJ allow users to specify this in app.js
|
||||
|
||||
function getStorageBucket(): string {
|
||||
if (process.env.EXPO_STAGING || process.env.EXPO_LOCAL) {
|
||||
return 'update-assets-staging';
|
||||
} else {
|
||||
return 'update-assets-production';
|
||||
}
|
||||
}
|
||||
|
||||
export type PublishPlatform = Extract<'android' | 'ios', Platform>;
|
||||
export type RawAsset = {
|
||||
type: string;
|
||||
contentType: string;
|
||||
path: string;
|
||||
};
|
||||
type CollectedAssets = {
|
||||
[platform in PublishPlatform]?: {
|
||||
launchAsset: RawAsset;
|
||||
assets: RawAsset[];
|
||||
};
|
||||
};
|
||||
|
||||
type ManifestFragment = {
|
||||
launchAsset: PartialManifestAsset;
|
||||
assets: PartialManifestAsset[];
|
||||
};
|
||||
type UpdateInfoGroup = {
|
||||
[key in PublishPlatform]: ManifestFragment;
|
||||
};
|
||||
|
||||
export function guessContentTypeFromExtension(ext?: string): string {
|
||||
return mime.getType(ext ?? '') ?? 'application/octet-stream'; // unrecognized extension
|
||||
}
|
||||
|
||||
export function getBase64URLEncoding(buffer: Buffer): string {
|
||||
const base64 = buffer.toString('base64');
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The storage key is used to store the asset in GCS
|
||||
*/
|
||||
export function getStorageKey(contentType: string, contentHash: string): string {
|
||||
const nullSeparator = Buffer.alloc(1);
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(contentType)
|
||||
.update(nullSeparator)
|
||||
.update(contentHash)
|
||||
.digest();
|
||||
return getBase64URLEncoding(hash);
|
||||
}
|
||||
|
||||
async function calculateFileHashAsync(filePath: string, algorithm: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createReadStream(filePath).on('error', reject);
|
||||
const hash = file.pipe(crypto.createHash(algorithm)).on('error', reject);
|
||||
hash.on('finish', () => resolve(hash.read()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function that computes an assets storage key starting from its buffer.
|
||||
*/
|
||||
export async function getStorageKeyForAssetAsync(asset: RawAsset): Promise<string> {
|
||||
const fileHash = getBase64URLEncoding(await calculateFileHashAsync(asset.path, 'sha256'));
|
||||
return getStorageKey(asset.contentType, fileHash);
|
||||
}
|
||||
|
||||
export async function convertAssetToUpdateInfoGroupFormatAsync(
|
||||
asset: RawAsset
|
||||
): Promise<PartialManifestAsset> {
|
||||
const fileHash = getBase64URLEncoding(await calculateFileHashAsync(asset.path, 'sha256'));
|
||||
const contentType = asset.contentType;
|
||||
const storageKey = getStorageKey(contentType, fileHash);
|
||||
const bundleKey = [
|
||||
(await calculateFileHashAsync(asset.path, 'md5')).toString('hex'),
|
||||
asset.type,
|
||||
].join('.');
|
||||
|
||||
return {
|
||||
fileHash,
|
||||
contentType,
|
||||
storageBucket: STORAGE_BUCKET,
|
||||
storageKey,
|
||||
bundleKey,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildUpdateInfoGroupAsync(assets: CollectedAssets): Promise<UpdateInfoGroup> {
|
||||
let platform: PublishPlatform;
|
||||
const updateInfoGroup: Partial<UpdateInfoGroup> = {};
|
||||
for (platform in assets) {
|
||||
updateInfoGroup[platform] = {
|
||||
launchAsset: await convertAssetToUpdateInfoGroupFormatAsync(assets[platform]?.launchAsset!),
|
||||
assets: await Promise.all(
|
||||
(assets[platform]?.assets ?? []).map(convertAssetToUpdateInfoGroupFormatAsync)
|
||||
),
|
||||
};
|
||||
}
|
||||
return updateInfoGroup as UpdateInfoGroup;
|
||||
}
|
||||
|
||||
export function resolveInputDirectory(customInputDirectory: string): string {
|
||||
const distRoot = path.resolve(customInputDirectory);
|
||||
if (!fs.existsSync(distRoot)) {
|
||||
throw new Error(`${distRoot} does not exist. Please create it with your desired bundler.`);
|
||||
}
|
||||
return distRoot;
|
||||
}
|
||||
|
||||
export function collectUserDefinedAssets(distRoot: string): RawAsset[] {
|
||||
const assetRoot = path.join(distRoot, 'assets');
|
||||
const assetPointers = Platforms.map(platform => {
|
||||
const assetJsonPath = path.join(distRoot, `${platform}-index.json`);
|
||||
return JsonFile.read(assetJsonPath).bundledAssets;
|
||||
}).flat();
|
||||
|
||||
return [...new Set(assetPointers)].map(pointer => {
|
||||
const [filename, ext] = new String(pointer ?? '').split('_').pop()?.split('.') ?? [];
|
||||
if (!filename) {
|
||||
throw new Error(
|
||||
'There was an error locating all of the bundler defined assets. Please rerun the bundler and then retry publishing.'
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: ext ?? '',
|
||||
contentType: guessContentTypeFromExtension(ext),
|
||||
path: path.join(assetRoot, filename),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function collectBundles(distRoot: string): { [key: string]: RawAsset } {
|
||||
const bundleRoot = path.join(distRoot, 'bundles');
|
||||
const bundlePaths = Object.fromEntries(
|
||||
fs.readdirSync(bundleRoot).map(name => [name.split('-')[0], path.join(bundleRoot, name)])
|
||||
);
|
||||
|
||||
const bundleBuffers: { [key: string]: RawAsset } = {};
|
||||
Platforms.forEach(platform => {
|
||||
bundleBuffers[platform] = {
|
||||
type: 'bundle',
|
||||
contentType: 'application/javascript',
|
||||
path: bundlePaths[platform],
|
||||
};
|
||||
});
|
||||
return bundleBuffers;
|
||||
}
|
||||
|
||||
export function collectAssets(inputDir: string): CollectedAssets {
|
||||
const distRoot = resolveInputDirectory(inputDir);
|
||||
const assets = collectUserDefinedAssets(distRoot);
|
||||
const bundles = collectBundles(distRoot);
|
||||
const assetsFinal: CollectedAssets = {};
|
||||
|
||||
for (const platform of Platforms) {
|
||||
assetsFinal[platform] = {
|
||||
launchAsset: bundles[platform],
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
return assetsFinal;
|
||||
}
|
||||
|
||||
export async function filterOutAssetsThatAlreadyExistAsync(
|
||||
uniqueAssetsWithStorageKey: (RawAsset & { storageKey: string })[]
|
||||
): Promise<(RawAsset & { storageKey: string })[]> {
|
||||
const assetMetadata = await PublishQuery.getAssetMetadataAsync(
|
||||
uniqueAssetsWithStorageKey.map(asset => asset.storageKey)
|
||||
);
|
||||
const missingAssetKeys = assetMetadata
|
||||
.filter(result => result.status !== AssetMetadataStatus.Exists)
|
||||
.map(result => result.storageKey);
|
||||
|
||||
const missingAssets = uniqueAssetsWithStorageKey.filter(asset => {
|
||||
return missingAssetKeys.includes(asset.storageKey);
|
||||
});
|
||||
return missingAssets;
|
||||
}
|
||||
|
||||
export async function uploadAssetsAsync(assetsForUpdateInfoGroup: CollectedAssets): Promise<void> {
|
||||
let assets: RawAsset[] = [];
|
||||
let platform: keyof CollectedAssets;
|
||||
for (platform in assetsForUpdateInfoGroup) {
|
||||
assets = [
|
||||
...assets,
|
||||
assetsForUpdateInfoGroup[platform]!.launchAsset,
|
||||
...assetsForUpdateInfoGroup[platform]!.assets,
|
||||
];
|
||||
}
|
||||
|
||||
const uniqueAssets = uniqBy<
|
||||
RawAsset & {
|
||||
storageKey: string;
|
||||
}
|
||||
>(
|
||||
await Promise.all(
|
||||
assets.map(async asset => {
|
||||
return {
|
||||
...asset,
|
||||
storageKey: await getStorageKeyForAssetAsync(asset),
|
||||
};
|
||||
})
|
||||
),
|
||||
'storageKey'
|
||||
);
|
||||
|
||||
let missingAssets = await filterOutAssetsThatAlreadyExistAsync(uniqueAssets);
|
||||
const { specifications } = await PublishMutation.getUploadURLsAsync(
|
||||
missingAssets.map(ma => ma.contentType)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
missingAssets.map((missingAsset, i) => {
|
||||
const presignedPost: PresignedPost = JSON.parse(specifications[i]);
|
||||
return uploadWithPresignedPostAsync(fs.createReadStream(missingAsset.path), presignedPost);
|
||||
})
|
||||
);
|
||||
|
||||
// Wait up to TIMEOUT_LIMIT for assets to be uploaded and processed
|
||||
const start = Date.now();
|
||||
let timeout = 1;
|
||||
while (missingAssets.length > 0) {
|
||||
const timeoutPromise = new Promise(resolve => setTimeout(resolve, timeout * 1000)); // linear backoff
|
||||
missingAssets = await filterOutAssetsThatAlreadyExistAsync(missingAssets);
|
||||
await timeoutPromise; // await after filterOutAssetsThatAlreadyExistAsync for easy mocking with jest.runAllTimers
|
||||
timeout += 1;
|
||||
|
||||
if (Date.now() - start > TIMEOUT_LIMIT) {
|
||||
throw new Error('Asset upload timed out. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import ora from 'ora';
|
||||
|
||||
import log from '../log';
|
||||
import { sleep } from '../utils/promise';
|
||||
import SubmissionService, { DEFAULT_CHECK_INTERVAL_MS } from './SubmissionService';
|
||||
import { Submission, SubmissionConfig, SubmissionStatus } from './SubmissionService.types';
|
||||
@@ -21,6 +22,7 @@ abstract class BaseSubmitter<SubmissionContext, SubmissionOptions> {
|
||||
submissionConfig: SubmissionConfig,
|
||||
verbose: boolean = false
|
||||
) {
|
||||
log.addNewLineIfNone();
|
||||
const scheduleSpinner = ora('Scheduling submission').start();
|
||||
let submissionId: string;
|
||||
try {
|
||||
@@ -71,7 +73,7 @@ abstract class BaseSubmitter<SubmissionContext, SubmissionOptions> {
|
||||
} else if (status === SubmissionStatus.IN_PROGRESS) {
|
||||
return `Submitting your app to ${this.appStoreName}: submission in progress`;
|
||||
} else if (status === SubmissionStatus.FINISHED) {
|
||||
return `Successfully submitted your app to ${this.appStoreName}!`;
|
||||
return `Submitted your app to ${this.appStoreName}!`;
|
||||
} else if (status === SubmissionStatus.ERRORED) {
|
||||
return `Something went wrong when submitting your app to ${this.appStoreName}.`;
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ArchiveType,
|
||||
SubmissionPlatform,
|
||||
} from '../types';
|
||||
import { breakWord, printSummary } from '../utils/summary';
|
||||
import { printSummary } from '../utils/summary';
|
||||
import { AndroidPackageSource, getAndroidPackageAsync } from './AndroidPackageSource';
|
||||
import { AndroidSubmissionConfig, ReleaseStatus, ReleaseTrack } from './AndroidSubmissionConfig';
|
||||
import { ServiceAccountSource, getServiceAccountAsync } from './ServiceAccountSource';
|
||||
@@ -68,19 +68,34 @@ class AndroidSubmitter extends BaseSubmitter<AndroidSubmissionContext, AndroidSu
|
||||
projectId,
|
||||
};
|
||||
|
||||
printSummary(
|
||||
{
|
||||
...submissionConfig,
|
||||
serviceAccountPath,
|
||||
},
|
||||
'Android Submission Summary',
|
||||
SummaryHumanReadableKeys,
|
||||
SummaryHumanReadableValues
|
||||
);
|
||||
printSummaryAndroid({
|
||||
...submissionConfig,
|
||||
serviceAccountPath,
|
||||
});
|
||||
return { ...submissionConfig, serviceAccount };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the summary as a table. Exported for testing locally.
|
||||
*
|
||||
* @example
|
||||
* printSummaryAndroid({
|
||||
* androidPackage: 'com.expo.demoapp',
|
||||
* archivePath: '/Users/example/Documents/863f9337-65d2-40c6-acb3-c1054c5c09f8.apk',
|
||||
* archiveUrl: 'https://turtle-v2-artifacts.s3.amazonaws.com/ios/6420592d-5b5d-439b-aed4-ccd278647138-ca4145d8468947df9ded737248a1a238.aab',
|
||||
* archiveType: 'apk' as any,
|
||||
* serviceAccountPath: '/Users/example/Documents/gsa.json',
|
||||
* track: ReleaseTrack.production,
|
||||
* releaseStatus: ReleaseStatus.completed,
|
||||
* projectId: '863f9337-65d2-40c6-acb3-c1054c5c09f8',
|
||||
* });
|
||||
* @param submissionConfig
|
||||
*/
|
||||
export function printSummaryAndroid(submissionConfig: Summary) {
|
||||
printSummary(submissionConfig, SummaryHumanReadableKeys, SummaryHumanReadableValues);
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
androidPackage: string;
|
||||
archivePath?: string;
|
||||
@@ -95,7 +110,7 @@ interface Summary {
|
||||
const SummaryHumanReadableKeys: Record<keyof Summary, string> = {
|
||||
androidPackage: 'Android package',
|
||||
archivePath: 'Archive path',
|
||||
archiveUrl: 'Archive URL',
|
||||
archiveUrl: 'Download URL',
|
||||
archiveType: 'Archive type',
|
||||
serviceAccountPath: 'Google Service Account',
|
||||
track: 'Release track',
|
||||
@@ -103,9 +118,6 @@ const SummaryHumanReadableKeys: Record<keyof Summary, string> = {
|
||||
projectId: 'Project ID',
|
||||
};
|
||||
|
||||
const SummaryHumanReadableValues: Partial<Record<keyof Summary, Function>> = {
|
||||
archivePath: (path: string) => breakWord(path, 50),
|
||||
archiveUrl: (url: string) => breakWord(url, 50),
|
||||
};
|
||||
const SummaryHumanReadableValues: Partial<Record<keyof Summary, Function>> = {};
|
||||
|
||||
export default AndroidSubmitter;
|
||||
@@ -159,15 +159,15 @@ async function handlePromptSourceAsync(source: ArchiveFilePromptSource): Promise
|
||||
type: 'select',
|
||||
message: 'What would you like to submit?',
|
||||
choices: [
|
||||
{
|
||||
title: 'Latest build from EAS',
|
||||
value: ArchiveFileSourceType.latest,
|
||||
},
|
||||
{ title: 'I have a url to the app archive', value: ArchiveFileSourceType.url },
|
||||
{
|
||||
title: "I'd like to upload the app archive from my computer",
|
||||
title: 'Local app binary file',
|
||||
value: ArchiveFileSourceType.path,
|
||||
},
|
||||
{
|
||||
title: 'The latest build from Expo servers',
|
||||
value: ArchiveFileSourceType.latest,
|
||||
},
|
||||
{
|
||||
title: 'A build identified by a build id',
|
||||
value: ArchiveFileSourceType.buildId,
|
||||
|
||||
@@ -4,6 +4,10 @@ import { Platform } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { authenticateAsync, getRequestContext } from '../../credentials/ios/appstore/authenticate';
|
||||
import {
|
||||
ensureAppExistsAsync,
|
||||
ensureBundleIdExistsWithNameAsync,
|
||||
} from '../../credentials/ios/appstore/ensureAppExists';
|
||||
import log from '../../log';
|
||||
import { getAppIdentifierAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
@@ -80,10 +84,9 @@ async function createAppStoreConnectAppAsync(options: CreateAppOptions): Promise
|
||||
log.addNewLineIfNone();
|
||||
|
||||
if (await isProvisioningAvailableAsync(requestCtx)) {
|
||||
log(`Ensuring that bundle ID "${bundleId}" is registered on Apple Dev Center...`);
|
||||
await App.ensureBundleIdExistsAsync(requestCtx, {
|
||||
await ensureBundleIdExistsWithNameAsync(authCtx, {
|
||||
name: appName,
|
||||
bundleId,
|
||||
bundleIdentifier: bundleId,
|
||||
});
|
||||
} else {
|
||||
log.warn(
|
||||
@@ -91,56 +94,38 @@ async function createAppStoreConnectAppAsync(options: CreateAppOptions): Promise
|
||||
);
|
||||
}
|
||||
|
||||
log(`Checking App Store Connect for "${appName}" (${bundleId})...`);
|
||||
let app = await App.findAsync(requestCtx, { bundleId });
|
||||
let app: App | null = null;
|
||||
|
||||
if (!app) {
|
||||
log(`Creating app "${appName}" (${bundleId}) on App Store Connect...`);
|
||||
try {
|
||||
app = await App.createAsync(requestCtx, {
|
||||
bundleId,
|
||||
name: appName,
|
||||
primaryLocale: language,
|
||||
companyName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.message.match(/An App ID with Identifier '(.*)' is not available/)) {
|
||||
const providerName = authCtx.authState?.session.provider.name;
|
||||
throw new Error(
|
||||
`\nThe bundle identifier "${bundleId}" is not available to provider "${providerName}". Please change it in your app config and try again.\n`
|
||||
);
|
||||
}
|
||||
|
||||
log.error('Failed to create the app in App Store Connect:');
|
||||
|
||||
if (
|
||||
// Name is invalid
|
||||
error.message.match(
|
||||
/App Name contains certain Unicode(.*)characters that are not permitted/
|
||||
) ||
|
||||
// UnexpectedAppleResponse: An attribute value has invalid characters. - App Name contains certain Unicode symbols, emoticons, diacritics, special characters, or private use characters that are not permitted.
|
||||
// Name is taken
|
||||
error.message.match(/The App Name you entered is already being used/)
|
||||
// UnexpectedAppleResponse: The provided entity includes an attribute with a value that has already been used on a different account. - The App Name you entered is already being used. If you have trademark rights to
|
||||
// this name and would like it released for your use, submit a claim.
|
||||
) {
|
||||
log.addNewLineIfNone();
|
||||
log.warn(
|
||||
`Change the name in your app config, or use a custom name with the ${chalk.bold(
|
||||
'--app-name'
|
||||
)} flag`
|
||||
);
|
||||
log.newLine();
|
||||
}
|
||||
throw error;
|
||||
try {
|
||||
app = await ensureAppExistsAsync(authCtx, {
|
||||
name: appName,
|
||||
language,
|
||||
companyName,
|
||||
bundleIdentifier: bundleId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
// Name is invalid
|
||||
error.message.match(
|
||||
/App Name contains certain Unicode(.*)characters that are not permitted/
|
||||
) ||
|
||||
// UnexpectedAppleResponse: An attribute value has invalid characters. - App Name contains certain Unicode symbols, emoticons, diacritics, special characters, or private use characters that are not permitted.
|
||||
// Name is taken
|
||||
error.message.match(/The App Name you entered is already being used/)
|
||||
// UnexpectedAppleResponse: The provided entity includes an attribute with a value that has already been used on a different account. - The App Name you entered is already being used. If you have trademark rights to
|
||||
// this name and would like it released for your use, submit a claim.
|
||||
) {
|
||||
log.addNewLineIfNone();
|
||||
log.warn(
|
||||
`Change the name in your app config, or use a custom name with the ${chalk.bold(
|
||||
'--app-name'
|
||||
)} flag`
|
||||
);
|
||||
log.newLine();
|
||||
}
|
||||
} else {
|
||||
// TODO: Update app name when API gives us that possibility
|
||||
throw error;
|
||||
}
|
||||
|
||||
// TODO: Maybe sync capabilities now as well
|
||||
log(`App "${app.attributes.name}" (${bundleId}) on App Store Connect is ready for your binary.`);
|
||||
|
||||
return {
|
||||
appleId: authCtx.appleId,
|
||||
ascAppId: app.id,
|
||||
@@ -151,7 +136,7 @@ async function promptForAppNameAsync(): Promise<string> {
|
||||
const { appName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'appName',
|
||||
message: 'How would you like to name your app?',
|
||||
message: 'What would you like to name your app?',
|
||||
validate: (val: string) => val !== '' || 'App name cannot be empty!',
|
||||
});
|
||||
return appName;
|
||||
|
||||
@@ -3,7 +3,7 @@ import chalk from 'chalk';
|
||||
import BaseSubmitter from '../BaseSubmitter';
|
||||
import { Archive, ArchiveSource, getArchiveAsync } from '../archiveSource';
|
||||
import { IosSubmissionContext, SubmissionPlatform } from '../types';
|
||||
import { breakWord, printSummary } from '../utils/summary';
|
||||
import { printSummary } from '../utils/summary';
|
||||
import {
|
||||
AppSpecificPasswordSource,
|
||||
getAppSpecificPasswordAsync,
|
||||
@@ -35,12 +35,8 @@ class IosSubmitter extends BaseSubmitter<IosSubmissionContext, IosSubmissionOpti
|
||||
resolvedSourceOptions
|
||||
);
|
||||
|
||||
printSummary(
|
||||
submissionConfig,
|
||||
'iOS Submission Summary',
|
||||
SummaryHumanReadableKeys,
|
||||
SummaryHumanReadableValues
|
||||
);
|
||||
printSummaryIOS(submissionConfig);
|
||||
|
||||
await this.startSubmissionAsync(submissionConfig, this.ctx.commandFlags.verbose);
|
||||
}
|
||||
|
||||
@@ -72,16 +68,37 @@ class IosSubmitter extends BaseSubmitter<IosSubmissionContext, IosSubmissionOpti
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the summary as a table. Exported for testing locally.
|
||||
* @example
|
||||
* printSummaryIOS({
|
||||
* appleId: '[email protected]',
|
||||
* appSpecificPassword: 'Apple Pie^',
|
||||
* appAppleId: '1234567890',
|
||||
* projectId: '863f9337-65d2-40c6-acb3-c1054c5c09f8',
|
||||
* archiveUrl: 'https://turtle-v2-artifacts.s3.amazonaws.com/ios/6420592d-5b5d-439b-aed4-ccd278647138-ca4145d8468947df9ded737248a1a238.ipa',
|
||||
* });
|
||||
* @param submissionConfig
|
||||
*/
|
||||
export function printSummaryIOS(submissionConfig: IosSubmissionConfig) {
|
||||
const { appSpecificPassword, projectId, archiveUrl, ...logConfig } = submissionConfig;
|
||||
|
||||
printSummary(
|
||||
{ ...logConfig, projectId, archiveUrl },
|
||||
SummaryHumanReadableKeys,
|
||||
SummaryHumanReadableValues
|
||||
);
|
||||
}
|
||||
|
||||
const SummaryHumanReadableKeys: Record<keyof IosSubmissionConfig, string> = {
|
||||
appleId: 'Apple ID',
|
||||
archiveUrl: 'Archive URL',
|
||||
archiveUrl: 'Download URL',
|
||||
appSpecificPassword: 'Apple app-specific password',
|
||||
appAppleId: 'App Store Connect App ID',
|
||||
appAppleId: 'ASC App ID',
|
||||
projectId: 'Project ID',
|
||||
};
|
||||
|
||||
const SummaryHumanReadableValues: Partial<Record<keyof IosSubmissionConfig, Function>> = {
|
||||
archiveUrl: (url: string) => breakWord(url, 50),
|
||||
appSpecificPassword: () => chalk.italic('[hidden]'),
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ enum SubmissionErrorCode {
|
||||
ANDROID_FIRST_UPLOAD_ERROR = 'SUBMISSION_SERVICE_ANDROID_FIRST_UPLOAD_ERROR',
|
||||
ANDROID_OLD_VERSION_CODE_ERROR = 'SUBMISSION_SERVICE_ANDROID_OLD_VERSION_CODE_ERROR',
|
||||
ANDROID_MISSING_PRIVACY_POLICY = 'SUBMISSION_SERVICE_ANDROID_MISSING_PRIVACY_POLICY',
|
||||
IOS_OLD_VERSION_CODE_ERROR = 'SUBMISSION_SERVICE_IOS_OLD_VERSION_CODE_ERROR',
|
||||
IOS_UNKNOWN_ERROR = 'SUBMISSION_SERVICE_IOS_UNKNOWN_ERROR',
|
||||
IOS_MISSING_APP_ICON = 'SUBMISSION_SERVICE_IOS_MISSING_APP_ICON',
|
||||
IOS_INVALID_SIGNATURE = 'SUBMISSION_SERVICE_IOS_INVALID_SIGNATURE',
|
||||
}
|
||||
|
||||
const SubmissionErrorMessages: Record<SubmissionErrorCode, string> = {
|
||||
@@ -28,6 +32,21 @@ const SubmissionErrorMessages: Record<SubmissionErrorCode, string> = {
|
||||
[SubmissionErrorCode.ANDROID_MISSING_PRIVACY_POLICY]:
|
||||
'The app has permissions that require a privacy policy set for the app.\n' +
|
||||
`${learnMore('https://expo.fyi/missing-privacy-policy')}.`,
|
||||
[SubmissionErrorCode.IOS_OLD_VERSION_CODE_ERROR]:
|
||||
"You've already submitted this version of the app.\n" +
|
||||
'Versions are identified by Build Numbers (expo.ios.buildNumber in app.json).\n' +
|
||||
"If you're submitting an Expo project built with EAS Build, increment the build number in app.json and build the project again.\n" +
|
||||
`${learnMore('https://expo.fyi/bumping-ios-build-number')}.`,
|
||||
[SubmissionErrorCode.IOS_UNKNOWN_ERROR]:
|
||||
"We couldn't figure out what went wrong. Please see logs to learn more.",
|
||||
[SubmissionErrorCode.IOS_MISSING_APP_ICON]:
|
||||
'Your iOS app icon is missing or is an invalid format. The icon must be a 1024x1024 PNG image with no transparency.\n' +
|
||||
'Please check your icon image and icon configuration in app.json.\n' +
|
||||
`${learnMore('https://docs.expo.io/guides/app-icons/')}`,
|
||||
[SubmissionErrorCode.IOS_INVALID_SIGNATURE]:
|
||||
'Your app signature seems to be invalid.\n' +
|
||||
"Please check your iOS Distribution Certificate and your app's Provisioning Profile.\n" +
|
||||
`${learnMore('https://docs.expo.io/distribution/app-signing')}`,
|
||||
};
|
||||
|
||||
export function printSubmissionError(error: SubmissionError): boolean {
|
||||
@@ -35,7 +54,9 @@ export function printSubmissionError(error: SubmissionError): boolean {
|
||||
const errorCode = error.errorCode as SubmissionErrorCode;
|
||||
log.addNewLineIfNone();
|
||||
log.error(SubmissionErrorMessages[errorCode]);
|
||||
return errorCode === SubmissionErrorCode.ANDROID_UNKNOWN_ERROR;
|
||||
return [SubmissionErrorCode.ANDROID_UNKNOWN_ERROR, SubmissionErrorCode.IOS_UNKNOWN_ERROR].some(
|
||||
code => code === errorCode
|
||||
);
|
||||
} else {
|
||||
log(error.message);
|
||||
return true;
|
||||
|
||||
@@ -1,36 +1,31 @@
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import chunk from 'lodash/chunk';
|
||||
|
||||
import log from '../../log';
|
||||
|
||||
export function breakWord(word: string, chars: number): string {
|
||||
return chunk(word, chars)
|
||||
.map((arr: string[]) => arr.join(''))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function printSummary<T>(
|
||||
summary: T,
|
||||
title: string,
|
||||
keyMap: Record<keyof T, string>,
|
||||
valueRemap: Partial<Record<keyof T, Function>>
|
||||
): void {
|
||||
const table = new Table({
|
||||
colWidths: [25, 55],
|
||||
wordWrap: true,
|
||||
});
|
||||
table.push([
|
||||
{
|
||||
colSpan: 2,
|
||||
content: chalk.bold(title),
|
||||
hAlign: 'center',
|
||||
},
|
||||
]);
|
||||
const padWidth = longestStringLength(Object.keys(summary).map(key => keyMap[key as keyof T]));
|
||||
|
||||
const tableFormat = (name: string, msg: string) =>
|
||||
`${chalk.bold.cyan(pad(`${name}:`, padWidth + 1))} ${msg}`;
|
||||
|
||||
log.newLine();
|
||||
for (const [key, value] of Object.entries(summary)) {
|
||||
const displayKey = keyMap[key as keyof T];
|
||||
const displayValue = valueRemap[key as keyof T]?.(value) ?? value;
|
||||
table.push([displayKey, displayValue]);
|
||||
log(tableFormat(displayKey, displayValue));
|
||||
}
|
||||
log(table.toString());
|
||||
log.addNewLineIfNone();
|
||||
}
|
||||
|
||||
function pad(str: string, width: number): string {
|
||||
const len = Math.max(0, width - str.length);
|
||||
return str + Array(len + 1).join(' ');
|
||||
}
|
||||
|
||||
function longestStringLength(values: string[]): number {
|
||||
return values.reduce((max, option) => Math.max(max, option.length), 0);
|
||||
}
|
||||
@@ -57,8 +57,8 @@ async function obtainS3PresignedPostAsync(
|
||||
export async function uploadWithPresignedPostAsync(
|
||||
stream: Readable | Buffer,
|
||||
presignedPost: PresignedPost,
|
||||
handleProgressEvent: ProgressHandler
|
||||
) {
|
||||
handleProgressEvent?: ProgressHandler
|
||||
): Promise<string> {
|
||||
const form = new FormData();
|
||||
for (const [fieldKey, fieldValue] of Object.entries(presignedPost.fields)) {
|
||||
form.append(fieldKey, fieldValue);
|
||||
@@ -66,17 +66,21 @@ export async function uploadWithPresignedPostAsync(
|
||||
form.append('file', stream);
|
||||
const formHeaders = form.getHeaders();
|
||||
let uploadPromise = got.post(presignedPost.url, { body: form, headers: { ...formHeaders } });
|
||||
|
||||
if (handleProgressEvent) {
|
||||
uploadPromise = uploadPromise.on('uploadProgress', progress =>
|
||||
handleProgressEvent({ progress })
|
||||
);
|
||||
try {
|
||||
const response = await uploadPromise;
|
||||
handleProgressEvent({ isComplete: true });
|
||||
return String(response.headers.location);
|
||||
} catch (error) {
|
||||
handleProgressEvent({ isComplete: true, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await uploadPromise;
|
||||
handleProgressEvent({ isComplete: true });
|
||||
return String(response.headers.location);
|
||||
} catch (error) {
|
||||
handleProgressEvent({ isComplete: true, error });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response = await uploadPromise;
|
||||
return String(response.headers.location);
|
||||
}
|
||||
@@ -50,10 +50,12 @@ async function isGitInstalledAsync(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getBranchNameAsync(): Promise<string | undefined> {
|
||||
async function getBranchNameAsync(): Promise<string | null> {
|
||||
try {
|
||||
return (await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout;
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getLastCommitMessageAsync(): Promise<string | null> {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"importHelpers": true,
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "build",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
|
||||
@@ -3686,6 +3686,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.161.tgz#a21ca0777dabc6e4f44f3d07f37b765f54188b18"
|
||||
integrity sha512-EP6O3Jkr7bXvZZSZYlsgt5DIjiGr0dXP1/jVEwVLTFgg0d+3lWVQkRavYVQszV7dYUwvg0B8R0MBDpcmXg7XIA==
|
||||
|
||||
"@types/mime@^2.0.3":
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"
|
||||
integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==
|
||||
|
||||
"@types/minimatch@*":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
|
||||
@@ -9258,6 +9263,11 @@ mime@^2.4.1, mime@^2.4.4:
|
||||
resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1"
|
||||
integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA==
|
||||
|
||||
mime@^2.4.7:
|
||||
version "2.4.7"
|
||||
resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.7.tgz#962aed9be0ed19c91fd7dc2ece5d7f4e89a90d74"
|
||||
integrity sha512-dhNd1uA2u397uQk3Nv5LM4lm93WYDUXFn3Fu291FJerns4jyTudqhIWe4W04YLy7Uk1tm1Ore04NpjRvQp/NPA==
|
||||
|
||||
mimic-fn@^1.0.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
|
||||
@@ -9397,6 +9407,11 @@ mkdirp@^0.5.0, mkdirp@^0.5.1:
|
||||
dependencies:
|
||||
minimist "^1.2.5"
|
||||
|
||||
mockdate@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/mockdate/-/mockdate-3.0.2.tgz#a5a7bb5820da617747af424d7a4dcb22c6c03d79"
|
||||
integrity sha512-ldfYSUW1ocqSHGTK6rrODUiqAFPGAg0xaHqYJ5tvj1hQyFsjuHpuWgWFTZWwDVlzougN/s2/mhDr8r5nY5xDpA==
|
||||
|
||||
modify-values@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022"
|
||||
|
||||
Reference in new issue
Block a user