Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eas-cli): Use a fallback generated name during EAS Submit #2842

Merged
merged 5 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
});
});
122 changes: 120 additions & 2 deletions packages/eas-cli/src/credentials/ios/appstore/ensureAppExists.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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<App> {
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<App> => {
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);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can try to validate this data against a scheme by using zod or joi instead of writing if statements manually

42 changes: 8 additions & 34 deletions packages/eas-cli/src/submit/ios/AppProduce.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Loading