diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e90a8a82..c8dc4291ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- Add `eas go` command for creating custom Expo Go builds. ([#3376](https://github.com/expo/eas-cli/pull/3376) by [@tchayen](https://github.com/tchayen)) - Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts new file mode 100644 index 0000000000..11f07caee2 --- /dev/null +++ b/packages/eas-cli/src/commands/go.ts @@ -0,0 +1,587 @@ +import { App, User, UserRole } from '@expo/apple-utils'; +import spawnAsync from '@expo/spawn-async'; +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import { Analytics } from '../analytics/AnalyticsManager'; +import EasCommand from '../commandUtils/EasCommand'; +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync'; +import { CredentialsContext } from '../credentials/context'; +import { AppStoreApiKeyPurpose } from '../credentials/ios/actions/AscApiKeyUtils'; +import { getAppFromContextAsync } from '../credentials/ios/actions/BuildCredentialsUtils'; +import { SetUpAscApiKey } from '../credentials/ios/actions/SetUpAscApiKey'; +import { SetUpBuildCredentials } from '../credentials/ios/actions/SetUpBuildCredentials'; +import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExists'; +import { Target } from '../credentials/ios/types'; +import { WorkflowJobStatus, WorkflowProjectSourceType, WorkflowRunStatus } from '../graphql/generated'; +import { AppMutation } from '../graphql/mutations/AppMutation'; +import { WorkflowRunMutation } from '../graphql/mutations/WorkflowRunMutation'; +import { AppQuery } from '../graphql/queries/AppQuery'; +import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery'; +import Log, { learnMore } from '../log'; +import { ora } from '../ora'; +import { getPrivateExpoConfigAsync } from '../project/expoConfig'; +import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync'; +import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync'; +import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync'; +import { Actor, getActorDisplayName } from '../user/User'; +import { sleepAsync } from '../utils/promise'; +import { resolveVcsClient } from '../vcs'; +import { Client as VcsClient } from '../vcs/vcs'; + +// Expo Go release info - update when releasing a new version +const EXPO_GO_SDK_VERSION = '55'; +const EXPO_GO_APP_VERSION = '55.0.11'; +const EXPO_GO_BUILD_NUMBER = '1017799'; + +const TESTFLIGHT_GROUP_NAME = 'Team (Expo)'; + +async function setupTestFlightAsync(ascApp: App): Promise { + // Create or get TestFlight group + let group; + for (let attempt = 0; attempt < 10; attempt++) { + try { + const groups = await ascApp.getBetaGroupsAsync({ + query: { includes: ['betaTesters'] }, + }); + + group = groups.find( + g => g.attributes.isInternalGroup && g.attributes.name === TESTFLIGHT_GROUP_NAME + ); + + if (!group) { + group = await ascApp.createBetaGroupAsync({ + name: TESTFLIGHT_GROUP_NAME, + isInternalGroup: true, + hasAccessToAllBuilds: true, + }); + } + break; + } catch (error: any) { + // Apple returns this error when the app isn't ready yet + if (error?.data?.errors?.some((e: any) => e.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID')) { + if (attempt < 9) { + await new Promise(resolve => setTimeout(resolve, 10000)); + continue; + } + } + throw error; + } + } + + if (!group) { + throw new Error('Failed to create TestFlight group'); + } + + const users = await User.getAsync(ascApp.context); + const admins = users.filter(u => u.attributes.roles?.includes(UserRole.ADMIN)); + + const existingEmails = new Set( + group.attributes.betaTesters?.map((t: any) => t.attributes.email?.toLowerCase()) ?? [] + ); + + const newTesters = admins + .filter(u => u.attributes.email && !existingEmails.has(u.attributes.email.toLowerCase())) + .map(u => ({ + email: u.attributes.email!, + firstName: u.attributes.firstName ?? '', + lastName: u.attributes.lastName ?? '', + })); + + if (newTesters.length > 0) { + await group.createBulkBetaTesterAssignmentsAsync(newTesters); + } +} + +/* eslint-disable no-console */ +async function withSuppressedOutputAsync(fn: () => Promise): Promise { + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + + let capturedOutput = ''; + + const capture = (chunk: any): boolean => { + if (typeof chunk === 'string') { + capturedOutput += chunk; + } + return true; + }; + + // Only suppress stdout, not stderr — ora writes spinner frames to stderr and + // patching it would freeze the spinner animation during suppressed async work. + process.stdout.write = capture as any; + console.log = () => {}; + console.error = () => {}; + console.warn = () => {}; + + try { + return await fn(); + } catch (error) { + process.stdout.write = originalStdoutWrite; + console.log = originalConsoleLog; + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + + if (capturedOutput) { + originalConsoleLog(capturedOutput); + } + throw error; + } finally { + process.stdout.write = originalStdoutWrite; + console.log = originalConsoleLog; + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + } +} +/* eslint-enable no-console */ + + +export default class Go extends EasCommand { + static override description = 'Create a custom Expo Go and submit to TestFlight'; + + static override flags = { + 'bundle-id': Flags.string({ + description: 'iOS bundle identifier (auto-generated if not provided)', + required: false, + }), + name: Flags.string({ + description: 'App name', + default: 'My Expo Go', + }), + credentials: Flags.boolean({ + description: 'Interactively select credentials (default: auto-select)', + default: false, + }), + }; + + static override contextDefinition = { + ...this.ContextOptions.LoggedIn, + ...this.ContextOptions.Analytics, + }; + + async runAsync(): Promise { + Log.log( + chalk.bold( + `Creating your personal Expo Go and deploying to TestFlight. ${learnMore('https://expo.fyi/personal-expo-go')}` + ) + ); + + const { flags } = await this.parse(Go); + + const spinner = ora('Logging in to Expo...').start(); + const { + loggedIn: { actor, graphqlClient }, + analytics, + } = await this.getContextAsync(Go, { + nonInteractive: false, + }); + spinner.succeed(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`); + + const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor); + const appName = flags.name ?? 'My Expo Go'; + const slug = bundleId.split('.').pop() || 'my-expo-go'; + + const projectDir = path.join(os.tmpdir(), `eas-go-${slug}`); + await fs.emptyDir(projectDir); + + const originalCwd = process.cwd(); + process.chdir(projectDir); + + const setupSpinner = ora('Creating project...').start(); + + // Step 1: Create project files and initialize git (silently) + try { + await withSuppressedOutputAsync(async () => { + await this.createProjectFilesAsync(projectDir, bundleId, appName); + await this.initGitRepoAsync(projectDir); + }); + const vcsClient = resolveVcsClient(); + + // Step 2: Create/link EAS project (silently) + const projectId = await withSuppressedOutputAsync(() => + this.ensureEasProjectAsync(graphqlClient, actor, projectDir, bundleId) + ); + + // Step 3: Set up iOS credentials and create App Store Connect app + const ascApp = await this.setupCredentialsAsync( + projectDir, + projectId, + bundleId, + appName, + graphqlClient, + actor, + analytics, + vcsClient, + flags.credentials, + () => { + setupSpinner.stop(); + Log.markFreshLine(); + } + ); + + // Step 4: Start workflow and monitor progress + const { workflowUrl, workflowRunId } = await this.runWorkflowAsync( + graphqlClient, + projectDir, + projectId, + actor, + vcsClient + ); + Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`); + + const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId); + if (status === WorkflowRunStatus.Failure) { + throw new Error('Workflow failed'); + } else if (status === WorkflowRunStatus.Canceled) { + throw new Error('Workflow was canceled'); + } + + // Step 5: Set up TestFlight group (silently) + try { + await setupTestFlightAsync(ascApp); + } catch { + // Non-fatal: TestFlight group setup failure shouldn't block the user + } + + Log.newLine(); + Log.succeed( + `Done! Your custom Expo Go has been submitted to TestFlight. ${learnMore( + `https://appstoreconnect.apple.com/apps/${ascApp.id}/testflight`, + { learnMoreMessage: 'Open it on App Store Connect' } + )}` + ); + Log.log( + `App Store processing may take several minutes to complete. ${learnMore( + 'https://expo.fyi/personal-expo-go', + { learnMoreMessage: 'Learn more about Expo Go on TestFlight' } + )}` + ); + + await fs.remove(projectDir); + } catch (error) { + Log.gray(`Project files preserved for debugging: ${projectDir}`); + throw error; + } finally { + process.chdir(originalCwd); + } + } + + private generateBundleId(actor: Actor): string { + const username = actor.accounts[0].name; + // Sanitize username for bundle ID: only alphanumeric and hyphens allowed + const sanitizedUsername = username + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens + // Deterministic bundle ID per user + SDK version (reuses same ASC app) + return `com.${sanitizedUsername || 'app'}.expogo${EXPO_GO_SDK_VERSION}`; + } + + private async createProjectFilesAsync( + projectDir: string, + bundleId: string, + appName: string + ): Promise { + const slug = bundleId.split('.').pop() || 'custom-expo-go'; + const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`; + + const appJson = { + expo: { + name: appName, + slug, + version: EXPO_GO_APP_VERSION, + ios: { + bundleIdentifier: bundleId, + buildNumber: EXPO_GO_BUILD_NUMBER, + config: { + usesNonExemptEncryption: false, + }, + }, + extra: { + eas: { + build: { + experimental: { + ios: { + appExtensions: [ + { + targetName: 'ExpoNotificationServiceExtension', + bundleIdentifier: extensionBundleId, + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const easJson = { + cli: { + version: '>= 5.0.0', + }, + build: { + production: { + distribution: 'store', + credentialsSource: 'remote', + }, + }, + submit: { + production: { + ios: {}, + }, + }, + }; + + const packageJson = { + name: slug, + version: '1.0.0', + dependencies: { + expo: '~54.0.0', + }, + }; + + await fs.writeJson(path.join(projectDir, 'app.json'), appJson, { spaces: 2 }); + await fs.writeJson(path.join(projectDir, 'eas.json'), easJson, { spaces: 2 }); + await fs.writeJson(path.join(projectDir, 'package.json'), packageJson, { spaces: 2 }); + + await spawnAsync('npm', ['install'], { cwd: projectDir }); + } + + private async initGitRepoAsync(projectDir: string): Promise { + await spawnAsync('git', ['init'], { cwd: projectDir }); + await spawnAsync('git', ['add', '.'], { cwd: projectDir }); + await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectDir }); + } + + private async ensureEasProjectAsync( + graphqlClient: ExpoGraphqlClient, + actor: Actor, + projectDir: string, + bundleId: string + ): Promise { + const slug = bundleId.split('.').pop() || 'custom-expo-go'; + const account = actor.accounts[0]; + + const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync( + graphqlClient, + account.name, + slug + ); + + if (existingProjectId) { + await saveProjectIdToAppConfigAsync(projectDir, existingProjectId); + return existingProjectId; + } + + const projectId = await AppMutation.createAppAsync(graphqlClient, { + accountId: account.id, + projectName: slug, + }); + await saveProjectIdToAppConfigAsync(projectDir, projectId); + return projectId; + } + + private async setupCredentialsAsync( + projectDir: string, + projectId: string, + bundleId: string, + appName: string, + graphqlClient: ExpoGraphqlClient, + actor: Actor, + analytics: Analytics, + vcsClient: VcsClient, + customizeCreds: boolean, + onBeforeAppleAuth?: () => void + ): Promise { + const exp = await getPrivateExpoConfigAsync(projectDir); + const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`; + + const credentialsCtx = new CredentialsContext({ + projectInfo: { exp, projectId }, + nonInteractive: false, + autoAcceptCredentialReuse: !customizeCreds, + projectDir, + user: actor, + graphqlClient, + analytics, + vcsClient, + easJsonCliConfig: { + promptToConfigurePushNotifications: false, + }, + }); + + onBeforeAppleAuth?.(); + const userAuthCtx = await credentialsCtx.appStore.ensureUserAuthenticatedAsync(); + + const app = await getAppFromContextAsync(credentialsCtx); + + const targets: Target[] = [ + { + targetName: exp.slug, + bundleIdentifier: bundleId, + entitlements: {}, + }, + { + targetName: 'ExpoNotificationServiceExtension', + bundleIdentifier: extensionBundleId, + parentBundleIdentifier: bundleId, + entitlements: {}, + }, + ]; + + const ascApp = await withSuppressedOutputAsync(async () => { + await new SetUpBuildCredentials({ + app, + targets, + distribution: 'store', + }).runAsync(credentialsCtx); + + const appLookupParams = { + ...app, + bundleIdentifier: bundleId, + }; + await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync( + credentialsCtx + ); + + const ascAppResult = await ensureAppExistsAsync(userAuthCtx, { + name: appName, + bundleIdentifier: bundleId, + }); + + const easJsonPath = path.join(projectDir, 'eas.json'); + const easJson = await fs.readJson(easJsonPath); + easJson.submit = easJson.submit || {}; + easJson.submit.production = easJson.submit.production || {}; + easJson.submit.production.ios = easJson.submit.production.ios || {}; + easJson.submit.production.ios.ascAppId = ascAppResult.id; + await fs.writeJson(easJsonPath, easJson, { spaces: 2 }); + + await spawnAsync('git', ['add', 'eas.json'], { cwd: projectDir }); + await spawnAsync('git', ['commit', '-m', 'Add ascAppId to eas.json'], { cwd: projectDir }); + + return ascAppResult; + }); + + return ascApp; + } + + private async runWorkflowAsync( + graphqlClient: ExpoGraphqlClient, + projectDir: string, + projectId: string, + actor: Actor, + vcsClient: VcsClient + ): Promise<{ workflowUrl: string; workflowRunId: string }> { + const account = actor.accounts[0]; + + const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } = + await withSuppressedOutputAsync(async () => { + const { projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({ + graphqlClient, + vcsClient, + accountId: account.id, + }); + + const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({ + graphqlClient, + accountId: account.id, + filePath: path.join(projectDir, 'eas.json'), + maxSizeBytes: 1024 * 1024, + }); + + const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({ + graphqlClient, + accountId: account.id, + filePath: path.join(projectDir, 'package.json'), + maxSizeBytes: 1024 * 1024, + }); + + return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey }; + }); + + const { id: workflowRunId } = + await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(graphqlClient, { + appId: projectId, + projectSource: { + type: WorkflowProjectSourceType.Gcs, + projectArchiveBucketKey, + easJsonBucketKey, + packageJsonBucketKey, + projectRootDirectory: '.', + }, + }); + + const app = await AppQuery.byIdAsync(graphqlClient, projectId); + const workflowUrl = `https://expo.dev/accounts/${account.name}/projects/${app.slug}/workflows/${workflowRunId}`; + + return { workflowUrl, workflowRunId }; + } + + private async monitorWorkflowJobsAsync( + graphqlClient: ExpoGraphqlClient, + workflowRunId: string + ): Promise { + const buildSpinner = ora('Building Expo Go').start(); + let submitSpinner: ReturnType | null = null; + let buildCompleted = false; + let failedFetchesCount = 0; + + while (true) { + try { + const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, { + useCache: false, + }); + failedFetchesCount = 0; + + const repackJob = workflowRun.jobs.find(j => j.name === 'Repack Expo Go'); + const submitJob = workflowRun.jobs.find(j => j.name === 'Submit to TestFlight'); + + if (!buildCompleted) { + if (repackJob?.status === WorkflowJobStatus.Success) { + buildSpinner.succeed('Built Expo Go'); + buildCompleted = true; + } else if ( + repackJob?.status === WorkflowJobStatus.Failure || + repackJob?.status === WorkflowJobStatus.Canceled + ) { + buildSpinner.fail('Build failed'); + return WorkflowRunStatus.Failure; + } + } + + if (buildCompleted && submitSpinner === null && submitJob) { + submitSpinner = ora('Submitting to TestFlight').start(); + } + + if (workflowRun.status === WorkflowRunStatus.Success) { + submitSpinner?.stop(); + return WorkflowRunStatus.Success; + } else if (workflowRun.status === WorkflowRunStatus.Failure) { + buildSpinner.stop(); + submitSpinner?.fail('Submission failed'); + return WorkflowRunStatus.Failure; + } else if (workflowRun.status === WorkflowRunStatus.Canceled) { + buildSpinner.stop(); + submitSpinner?.stop(); + return WorkflowRunStatus.Canceled; + } + } catch { + failedFetchesCount++; + if (failedFetchesCount > 6) { + buildSpinner.fail(); + submitSpinner?.fail(); + throw new Error('Failed to fetch the workflow run status 6 times in a row'); + } + } + + await sleepAsync(10 * 1000); + } + } +} diff --git a/packages/eas-cli/src/credentials/context.ts b/packages/eas-cli/src/credentials/context.ts index 340c63b915..0f7c092918 100644 --- a/packages/eas-cli/src/credentials/context.ts +++ b/packages/eas-cli/src/credentials/context.ts @@ -25,6 +25,7 @@ export class CredentialsContext { public readonly appStore = new AppStoreApi(); public readonly ios = IosGraphqlClient; public readonly nonInteractive: boolean; + public readonly autoAcceptCredentialReuse: boolean; public readonly freezeCredentials: boolean = false; public readonly projectDir: string; public readonly user: Actor; @@ -50,6 +51,7 @@ export class CredentialsContext { analytics: Analytics; vcsClient: Client; freezeCredentials?: boolean; + autoAcceptCredentialReuse?: boolean; env?: Env; } ) { @@ -60,6 +62,7 @@ export class CredentialsContext { this.analytics = options.analytics; this.vcsClient = options.vcsClient; this.nonInteractive = options.nonInteractive ?? false; + this.autoAcceptCredentialReuse = options.autoAcceptCredentialReuse ?? false; this.projectInfo = options.projectInfo; this.freezeCredentials = options.freezeCredentials ?? false; this.usesBroadcastPushNotifications = diff --git a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts index 826020b7a9..5b6915115d 100644 --- a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts +++ b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts @@ -66,6 +66,11 @@ export async function provideOrGenerateAscApiKeyAsync( throw new Error(`A new App Store Connect API Key cannot be created in non-interactive mode.`); } + // When auto-accepting credentials, always auto-generate without asking for user input + if (ctx.autoAcceptCredentialReuse) { + return await generateAscApiKeyAsync(ctx, purpose); + } + const userProvided = await promptForAscApiKeyAsync(ctx); if (!userProvided) { return await generateAscApiKeyAsync(ctx, purpose); diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts index 463197f053..bb6d782551 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts @@ -65,6 +65,12 @@ export class SetUpAscApiKey { return await new AssignAscApiKey(this.app).runAsync(ctx, maybeAutoselectedKey, this.purpose); } + // When auto-accepting credentials and no valid key was found, generate a new one without prompting + if (ctx.autoAcceptCredentialReuse) { + const ascApiKey = await new CreateAscApiKey(this.app.account).runAsync(ctx, this.purpose); + return await new AssignAscApiKey(this.app).runAsync(ctx, ascApiKey, this.purpose); + } + const availableChoices = keysForAccount.length === 0 ? this.choices.filter(choice => choice.value !== SetupAscApiKeyChoice.USE_EXISTING) @@ -90,9 +96,11 @@ export class SetUpAscApiKey { return null; } const [autoselectedKey] = sortAscApiKeysByUpdatedAtDesc(validKeys); - const useAutoselected = await confirmAsync({ - message: `Reuse this App Store Connect API Key?\n${formatAscApiKey(autoselectedKey)}`, - }); + const useAutoselected = + ctx.autoAcceptCredentialReuse || + (await confirmAsync({ + message: `Reuse this App Store Connect API Key?\n${formatAscApiKey(autoselectedKey)}`, + })); if (useAutoselected) { Log.log(`Using App Store Connect API Key with ID ${autoselectedKey.keyIdentifier}`); diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts index 2242ccc2f4..45dd6cd8bc 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts @@ -117,11 +117,13 @@ export class SetUpDistributionCertificate { const validDistCerts = await this.getValidDistCertsAsync(ctx); const autoselectedDistCert = validDistCerts[0]; - const useAutoselected = await confirmAsync({ - message: `Reuse this distribution certificate?\n${formatDistributionCertificate( - autoselectedDistCert - )}`, - }); + const useAutoselected = + ctx.autoAcceptCredentialReuse || + (await confirmAsync({ + message: `Reuse this distribution certificate?\n${formatDistributionCertificate( + autoselectedDistCert + )}`, + })); if (useAutoselected) { Log.log( diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts index 00f90c2476..e5d68bcdc7 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts @@ -142,7 +142,7 @@ export class SetUpProvisioningProfile { } const isNonInteractiveOrUserDidConfirmAsync = async (): Promise => { - if (ctx.nonInteractive) { + if (ctx.nonInteractive || ctx.autoAcceptCredentialReuse) { return true; } return await confirmAsync({ diff --git a/packages/eas-cli/src/graphql/mutations/WorkflowRunMutation.ts b/packages/eas-cli/src/graphql/mutations/WorkflowRunMutation.ts index 2ca8bce4aa..f351f9c0d7 100644 --- a/packages/eas-cli/src/graphql/mutations/WorkflowRunMutation.ts +++ b/packages/eas-cli/src/graphql/mutations/WorkflowRunMutation.ts @@ -9,6 +9,7 @@ import { CreateWorkflowRunFromGitRefMutationVariables, CreateWorkflowRunMutation, CreateWorkflowRunMutationVariables, + WorkflowProjectSourceInput, WorkflowRevisionInput, WorkflowRunInput, } from '../generated'; @@ -103,6 +104,41 @@ export namespace WorkflowRunMutation { return { id: data.workflowRun.createWorkflowRunFromGitRef.id }; } + export async function createExpoGoRepackWorkflowRunAsync( + graphqlClient: ExpoGraphqlClient, + { + appId, + projectSource, + }: { + appId: string; + projectSource: WorkflowProjectSourceInput; + } + ): Promise<{ id: string }> { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { workflowRun: { createExpoGoRepackWorkflowRun: { id: string } } }, + { appId: string; projectSource: WorkflowProjectSourceInput } + >( + gql` + mutation CreateExpoGoRepackWorkflowRun( + $appId: ID! + $projectSource: WorkflowProjectSourceInput! + ) { + workflowRun { + createExpoGoRepackWorkflowRun(appId: $appId, projectSource: $projectSource) { + id + } + } + } + `, + { appId, projectSource } + ) + .toPromise() + ); + return { id: data.workflowRun.createExpoGoRepackWorkflowRun.id }; + } + export async function cancelWorkflowRunAsync( graphqlClient: ExpoGraphqlClient, { diff --git a/packages/eas-cli/src/log.ts b/packages/eas-cli/src/log.ts index bc6c2a18c1..4fdd092671 100644 --- a/packages/eas-cli/src/log.ts +++ b/packages/eas-cli/src/log.ts @@ -26,6 +26,15 @@ export default class Log { } } + /** + * Signal that the cursor is already at the start of a fresh line (e.g. after + * a spinner clears itself), so that the next `addNewLineIfNone()` call knows + * it doesn't need to emit an extra blank line. + */ + public static markFreshLine(): void { + Log.isLastLineNewLine = true; + } + public static error(...args: any[]): void { Log.consoleLog(...Log.withTextColor(args, chalk.red)); }