Compare commits

...
Author SHA1 Message Date
evanbacon 329ddccd61 Merge branch 'main' into @evanbacon/cli/auto-bundle-id 2026-03-30 11:11:21 -07:00
evanbacon 1db7281e8b Merge branch 'main' into @evanbacon/cli/auto-bundle-id 2026-02-14 14:12:30 -08:00
evanbacon 1e8a0acabe Update CHANGELOG.md 2026-02-14 14:12:18 -08:00
evanbaconandClaude Opus 4.5 6bfb8af57f Fix tests and add changelog entry
- Wrap suggestion lookup in try-catch for non-interactive mode
- Add changelog entry for the new feature

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-12 15:16:00 -08:00
evanbacon 231f29ecf1 Non-interactive auto-generate bundle & package
Allow iOS bundle identifier and Android application id to be auto-generated in non-interactive mode instead of immediately throwing. If a suggested identifier is available (from AppQuery or the counterpart platform field) it will be used and logged; if not, an error is still thrown. Tests updated to cover auto-generation, cross-platform fallbacks, and the error case when no valid suggestion can be produced.
2026-02-12 15:15:23 -08:00
5 changed files with 218 additions and 59 deletions

No files matched your search

+2
View File
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Auto-generate `ios.bundleIdentifier` and `android.package` in non-interactive mode when not set in app config. ([#3399](https://github.com/expo/eas-cli/pull/3399) by [@evanbacon](https://github.com/evanbacon))
- 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))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
@@ -137,21 +137,81 @@ describe(ensureApplicationIdIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('throws an error in non-interactive mode', async () => {
it('auto-generates application id in non-interactive mode when suggestion is available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { android: { package: 'com.jester.myapp' } },
});
});
it('uses ios.bundleIdentifier as application id in non-interactive mode when available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "blah": {} }',
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', ios: { bundleIdentifier: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '',
exp: {} as any,
projectId: '1234',
exp: { slug: '' } as any,
vcsClient,
nonInteractive: true,
})
@@ -148,15 +148,8 @@ async function configureApplicationIdAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
if (nonInteractive) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -166,25 +159,50 @@ async function configureApplicationIdAsync({
assert(paths.staticConfigPath, 'app.json must exist');
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
let packageName: string;
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
const { packageName } = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
if (nonInteractive) {
let suggestedAndroidApplicationId: string | undefined;
try {
suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedAndroidApplicationId) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
packageName = suggestedAndroidApplicationId;
Log.log(`Using automatically generated Android application id: ${chalk.bold(packageName)}`);
} else {
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
const result = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
packageName = result.packageName;
}
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -115,20 +115,81 @@ describe(ensureBundleIdentifierIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('throws an error in non-interactive mode', async () => {
it('auto-generates bundle identifier in non-interactive mode when suggestion is available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "blah": {} }',
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { ios: { bundleIdentifier: 'com.jester.myapp' } },
});
});
it('uses android.package as bundle identifier in non-interactive mode when available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', android: { package: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: {} as any,
exp: { slug: '' } as any,
vcsClient,
nonInteractive: true,
})
@@ -137,15 +137,8 @@ async function configureBundleIdentifierAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
if (nonInteractive) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -155,26 +148,51 @@ async function configureBundleIdentifierAsync({
assert(paths.staticConfigPath, 'app.json must exist');
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
let bundleIdentifier: string;
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
if (nonInteractive) {
let suggestedBundleIdentifier: string | undefined;
try {
suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedBundleIdentifier) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
bundleIdentifier = suggestedBundleIdentifier;
Log.log(`Using automatically generated iOS bundle identifier: ${chalk.bold(bundleIdentifier)}`);
} else {
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
const { bundleIdentifier } = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
});
const result = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value =>
isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE,
});
bundleIdentifier = result.bundleIdentifier;
}
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {