diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc737e19c..d535d6c7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features +- Sanitize and generate names for EAS Submit to prevent failures due to invalid characters or taken names. ([#2842](https://github.com/expo/eas-cli/pull/2842) by [@evanbacon](https://github.com/evanbacon)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/eas-cli/src/credentials/ios/appstore/__tests__/ensureAppExists-test.ts b/packages/eas-cli/src/credentials/ios/appstore/__tests__/ensureAppExists-test.ts new file mode 100644 index 0000000000..928d377e4e --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/appstore/__tests__/ensureAppExists-test.ts @@ -0,0 +1,168 @@ +import { Session } from '@expo/apple-utils'; +import nock from 'nock'; + +import { createAppAsync } from '../ensureAppExists'; + +const FIXTURE_SUCCESS = { + data: { + type: 'apps', + id: '6741087677', + attributes: { + name: 'expo (xxx)', + bundleId: 'com.bacon.jan27.x', + }, + }, +}; + +const FIXTURE_INVALID_NAME = { + errors: [ + { + id: 'b3e7ca18-e4ce-4e55-83ce-8fff35dbaeca', + status: '409', + code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.INVALID_CHARACTERS', + title: 'An attribute value has invalid characters.', + detail: + 'App Name contains certain Unicode symbols, emoticons, diacritics, special characters, or private use characters that are not permitted.', + source: { + pointer: '/included/1/name', + }, + }, + ], +}; + +const FIXTURE_ALREADY_USED_ON_ACCOUNT = { + errors: [ + { + id: 'b91aefc5-0e94-48d9-8613-5b1a464a20f0', + status: '409', + code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE.SAME_ACCOUNT', + title: + 'The provided entity includes an attribute with a value that has already been used on this account.', + detail: + 'The app name you entered is already being used for another app in your account. If you would like to use the name for this app you will need to submit an update to your other app to change the name, or remove it from App Store Connect.', + source: { + pointer: '/included/1/name', + }, + }, + ], +}; + +const FIXTURE_ALREADY_USED_ON_ANOTHER_ACCOUNT = { + errors: [ + { + id: '72b960f2-9e51-4f19-8d83-7cc08d42fec4', + status: '409', + code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE.DIFFERENT_ACCOUNT', + title: + 'The provided entity includes an attribute with a value that has already been used on a different account.', + detail: + '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.', + source: { + pointer: '/included/1/name', + }, + }, + ], +}; + +const MOCK_CONTEXT = { + providerId: 1337, + teamId: 'test-team-id', + token: 'test-token', +}; + +beforeAll(async () => { + // Mock setup cookies API calls. + nock('https://appstoreconnect.apple.com') + .get(`/olympus/v1/session`) + .reply(200, { + provider: { + providerId: 1337, + publicProviderId: 'xxx-xxx-xxx-xxx-xxx', + name: 'Evan Bacon', + contentTypes: ['SOFTWARE'], + subType: 'INDIVIDUAL', + }, + }); + + await Session.fetchCurrentSessionInfoAsync(); +}); + +function getNameFromBody(body: any): any { + return body.included.find((item: any) => item.id === '${new-appInfoLocalization-id}')?.attributes + ?.name; +} + +it('asserts invalid name cases', async () => { + const scope = nock('https://appstoreconnect.apple.com') + .post(`/iris/v1/apps`, body => { + expect(getNameFromBody(body)).toBe('Expo ๐Ÿš€'); + + return true; + }) + .reply(409, FIXTURE_INVALID_NAME); + + // Already used on same account + nock('https://appstoreconnect.apple.com') + .post(`/iris/v1/apps`, body => { + expect(getNameFromBody(body)).toBe('Expo -'); + return true; + }) + .reply(409, FIXTURE_ALREADY_USED_ON_ACCOUNT); + + // Already used on different account + nock('https://appstoreconnect.apple.com') + .post(`/iris/v1/apps`, body => { + expect(getNameFromBody(body)).toMatch(/Expo - \([\w\d]+\)/); + return true; + }) + .reply(409, FIXTURE_ALREADY_USED_ON_ANOTHER_ACCOUNT); + + // Success + nock('https://appstoreconnect.apple.com') + .post(`/iris/v1/apps`, body => { + expect(getNameFromBody(body)).toMatch(/Expo - \([\w\d]+\)/); + return true; + }) + .reply(200, FIXTURE_SUCCESS); + + await createAppAsync(MOCK_CONTEXT, { + bundleId: 'com.bacon.jan27.x', + name: 'Expo ๐Ÿš€', + companyName: 'expo', + }); + + expect(scope.isDone()).toBeTruthy(); +}); + +it('works on first try', async () => { + nock('https://appstoreconnect.apple.com').post(`/iris/v1/apps`).reply(200, FIXTURE_SUCCESS); + + await createAppAsync(MOCK_CONTEXT, { + bundleId: 'com.bacon.jan27.x', + name: 'Expo', + companyName: 'expo', + }); +}); + +it('doubles up entropy', async () => { + nock('https://appstoreconnect.apple.com') + .post(`/iris/v1/apps`) + .reply(409, FIXTURE_ALREADY_USED_ON_ANOTHER_ACCOUNT); + + nock('https://appstoreconnect.apple.com') + .post(`/iris/v1/apps`) + .reply(409, FIXTURE_ALREADY_USED_ON_ANOTHER_ACCOUNT); + + nock('https://appstoreconnect.apple.com') + .post(`/iris/v1/apps`, body => { + expect(getNameFromBody(body)).toMatch(/Expo \([\w\d]+\) \([\w\d]+\)/); + return true; + }) + .reply(200, FIXTURE_SUCCESS); + + await createAppAsync(MOCK_CONTEXT, { + bundleId: 'com.bacon.jan27.x', + name: 'Expo', + companyName: 'expo', + }); +}); diff --git a/packages/eas-cli/src/credentials/ios/appstore/ensureAppExists.ts b/packages/eas-cli/src/credentials/ios/appstore/ensureAppExists.ts index 2a50306533..29bc351f8d 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/ensureAppExists.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/ensureAppExists.ts @@ -1,6 +1,7 @@ -import { App, BundleId } from '@expo/apple-utils'; +import { App, BundleId, RequestContext } from '@expo/apple-utils'; import { JSONObject } from '@expo/json-file'; import chalk from 'chalk'; +import { randomBytes } from 'node:crypto'; import { getRequestContext, isUserAuthCtx } from './authenticate'; import { AuthCtx, UserAuthCtx } from './authenticateTypes'; @@ -188,7 +189,7 @@ export async function ensureAppExistsAsync( /** * **Does not support App Store Connect API (CI).** */ - app = await App.createAsync(context, { + app = await createAppAsync(context, { bundleId: bundleIdentifier, name, primaryLocale: language, @@ -215,3 +216,120 @@ export async function ensureAppExistsAsync( ); return app; } + +function sanitizeName(name: string): string { + return ( + name + // Replace emojis with a `-` + .replace(/[\p{Emoji}]/gu, '-') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + ); +} + +export async function createAppAsync( + context: RequestContext, + props: { + bundleId: string; + name: string; + primaryLocale?: string; + companyName?: string; + sku?: string; + }, + retryCount = 0 +): Promise { + try { + /** + * **Does not support App Store Connect API (CI).** + */ + return await App.createAsync(context, props); + } catch (error) { + if (retryCount >= 5) { + throw error; + } + if (error instanceof Error) { + const handleDuplicateNameErrorAsync = async (): Promise => { + const generatedName = props.name + ` (${randomBytes(3).toString('hex')})`; + Log.warn( + `App name "${props.name}" is already taken. Using generated name "${generatedName}" which can be changed later from https://appstoreconnect.apple.com.` + ); + // Sanitize the name and try again. + return await createAppAsync( + context, + { + ...props, + name: generatedName, + }, + retryCount + 1 + ); + }; + + if (isAppleError(error)) { + // New error class that is thrown when the name is already taken but belongs to you. + if ( + error.data.errors.some( + e => + e.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE.SAME_ACCOUNT' || + e.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE.DIFFERENT_ACCOUNT' + ) + ) { + return await handleDuplicateNameErrorAsync(); + } + } + + if ('code' in error && typeof error.code === 'string') { + if ( + // Name is invalid + error.code === 'APP_CREATE_NAME_INVALID' + // 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 + ) { + const sanitizedName = sanitizeName(props.name); + if (sanitizedName === props.name) { + throw error; + } + Log.warn( + `App name "${props.name}" contains invalid characters. Using sanitized name "${sanitizedName}" which can be changed later from https://appstoreconnect.apple.com.` + ); + // Sanitize the name and try again. + return await createAppAsync( + context, + { + ...props, + name: sanitizedName, + }, + retryCount + 1 + ); + } + + if ( + // 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. + error.code === 'APP_CREATE_NAME_UNAVAILABLE' + ) { + return await handleDuplicateNameErrorAsync(); + } + } + } + + throw error; + } +} + +function isAppleError(error: any): error is { + data: { + errors: { + id: string; + status: string; + /** 'ENTITY_ERROR.ATTRIBUTE.INVALID.INVALID_CHARACTERS' */ + code: string; + /** 'An attribute value has invalid characters.' */ + title: string; + /** 'App Name contains certain Unicode symbols, emoticons, diacritics, special characters, or private use characters that are not permitted.' */ + detail: string; + }[]; + }; +} { + return 'data' in error && 'errors' in error.data && Array.isArray(error.data.errors); +} diff --git a/packages/eas-cli/src/submit/ios/AppProduce.ts b/packages/eas-cli/src/submit/ios/AppProduce.ts index 159ef7be20..534da0bdf1 100644 --- a/packages/eas-cli/src/submit/ios/AppProduce.ts +++ b/packages/eas-cli/src/submit/ios/AppProduce.ts @@ -1,6 +1,5 @@ -import { App, RequestContext, Session, User } from '@expo/apple-utils'; +import { RequestContext, Session, User } from '@expo/apple-utils'; import { Platform } from '@expo/eas-build-job'; -import chalk from 'chalk'; import { sanitizeLanguage } from './utils/language'; import { getRequestContext } from '../../credentials/ios/appstore/authenticate'; @@ -85,38 +84,13 @@ async function createAppStoreConnectAppAsync( ); } - let app: App | null = null; - - try { - app = await ensureAppExistsAsync(userAuthCtx, { - name: appName, - language, - companyName, - bundleIdentifier: bundleId, - sku, - }); - } catch (error: any) { - 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; - } + const app = await ensureAppExistsAsync(userAuthCtx, { + name: appName, + language, + companyName, + bundleIdentifier: bundleId, + sku, + }); return { ascAppIdentifier: app.id,