Compare commits

...
9 Commits
Author SHA1 Message Date
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
44 changed files with 344 additions and 249 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.1"
}
+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.1 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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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(),
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.1.0",
"version": "0.1.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -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(
+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 };
+1 -1
View File
@@ -92,7 +92,7 @@ export default class Build extends Command {
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;
}
+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'
);
});
});
@@ -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')) {
@@ -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." +
`${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);
}