Skip to content
This repository has been archived by the owner on Jan 18, 2024. It is now read-only.

Commit

Permalink
feat(expo-cli): extract android credentials flow (#2230)
Browse files Browse the repository at this point in the history
* feat(expo-cli): extract android credentials flow

* review feedback

* tests

* fix nonInteractive mode

* add warning to --generate-keystore flag
  • Loading branch information
wkozyra95 authored Jun 10, 2020
1 parent f224dcb commit 6f6f108
Show file tree
Hide file tree
Showing 34 changed files with 854 additions and 511 deletions.
2 changes: 2 additions & 0 deletions packages/expo-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@babel/cli": "^7.8.4",
"@babel/core": "^7.9.0",
"@expo/babel-preset-cli": "0.2.16",
"@types/command-exists": "^1.2.0",
"@types/dateformat": "^3.0.0",
"@types/fs-extra": "^9.0.1",
"@types/inquirer": "6.0.3",
Expand Down Expand Up @@ -76,6 +77,7 @@
"boxen": "4.1.0",
"chalk": "^4.0.0",
"cli-table3": "^0.6.0",
"command-exists": "^1.2.8",
"commander": "2.17.1",
"concat-stream": "1.6.2",
"dateformat": "3.0.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { vol } from 'memfs';
import { ApiV2 } from '@expo/xdl';

import { getPublicationDetailAsync, getPublishHistoryAsync } from '../utils/PublishUtils';
import { jester } from '../../credentials/test-fixtures/mocks';
import { jester } from '../../credentials/test-fixtures/mocks-ios';
import { mockExpoXDL } from '../../__tests__/mock-utils';

jest.mock('fs');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
rollbackPublicationFromChannelAsync,
setPublishToChannelAsync,
} from '../utils/PublishUtils';
import { jester } from '../../credentials/test-fixtures/mocks';
import { jester } from '../../credentials/test-fixtures/mocks-ios';
import { mockExpoXDL } from '../../__tests__/mock-utils';
import { createTestProject } from '../../__tests__/project-utils';

Expand Down
233 changes: 32 additions & 201 deletions packages/expo-cli/src/commands/build/AndroidBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import fs from 'fs-extra';
import path from 'path';
import untildify from 'untildify';
import { Android, AndroidCredentials, Credentials } from '@expo/xdl';
import chalk from 'chalk';
import { Android } from '@expo/xdl';
import pick from 'lodash/pick';

import invariant from 'invariant';
import log from '../../log';
import { Context } from '../../credentials';
import { runCredentialsManager } from '../../credentials/route';
import {
RemoveKeystore,
getKeystoreFromParams,
useKeystore,
} from '../../credentials/views/AndroidKeystore';
import { SetupAndroidKeystore } from '../../credentials/views/SetupAndroidKeystore';
import BuildError from './BuildError';
import BaseBuilder from './BaseBuilder';
import prompt, { Question } from '../../prompt';
import log from '../../log';
import * as utils from './utils';
import { PLATFORMS, Platform } from './constants';
import { Context } from '../../credentials';
import { DownloadKeystore } from '../../credentials/views/AndroidCredentials';

const { ANDROID } = PLATFORMS;

Expand Down Expand Up @@ -53,207 +55,36 @@ See https://docs.expo.io/distribution/building-standalone-apps/#2-configure-appj
}
}

