Compare commits

...
17 Commits
Author SHA1 Message Date
Cedric van Putten ca718d8da9 v0.1.2 2020-12-21 13:59:59 +01:00
Cedric van Putten 3e7b849ad1 [eas-cli] Force update plist library to fix missing invariant (#174) 2020-12-21 13:47:24 +01:00
Dominik Sokal 2ec646659d [eas-cli] fix exiting 'eas build' when there are other pending builds 2020-12-21 11:10:45 +01:00
Dominik Sokal c33e3d8a4c [eas-cli] eas build - print an error when there's another pending build (#169) 2020-12-21 10:51:01 +01:00
jkh d65c180746 [eas-cli] add publish.ts 3/4 (#147) 2020-12-18 08:43:34 -08:00
jkh 754af79e0c [expo-cli] set noImplicitReturns to true (#166) 2020-12-17 16:51:34 -08:00
Bartłomiej Klocek 3733cc4cf8 Fix no-newline typo 2020-12-16 11:56:43 +01:00
jkh 9e5c1740b4 [eas-cli] add graphql calls 2/4 (#149) 2020-12-15 18:01:21 -08:00
Brent Vatne 58919d37ab v0.1.1 2020-12-14 20:37:37 -08:00
Evan Bacon 2ec4279ac7 [submit] Unify app creation code (#158) 2020-12-14 20:36:12 -08:00
Evan Bacon 3052276c3b fix tests (#161)
* await test rejection

* fix tests
2020-12-14 16:07:16 -08:00
Evan Bacon 65a9238d48 [eas-cli] Simplify the prompts for auto generation (#156) 2020-12-14 16:03:31 -08:00
Evan Bacon 5a1bb94563 Improve logging (#154)
* Update configure.ts

* Update build.ts

* remove Successfully, fix casing

* improve credentials logging

* updated logging

* more minor changes

* Update RemoveKeystore.ts

* change log treatment

* fix tests

* fix lint
2020-12-14 15:41:41 -08:00
Bartłomiej Klocek 1094d3f9f7 [eas submit] Add missing iOS error messages (#125)
* Handle iOS missing app icon error
* Handle invalid signature error
2020-12-14 15:37:00 -08:00
Wojciech Kozyra d8452ce6f8 [eas-cli] Improve keytool detection (#152) 2020-12-14 15:23:03 -08:00
Evan Bacon a5be039fd2 Show tar logging if it takes a while (#155)
* Update repository.ts

* Update repository.ts
2020-12-14 15:22:39 -08:00
Evan Bacon 6178a54292 Simplify table logs (#157) 2020-12-14 15:22:23 -08:00
57 changed files with 1383 additions and 395 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.1.0"
"version": "0.1.2"
}
+13 -13
View File
@@ -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 -->
+1
View File
@@ -3,4 +3,5 @@ export default jest.fn().mockReturnValue({
fail: jest.fn(),
succeed: jest.fn(),
}),
succeed: jest.fn(),
});
+5 -2
View File
@@ -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(
+1 -1
View File
@@ -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 });
+3 -3
View File
@@ -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);
+13 -9
View File
@@ -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);
+1 -1
View File
@@ -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(),
},
+31 -6
View File
@@ -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 };
+83 -5
View File
@@ -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: [
{
+1 -1
View File
@@ -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}`);
+1 -1
View File
@@ -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;
}
+1 -3
View File
@@ -80,9 +80,7 @@ export class AccountResolver {
)} account?`,
});
if (useProjectAccount) {
return projectAccount;
}
return useProjectAccount ? projectAccount : undefined;
}
private async promptForAccountAsync(): Promise<Account> {
+137 -123
View File
@@ -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
}
}
`
+5
View File
@@ -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')) {
+251
View File
@@ -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);
}
+14 -10
View File
@@ -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);
}
+4 -2
View File
@@ -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> {
+1
View File
@@ -4,6 +4,7 @@
"importHelpers": true,
"module": "commonjs",
"esModuleInterop": true,
"noImplicitReturns": true,
"outDir": "build",
"rootDir": "src",
"strict": true,
+15
View File
@@ -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"