diff --git a/packages/expo-cli/src/commands/eas-build/credentialsSync/action.ts b/packages/expo-cli/src/commands/eas-build/credentialsSync/action.ts new file mode 100644 index 0000000000..c3c5ec0dca --- /dev/null +++ b/packages/expo-cli/src/commands/eas-build/credentialsSync/action.ts @@ -0,0 +1,73 @@ +import CommandError from '../../../CommandError'; +import { Context } from '../../../credentials/context'; +import { updateLocalCredentialsJsonAsync } from '../../../credentials/local'; +import { runCredentialsManager } from '../../../credentials/route'; +import { SetupAndroidBuildCredentialsFromLocal } from '../../../credentials/views/SetupAndroidKeystore'; +import { SetupIosBuildCredentialsFromLocal } from '../../../credentials/views/SetupIosBuildCredentials'; +import prompts from '../../../prompts'; +import { BuildCommandPlatform } from '../types'; + +interface Options { + parent: { + nonInteractive?: boolean; + }; +} + +export default async function credentialsSyncAction(projectDir: string, options: Options) { + if (options.parent.nonInteractive) { + throw new CommandError('This command is not supported in --non-interactive mode'); + } + const { update, platform } = await prompts([ + { + type: 'select', + name: 'update', + message: 'What do you want to do?', + choices: [ + { + title: 'Update credentials on Expo servers with local credentials.json content', + value: 'remote', + }, + { title: 'Update local credentials.json with values from Expo servers', value: 'local' }, + ], + }, + { + type: 'select', + name: 'platform', + message: 'Do you want to update credentials for both platforms?', + choices: [ + { title: 'Android & iOS', value: BuildCommandPlatform.ALL }, + { title: 'only Android', value: BuildCommandPlatform.ANDROID }, + { title: 'only iOS', value: BuildCommandPlatform.IOS }, + ], + }, + ]); + if (update === 'local') { + await updateLocalCredentialsJsonAsync(projectDir, platform); + } else { + await updateRemoteCredentialsAsync(projectDir, platform); + } +} + +async function updateRemoteCredentialsAsync(projectDir: string, platform: BuildCommandPlatform) { + const ctx = new Context(); + await ctx.init(projectDir); + if (!ctx.hasProjectContext) { + throw new Error('project context is required'); // should bb checked earlier + } + if (['all', 'android'].includes(platform)) { + const experienceName = `@${ctx.manifest.owner || ctx.user.username}/${ctx.manifest.slug}`; + await runCredentialsManager(ctx, new SetupAndroidBuildCredentialsFromLocal(experienceName)); + } + if (['all', 'ios'].includes(platform)) { + const bundleIdentifier = ctx.manifest.ios?.bundleIdentifier; + if (!bundleIdentifier) { + throw new Error('"expo.ios.bundleIdentifier" field is required in your app.json'); + } + const appLookupParams = { + accountName: ctx.manifest.owner ?? ctx.user.username, + projectName: ctx.manifest.slug, + bundleIdentifier, + }; + await runCredentialsManager(ctx, new SetupIosBuildCredentialsFromLocal(appLookupParams)); + } +} diff --git a/packages/expo-cli/src/commands/eas-build/index.ts b/packages/expo-cli/src/commands/eas-build/index.ts index 7832bf430c..db8da792bf 100644 --- a/packages/expo-cli/src/commands/eas-build/index.ts +++ b/packages/expo-cli/src/commands/eas-build/index.ts @@ -3,6 +3,7 @@ import fs from 'fs-extra'; import path from 'path'; import buildAction from './build/action'; +import credentialsSyncAction from './credentialsSync/action'; import statusAction from './status/action'; export default function (program: Command) { @@ -12,6 +13,11 @@ export default function (program: Command) { return; } + program + .command('eas:credentials:sync [project-dir]') + .description('Update credentials.json with credentials stored on Expo servers') + .asyncActionProjectDir(credentialsSyncAction, { checkConfig: true }); + program .command('eas:build [project-dir]') .description('Build an app binary for your project.') diff --git a/packages/expo-cli/src/credentials/local/index.ts b/packages/expo-cli/src/credentials/local/index.ts index 5724560094..8ff4a893f6 100644 --- a/packages/expo-cli/src/credentials/local/index.ts +++ b/packages/expo-cli/src/credentials/local/index.ts @@ -1 +1,2 @@ export { default as credentialsJson } from './credentialsJson'; +export { updateLocalCredentialsJsonAsync } from './update'; diff --git a/packages/expo-cli/src/credentials/local/update.ts b/packages/expo-cli/src/credentials/local/update.ts new file mode 100644 index 0000000000..94b86906fd --- /dev/null +++ b/packages/expo-cli/src/credentials/local/update.ts @@ -0,0 +1,157 @@ +import fs from 'fs-extra'; +import path from 'path'; +import prompts from 'prompts'; + +import log from '../../log'; +import { Context } from '../context'; + +type Platform = 'android' | 'ios' | 'all'; + +export async function updateLocalCredentialsJsonAsync(projectDir: string, platform: Platform) { + const ctx = new Context(); + await ctx.init(projectDir); + if (!ctx.hasProjectContext) { + throw new Error('project context is required'); // should be checked earlier + } + if (['all', 'android'].includes(platform)) { + log('Updating Android credentials in credentials.json'); + await updateAndroidAsync(ctx); + } + if (['all', 'ios'].includes(platform)) { + log('Updating iOS credentials in credentials.json'); + await updateIosAsync(ctx); + } +} + +async function updateAndroidAsync(ctx: Context) { + const credentialsJsonFilePath = path.join(ctx.projectDir, 'credentials.json'); + let rawCredentialsJsonObject: any = {}; + if (await fs.pathExists(credentialsJsonFilePath)) { + try { + const rawFile = await fs.readFile(credentialsJsonFilePath); + rawCredentialsJsonObject = JSON.parse(rawFile.toString()); + } catch (error) { + log.error(`There was an error while reading credentials.json [${error}]`); + log.error('Make sure that file is correct (or remove it) and rerun this command.'); + throw error; + } + } + const experienceName = `@${ctx.manifest.owner || ctx.user.username}/${ctx.manifest.slug}`; + const keystore = await ctx.android.fetchKeystore(experienceName); + if (!keystore) { + throw new Error('There are no credentials configured for this project on Expo servers'); + } + + const isKeystoreComplete = + keystore.keystore && keystore.keystorePassword && keystore.keyPassword && keystore.keyAlias; + + if (!isKeystoreComplete) { + const { confirm } = await prompts({ + type: 'confirm', + name: 'confirm', + message: + 'Credentials on Expo servers might be invalid or incomplete. Are you sure you want to continue?', + }); + if (!confirm) { + log.warn('Aborting...'); + return; + } + } + + const keystorePath = + rawCredentialsJsonObject?.android?.keystorePath ?? './android/keystores/keystore.jks'; + await _updateFileAsync(ctx.projectDir, keystorePath, keystore.keystore); + + rawCredentialsJsonObject.android = { + keystore: { + keystorePath, + keystorePassword: keystore.keystorePassword, + keyAlias: keystore.keyAlias, + keyPassword: keystore.keyPassword, + }, + }; + await fs.writeJson(credentialsJsonFilePath, rawCredentialsJsonObject, { + spaces: 2, + }); +} + +async function updateIosAsync(ctx: Context) { + const credentialsJsonFilePath = path.join(ctx.projectDir, 'credentials.json'); + let rawCredentialsJsonObject: any = {}; + if (await fs.pathExists(credentialsJsonFilePath)) { + try { + const rawFile = await fs.readFile(credentialsJsonFilePath); + rawCredentialsJsonObject = JSON.parse(rawFile.toString()); + } catch (error) { + log.error(`There was an error while reading credentials.json [${error}]`); + log.error('Make sure that file is correct (or remove it) and rerun this command.'); + throw error; + } + } + + const bundleIdentifier = ctx.manifest.ios?.bundleIdentifier; + if (!bundleIdentifier) { + throw new Error('"expo.ios.bundleIdentifier" field is required in your app.json'); + } + const appLookupParams = { + accountName: ctx.manifest.owner ?? ctx.user.username, + projectName: ctx.manifest.slug, + bundleIdentifier, + }; + const pprofilePath = + rawCredentialsJsonObject?.ios?.provisioningProfilePath ?? './ios/certs/profile.mobileprovision'; + const distCertPath = + rawCredentialsJsonObject?.ios?.distributionCertificate.path ?? './ios/certs/dist-cert.p12'; + const appCredentials = await ctx.ios.getAppCredentials(appLookupParams); + const distCredentials = await ctx.ios.getDistCert(appLookupParams); + if (!appCredentials && !distCredentials) { + throw new Error('There are no credentials configured for this project on Expo servers'); + } + + const areCredentialsComplete = + appCredentials?.credentials?.provisioningProfile && + distCredentials?.certP12 && + distCredentials?.certPassword; + + if (!areCredentialsComplete) { + const { confirm } = await prompts({ + type: 'confirm', + name: 'confirm', + message: + 'Credentials on Expo servers might be invalid or incomplete. Are you sure you want to continue?', + }); + if (!confirm) { + log.warn('Aborting...'); + return; + } + } + + await _updateFileAsync( + ctx.projectDir, + pprofilePath, + appCredentials?.credentials?.provisioningProfile + ); + await _updateFileAsync(ctx.projectDir, distCertPath, distCredentials?.certP12); + + rawCredentialsJsonObject.ios = { + provisioningProfilePath: pprofilePath, + distributionCertificate: { + path: distCertPath, + password: distCredentials?.certPassword, + }, + }; + await fs.writeJson(credentialsJsonFilePath, rawCredentialsJsonObject, { + spaces: 2, + }); +} + +async function _updateFileAsync(projectDir: string, filePath: string, base64Data?: string) { + const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectDir, filePath); + if (await fs.pathExists(absolutePath)) { + await fs.remove(absolutePath); + } + if (base64Data) { + await fs.mkdirp(path.dirname(filePath)); + await fs.writeFile(filePath, Buffer.from(base64Data, 'base64')); + } +} diff --git a/packages/expo-cli/src/credentials/views/SetupAndroidKeystore.ts b/packages/expo-cli/src/credentials/views/SetupAndroidKeystore.ts index 4ad7c6c694..b2c344d417 100644 --- a/packages/expo-cli/src/credentials/views/SetupAndroidKeystore.ts +++ b/packages/expo-cli/src/credentials/views/SetupAndroidKeystore.ts @@ -2,6 +2,7 @@ import commandExists from 'command-exists'; import log from '../../log'; import { Context, IView } from '../context'; +import { credentialsJson } from '../local'; import { UpdateKeystore } from './AndroidKeystore'; interface Options { @@ -40,6 +41,24 @@ export class SetupAndroidKeystore implements IView { } } +export class SetupAndroidBuildCredentialsFromLocal implements IView { + constructor(private experienceName: string) {} + + async open(ctx: Context): Promise { + let localCredentials; + try { + localCredentials = await credentialsJson.readAndroidAsync(ctx.projectDir); + } catch (error) { + log.error( + 'Reading credentials from credentials.json failed. Make sure that file is correct and all credentials are present.' + ); + throw error; + } + await ctx.android.updateKeystore(this.experienceName, localCredentials.keystore); + return null; + } +} + async function keytoolCommandExists(): Promise { try { await commandExists('keytool'); diff --git a/packages/expo-cli/src/credentials/views/SetupIosBuildCredentials.ts b/packages/expo-cli/src/credentials/views/SetupIosBuildCredentials.ts index 7b303536a5..1c312f66a1 100644 --- a/packages/expo-cli/src/credentials/views/SetupIosBuildCredentials.ts +++ b/packages/expo-cli/src/credentials/views/SetupIosBuildCredentials.ts @@ -3,10 +3,12 @@ import chalk from 'chalk'; import CommandError from '../../CommandError'; import * as appleApi from '../../appleApi'; import log from '../../log'; -import prompt from '../../prompts'; +import prompts from '../../prompts'; import { AppLookupParams } from '../api/IosApi'; import { Context, IView } from '../context'; +import { credentialsJson } from '../local'; import { runCredentialsManager } from '../route'; +import { readAppleTeam } from '../utils/provisioningProfile'; import { SetupIosDist } from './SetupIosDist'; import { SetupIosProvisioningProfile } from './SetupIosProvisioningProfile'; @@ -56,7 +58,7 @@ export class SetupIosBuildCredentials implements IView { return; } - const { confirm } = await prompt([ + const { confirm } = await prompts([ { type: 'confirm', name: 'confirm', @@ -74,3 +76,62 @@ export class SetupIosBuildCredentials implements IView { } } } + +export class SetupIosBuildCredentialsFromLocal implements IView { + constructor(private app: AppLookupParams) {} + + async open(ctx: Context): Promise { + let localCredentials; + try { + localCredentials = await credentialsJson.readIosAsync(ctx.projectDir); + } catch (error) { + log.error( + 'Reading credentials from credentials.json failed. Make sure that file is correct and all credentials are present.' + ); + throw error; + } + + const team = await readAppleTeam(localCredentials.provisioningProfile); + await ctx.ios.updateProvisioningProfile(this.app, { + ...team, + provisioningProfile: localCredentials.provisioningProfile, + }); + const credentials = await ctx.ios.getAllCredentials(this.app.accountName); + const distCert = await ctx.ios.getDistCert(this.app); + const appsUsingCert = distCert?.id + ? (credentials.appCredentials || []).filter(cred => cred.distCredentialsId === distCert.id) + : []; + + const appInfo = `@${this.app.accountName}/${this.app.projectName} (${this.app.bundleIdentifier})`; + const newDistCert = { + ...team, + certP12: localCredentials.distributionCertificate.certP12, + certPassword: localCredentials.distributionCertificate.certPassword, + }; + + if (appsUsingCert.length > 1 && distCert?.id) { + const { update } = await prompts({ + type: 'select', + name: 'update', + message: + 'Current distribution certificate is used by multiple apps. Do you want to update all of them?', + choices: [ + { title: 'Update all apps', value: 'all' }, + { title: `Update only ${appInfo}`, value: 'app' }, + ], + }); + if (update === 'all') { + await ctx.ios.updateDistCert(distCert.id, this.app.accountName, newDistCert); + } else { + const createdDistCert = await ctx.ios.createDistCert(this.app.accountName, newDistCert); + await ctx.ios.useDistCert(this.app, createdDistCert.id); + } + } else if (distCert?.id) { + await ctx.ios.updateDistCert(distCert.id, this.app.accountName, newDistCert); + } else { + const createdDistCert = await ctx.ios.createDistCert(this.app.accountName, newDistCert); + await ctx.ios.useDistCert(this.app, createdDistCert.id); + } + return null; + } +}