async _clearCredentials() {
const credentialMetadata = await Credentials.getCredentialMetadataAsync(
this.projectDir,
ANDROID
);
log.newLine();
log.warn(
`⚠️ Clearing your Android build credentials from our build servers is a ${chalk.red(
'PERMANENT and IRREVERSIBLE action.'
)}`
);
log.warn(
chalk.bold(
'Android keystores must be identical to the one previously used to submit your app to the Google Play Store.'
)
);
log.warn(
'Please read https://docs.expo.io/distribution/building-standalone-apps/#if-you-choose-to-build-for-android for more info before proceeding.'
);
log.newLine();
log.warn(
chalk.bold('Your keystore will be backed up to your current directory if you continue.')
);
log.newLine();
let questions: Question[] = [
{
type: 'confirm',
name: 'confirm',
message: 'Permanently delete the Android build credentials from our servers?',
},
];

const answers = await prompt(questions);

if (answers.confirm) {
log('Backing up your Android keystore now...');
const ctx = new Context();
await ctx.init(this.projectDir);

const backupKeystoreOutputPath = path.resolve(this.projectDir, `${ctx.manifest.slug}.jks`);

invariant(ctx.manifest.slug, 'app.json slug field must be set');
const view = new DownloadKeystore(ctx.manifest.slug as string);
await view.fetch(ctx);
await view.save(ctx, backupKeystoreOutputPath, true);
await Credentials.removeCredentialsForPlatform(ANDROID, credentialMetadata);
log.warn('Removed existing credentials from Expo servers');
}
platform(): Platform {
return ANDROID;
}

async collectAndValidateCredentials() {
const credentialMetadata = await Credentials.getCredentialMetadataAsync(
this.projectDir,
ANDROID
);

const credentialsExist = await Credentials.credentialsExistForPlatformAsync(credentialMetadata);

if (this.checkEnv()) {
await this.collectAndValidateCredentialsFromCI(credentialMetadata);
} else if (
!this.options.generateKeystore &&
(this.options.clearCredentials || !credentialsExist)
) {
console.log('');
const questions: Question[] = [
{
type: 'rawlist',
name: 'uploadKeystore',
message: `Would you like to upload a keystore or have us generate one for you?\nIf you don't know what this means, let us handle it! :)\n`,
choices: [
{ name: 'Let Expo handle the process!', value: false },
{ name: 'I want to upload my own keystore!', value: true },
],
},
{
type: 'input',
name: 'keystorePath',
message: `Path to keystore:`,
validate: async (keystorePath: string): Promise<boolean> => {
try {
const keystorePathStats = await fs.stat(keystorePath);
return keystorePathStats.isFile();
} catch (e) {
// file does not exist
console.log('\nFile does not exist.');
return false;
}
},
filter: (keystorePath: string): string => {
keystorePath = untildify(keystorePath);
if (!path.isAbsolute(keystorePath)) {
keystorePath = path.resolve(keystorePath);
}
return keystorePath;
},
// @ts-ignore: The expected type comes from property 'when' which is declared here on type 'Question<Record<string, any>>'
when: (answers: Record<string, Question>) => answers.uploadKeystore,
},
{
type: 'password',
name: 'keystorePassword',
message: `Keystore Password:`,
validate: (val: string): boolean => val !== '',
// @ts-ignore: The expected type comes from property 'when' which is declared here on type 'Question<Record<string, any>>'
when: (answers: Record<string, Question>) => answers.uploadKeystore,
},
{
type: 'input',
name: 'keystoreAlias',
message: `Keystore Alias:`,
validate: (val: string): boolean => val !== '',
// @ts-ignore: The expected type comes from property 'when' which is declared here on type 'Question<Record<string, any>>'
when: (answers: Record<string, Question>) => answers.uploadKeystore,
},
{
type: 'password',
name: 'keyPassword',
message: `Key Password:`,
validate: (password: string): boolean => {
if (password === '') {
return false;
}
// Todo validate keystore passwords
return true;
},
// @ts-ignore: The expected type comes from property 'when' which is declared here on type 'Question<Record<string, any>>'
when: (answers: Record<string, Question>) => answers.uploadKeystore,
},
];
async collectAndValidateCredentials(): Promise<void> {
const ctx = new Context();
await ctx.init(this.projectDir);

const answers = await prompt(questions);
const experienceName = `@${ctx.manifest.owner || ctx.user.username}/${ctx.manifest.slug}`;

if (!answers.uploadKeystore) {
if (this.options.clearCredentials && credentialsExist) {
await this._clearCredentials();
}
// just continue
} else {
const { keystorePath, keystoreAlias, keystorePassword, keyPassword } = answers;

// read the keystore
const keystoreData = await fs.readFile(keystorePath);

const credentials: AndroidCredentials.Keystore = {
keystore: keystoreData.toString('base64'),
keyAlias: keystoreAlias,
keystorePassword,
keyPassword,
};
await Credentials.updateCredentialsForPlatform(
ANDROID,
// @ts-ignore: Type '{ keystore: string; keystoreAlias: any; keystorePassword: any; keyPassword: any; }' has no properties in common with type 'Credentials'.
credentials,
[],
credentialMetadata
if (this.options.clearCredentials) {
if (this.options.parent?.nonInteractive) {
throw new BuildError(
'Clearing your Android build credentials from our build servers is a PERMANENT and IRREVERSIBLE action, it\'s not supported when combined with the "--non-interactive" option'
);
}
}
}

checkEnv(): boolean {
const allEnvSet =
!!this.options.keystorePath &&
!!this.options.keystoreAlias &&
!!process.env.EXPO_ANDROID_KEYSTORE_PASSWORD &&
!!process.env.EXPO_ANDROID_KEY_PASSWORD;

if (allEnvSet) {
return true;
await runCredentialsManager(ctx, new RemoveKeystore(experienceName));
}

// Check if user was trying to upload keystore incorretly and supply an helpful error message if so.
if (this.options.keystorePath || this.options.keystoreAlias) {
throw Error(
'When uploading your own keystore you must provide:\n' +
'\t--keystore-path /path/to/your/keystore.jks \n' +
'\t--keystore-alias PUT_KEYSTORE_ALIAS_HERE \n' +
'And set the enviroment variables:\n' +
'\tEXPO_ANDROID_KEYSTORE_PASSWORD\n' +
'\tEXPO_ANDROID_KEY_PASSWORD\n' +
'For details, see:\n' +
'\thttps://docs.expo.io/distribution/building-standalone-apps/#if-you-choose-to-build-for-android'
const paramKeystore = await getKeystoreFromParams(this.options);
if (paramKeystore) {
await useKeystore(ctx, experienceName, paramKeystore);
} else {
await runCredentialsManager(
ctx,
new SetupAndroidKeystore(experienceName, {
nonInteractive: this.options.parent?.nonInteractive,
allowMissingKeystore: true,
})
);
}
return false;
}

async collectAndValidateCredentialsFromCI(
credentialMetadata: Credentials.CredentialMetadata
): Promise<void> {
const credentials: any = {
keystore: (await fs.readFile(this.options.keystorePath!)).toString('base64'),
keystoreAlias: this.options.keystoreAlias,
keystorePassword: process.env.EXPO_ANDROID_KEYSTORE_PASSWORD,
keyPassword: process.env.EXPO_ANDROID_KEY_PASSWORD,
};
await Credentials.updateCredentialsForPlatform(ANDROID, credentials, [], credentialMetadata);
}

platform(): Platform {
return ANDROID;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getApiV2MockCredentials,
jester,
testAppJson,
} from '../../../credentials/test-fixtures/mocks';
} from '../../../credentials/test-fixtures/mocks-ios';
import { mockExpoXDL } from '../../../__tests__/mock-utils';

jest.setTimeout(10 * 1000); // 10s
Expand Down
7 changes: 6 additions & 1 deletion packages/expo-cli/src/commands/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export default function (program: Command) {
.option('--no-wait', 'Exit immediately after triggering build.')
.option('--keystore-path <app.jks>', 'Path to your Keystore.')
.option('--keystore-alias <alias>', 'Keystore Alias')
.option('--generate-keystore', 'Generate Keystore if one does not exist')
.option('--generate-keystore', '[deprecated] Generate Keystore if one does not exist')
.option('--public-url <url>', 'The URL of an externally hosted manifest (for self-hosted apps)')
.option('--skip-workflow-check', 'Skip warning about build service bare workflow limitations.')
.option('-t --type <build>', 'Type of build: [app-bundle|apk].')
Expand All @@ -145,6 +145,11 @@ export default function (program: Command) {
)
.asyncActionProjectDir(
async (projectDir: string, options: AndroidOptions) => {
if (options.generateKeystore) {
log.warn(
`The --generate-keystore flag is deprecated and does not do anything. A Keystore will always be generated on the Expo servers if it's missing.`
);
}
if (!options.skipWorkflowCheck) {
if (
await maybeBailOnWorkflowWarning({
Expand Down
Loading

0 comments on commit 6f6f108

Please sign in to comment.