From 368e7f19a25108d2609110811d307617759a0329 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Sat, 11 Jul 2026 17:04:01 -0700 Subject: [PATCH 1/2] [eas-cli] Add --dev-domain flag to eas deploy Allows assigning the EAS Hosting preview URL subdomain on a project's first deployment without the interactive prompt, so first-time deploys can run with --non-interactive and a custom URL. - Validates the name eagerly (shared rules with the interactive prompt, plus rejecting anything the prompt would have auto-reformatted) - Errors clearly when the requested name is taken, instead of the previous behavior in non-interactive mode where a taken suggested name caused infinite recursion in assignDevDomainNameAsync - Errors when the project already has a different dev domain rather than silently ignoring the flag --- CHANGELOG.md | 1 + packages/eas-cli/graphql.schema.json | 42 ++-- packages/eas-cli/src/commands/deploy/index.ts | 22 +- packages/eas-cli/src/graphql/generated.ts | 26 ++- .../src/worker/__tests__/deployment.test.ts | 208 ++++++++++++++++++ packages/eas-cli/src/worker/deployment.ts | 89 ++++++-- packages/eas-cli/src/worker/queries.ts | 31 +++ 7 files changed, 361 insertions(+), 58 deletions(-) create mode 100644 packages/eas-cli/src/worker/__tests__/deployment.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e9705f0b..84aa97b851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `--dev-domain` flag to `eas deploy` to choose the project's preview URL (`.expo.app`) without prompting, allowing first-time deploys with `--non-interactive`. ([#xxxx](https://github.com/expo/eas-cli/pull/xxxx) by [@brentvatne](https://github.com/brentvatne)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/graphql.schema.json b/packages/eas-cli/graphql.schema.json index 877091fe1f..e9eb6c05fd 100644 --- a/packages/eas-cli/graphql.schema.json +++ b/packages/eas-cli/graphql.schema.json @@ -58471,7 +58471,7 @@ }, { "name": "setPreferences", - "description": "Sets user preferences. This is a key-value store for user-specific settings. Provided values are\nkey-level merged with existing values.", + "description": "Legacy user preferences are no longer stored; this mutation accepts and discards\nits input. Use userPreference.set instead.", "args": [ { "name": "preferences", @@ -58499,8 +58499,8 @@ "ofType": null } }, - "isDeprecated": false, - "deprecationReason": null + "isDeprecated": true, + "deprecationReason": "No longer stored; this mutation has no effect. Use userPreference.set instead." }, { "name": "setPrimarySecondFactorDevice", @@ -66775,8 +66775,8 @@ "ofType": null } }, - "isDeprecated": false, - "deprecationReason": null + "isDeprecated": true, + "deprecationReason": "No longer stored. Use userPreference instead." }, { "name": "primaryAccount", @@ -76602,8 +76602,8 @@ "ofType": null } }, - "isDeprecated": false, - "deprecationReason": null + "isDeprecated": true, + "deprecationReason": "No longer stored. Use userPreference instead." }, { "name": "primaryAccount", @@ -77305,8 +77305,8 @@ "ofType": null } }, - "isDeprecated": false, - "deprecationReason": null + "isDeprecated": true, + "deprecationReason": "No longer stored. Use userPreference instead." }, { "name": "primaryAccount", @@ -80045,20 +80045,8 @@ "name": "UserPreferencesOnboarding", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "selectedAccountName", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, "isDeprecated": true, - "deprecationReason": "Use userPreference.get(key: \"selected-account-id\") instead." + "deprecationReason": "No longer stored; always null." } ], "inputFields": null, @@ -80112,7 +80100,7 @@ }, { "name": "deviceType", - "description": "Can be null if the user has not selected one yet.", + "description": null, "args": [], "type": { "kind": "ENUM", @@ -80124,7 +80112,7 @@ }, { "name": "environment", - "description": "Can be null if the user has not selected one yet.", + "description": null, "args": [], "type": { "kind": "ENUM", @@ -80136,7 +80124,7 @@ }, { "name": "isCLIDone", - "description": "Set by CLI when the user has completed that phase. Used by the website to determine when\nthe next step can be shown.", + "description": null, "args": [], "type": { "kind": "SCALAR", @@ -80148,7 +80136,7 @@ }, { "name": "lastUsed", - "description": "The last time when this object was updated.", + "description": null, "args": [], "type": { "kind": "NON_NULL", @@ -80164,7 +80152,7 @@ }, { "name": "platform", - "description": "User selects a platform for which they want to build the app. CLI uses this information to start the build.", + "description": null, "args": [], "type": { "kind": "ENUM", diff --git a/packages/eas-cli/src/commands/deploy/index.ts b/packages/eas-cli/src/commands/deploy/index.ts index c32cac9edd..f14fda2a01 100644 --- a/packages/eas-cli/src/commands/deploy/index.ts +++ b/packages/eas-cli/src/commands/deploy/index.ts @@ -18,6 +18,7 @@ import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils'; import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; import * as WorkerAssets from '../../worker/assets'; import { + assertValidDevDomainName, assignWorkerDeploymentAliasAsync, assignWorkerDeploymentProductionAsync, getSignedDeploymentUrlAsync, @@ -30,6 +31,7 @@ import { uploadAsync, } from '../../worker/upload'; import { + EXPO_BASE_DOMAIN, formatWorkerDeploymentJson, formatWorkerDeploymentTable, getDeploymentUrlFromFullName, @@ -50,6 +52,7 @@ interface DeployFlags { aliasName?: string; environment?: string; deploymentIdentifier?: string; + devDomainName?: string; exportDir: string; dryRun: boolean; sourceMaps: boolean; @@ -62,6 +65,7 @@ interface RawDeployFlags { prod: boolean; alias?: string; id?: string; + 'dev-domain'?: string; 'export-dir': string; 'dry-run': boolean; 'source-maps': boolean; @@ -82,7 +86,11 @@ interface DeployInProgressParams { export default class WorkerDeploy extends EasCommand { static override description = 'deploy your Expo Router web build and API Routes'; static override aliases = ['worker:deploy']; - static override usage = [chalk`deploy {dim [options]}`, `deploy --prod`]; + static override usage = [ + chalk`deploy {dim [options]}`, + `deploy --prod`, + `deploy --non-interactive --dev-domain my-app`, + ]; static override state = 'preview'; static override flags = { @@ -99,6 +107,10 @@ export default class WorkerDeploy extends EasCommand { description: 'Custom unique identifier for the new deployment.', helpValue: 'xyz123', }), + 'dev-domain': Flags.string({ + description: `Custom preview URL subdomain to assign to the project on its first deployment, e.g. "my-app" for my-app.${EXPO_BASE_DOMAIN}.app. Required with --non-interactive if you want to customize the preview URL.`, + helpValue: 'name', + }), 'export-dir': Flags.string({ description: 'Directory where the Expo project was exported.', helpValue: 'dir', @@ -322,6 +334,7 @@ export default class WorkerDeploy extends EasCommand { const uploadUrl = await getSignedDeploymentUrlAsync(graphqlClient, { appId: projectId, deploymentIdentifier: flags.deploymentIdentifier, + devDomainName: flags.devDomainName, // NOTE(cedric): this function might ask the user for a dev-domain name, // when that happens, no ora spinner should be running. onSetupDevDomain: () => progress.stop(), @@ -467,12 +480,19 @@ export default class WorkerDeploy extends EasCommand { private sanitizeFlags(flags: RawDeployFlags): DeployFlags { const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + + const devDomainName = flags['dev-domain']?.trim().toLowerCase(); + if (devDomainName !== undefined) { + assertValidDevDomainName(devDomainName); + } + return { nonInteractive, json, isProduction: !!flags.prod, aliasName: flags.alias?.trim().toLowerCase(), deploymentIdentifier: flags.id?.trim(), + devDomainName, exportDir: flags['export-dir'], environment: flags['environment'], dryRun: flags['dry-run'], diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 4273e78351..17d262bb47 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -8217,8 +8217,9 @@ export type MeMutation = { /** Schedule deletion of a SSO user. Actor must be an owner on the SSO user's SSO account. */ scheduleSSOUserDeletionAsSSOAccountOwner: BackgroundJobReceipt; /** - * Sets user preferences. This is a key-value store for user-specific settings. Provided values are - * key-level merged with existing values. + * Legacy user preferences are no longer stored; this mutation accepts and discards + * its input. Use userPreference.set instead. + * @deprecated No longer stored; this mutation has no effect. Use userPreference.set instead. */ setPreferences: UserPreferences; /** Set the user's primary second factor device */ @@ -9387,6 +9388,7 @@ export type SsoUser = Actor & UserActor & { lastName?: Maybe; pinnedApps: Array; pinnedDashboardViews: Array; + /** @deprecated No longer stored. Use userPreference instead. */ preferences: UserPreferences; /** Associated accounts */ primaryAccount: Account; @@ -10739,6 +10741,7 @@ export type User = Actor & UserActor & { pendingUserInvitations: Array; pinnedApps: Array; pinnedDashboardViews: Array; + /** @deprecated No longer stored. Use userPreference instead. */ preferences: UserPreferences; /** Associated accounts */ primaryAccount: Account; @@ -10833,6 +10836,7 @@ export type UserActor = { lastDeletionAttemptTime?: Maybe; lastName?: Maybe; pinnedApps: Array; + /** @deprecated No longer stored. Use userPreference instead. */ preferences: UserPreferences; /** Associated accounts */ primaryAccount: Account; @@ -11276,9 +11280,8 @@ export type UserPreferenceQueryGetArgs = { export type UserPreferences = { __typename?: 'UserPreferences'; + /** @deprecated No longer stored; always null. */ onboarding?: Maybe; - /** @deprecated Use userPreference.get(key: "selected-account-id") instead. */ - selectedAccountName?: Maybe; }; export type UserPreferencesInput = { @@ -11292,18 +11295,10 @@ export type UserPreferencesInput = { export type UserPreferencesOnboarding = { __typename?: 'UserPreferencesOnboarding'; appId: Scalars['ID']['output']; - /** Can be null if the user has not selected one yet. */ deviceType?: Maybe; - /** Can be null if the user has not selected one yet. */ environment?: Maybe; - /** - * Set by CLI when the user has completed that phase. Used by the website to determine when - * the next step can be shown. - */ isCLIDone?: Maybe; - /** The last time when this object was updated. */ lastUsed: Scalars['String']['output']; - /** User selects a platform for which they want to build the app. CLI uses this information to start the build. */ platform?: Maybe; }; @@ -14518,6 +14513,13 @@ export type SuggestedDevDomainNameQueryVariables = Exact<{ export type SuggestedDevDomainNameQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, suggestedDevDomainName: string } } }; +export type DevDomainNameByAppIdQueryVariables = Exact<{ + appId: Scalars['String']['input']; +}>; + + +export type DevDomainNameByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, devDomainName?: { __typename?: 'AppDevDomainName', id: string, name: any } | null } } }; + export type PaginatedWorkerDeploymentAliasesQueryVariables = Exact<{ appId: Scalars['String']['input']; first?: InputMaybe; diff --git a/packages/eas-cli/src/worker/__tests__/deployment.test.ts b/packages/eas-cli/src/worker/__tests__/deployment.test.ts new file mode 100644 index 0000000000..2db607524e --- /dev/null +++ b/packages/eas-cli/src/worker/__tests__/deployment.test.ts @@ -0,0 +1,208 @@ +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { promptAsync } from '../../prompts'; +import { + assertValidDevDomainName, + assignDevDomainNameAsync, + getSignedDeploymentUrlAsync, +} from '../deployment'; +import { DeploymentsMutation } from '../mutations'; +import { DeploymentsQuery } from '../queries'; + +jest.mock('../mutations'); +jest.mock('../queries'); +jest.mock('../../prompts'); +jest.mock('../../log'); + +function createDevDomainNameTakenError(): Error { + return Object.assign(new Error('Dev domain name is already taken'), { + graphQLErrors: [{ extensions: { errorCode: 'DEV_DOMAIN_NAME_TAKEN' } }], + }); +} + +function createMissingDevDomainNameError(): Error { + return Object.assign(new Error('App does not have a dev domain name'), { + graphQLErrors: [{ extensions: { errorCode: 'APP_NO_DEV_DOMAIN_NAME' } }], + }); +} + +describe(assertValidDevDomainName, () => { + it.each(['abc', 'my-app', 'app123', 'my-app-123'])('accepts %p', name => { + expect(() => { + assertValidDevDomainName(name); + }).not.toThrow(); + }); + + it('rejects empty names', () => { + expect(() => { + assertValidDevDomainName(''); + }).toThrow(/choose a preview URL/); + }); + + it('rejects names shorter than 3 characters', () => { + expect(() => { + assertValidDevDomainName('ab'); + }).toThrow(/at least 3 characters/); + }); + + it('rejects names ending with a hyphen', () => { + expect(() => { + assertValidDevDomainName('my-app-'); + }).toThrow(/cannot end with a hyphen/); + }); + + it.each(['-my-app', 'My-App', 'my_app', 'my--app', 'my.app'])( + 'rejects names with invalid characters, like %p', + name => { + expect(() => { + assertValidDevDomainName(name); + }).toThrow(/lowercase letters, numbers, and non-consecutive hyphens/); + } + ); +}); + +describe(assignDevDomainNameAsync, () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('assigns the requested dev domain name without prompting', async () => { + const graphqlClient = {} as ExpoGraphqlClient; + jest.mocked(DeploymentsMutation.assignDevDomainNameAsync).mockResolvedValueOnce(true); + + await assignDevDomainNameAsync({ + graphqlClient, + appId: 'test-app-id', + devDomainName: 'my-app', + nonInteractive: true, + }); + + expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledWith(graphqlClient, { + appId: 'test-app-id', + name: 'my-app', + }); + expect(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync).not.toHaveBeenCalled(); + expect(promptAsync).not.toHaveBeenCalled(); + }); + + it('throws when the requested dev domain name is taken', async () => { + const graphqlClient = {} as ExpoGraphqlClient; + jest + .mocked(DeploymentsMutation.assignDevDomainNameAsync) + .mockRejectedValueOnce(createDevDomainNameTakenError()); + + await expect( + assignDevDomainNameAsync({ + graphqlClient, + appId: 'test-app-id', + devDomainName: 'my-app', + nonInteractive: true, + }) + ).rejects.toThrow(/"my-app" is already taken.*--dev-domain/); + + expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledTimes(1); + }); + + it('assigns the suggested dev domain name in non-interactive mode', async () => { + const graphqlClient = {} as ExpoGraphqlClient; + jest + .mocked(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync) + .mockResolvedValueOnce('suggested-name'); + jest.mocked(DeploymentsMutation.assignDevDomainNameAsync).mockResolvedValueOnce(true); + + await assignDevDomainNameAsync({ + graphqlClient, + appId: 'test-app-id', + nonInteractive: true, + }); + + expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledWith(graphqlClient, { + appId: 'test-app-id', + name: 'suggested-name', + }); + expect(promptAsync).not.toHaveBeenCalled(); + }); + + it('throws when the suggested dev domain name is taken in non-interactive mode', async () => { + const graphqlClient = {} as ExpoGraphqlClient; + jest + .mocked(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync) + .mockResolvedValueOnce('suggested-name'); + jest + .mocked(DeploymentsMutation.assignDevDomainNameAsync) + .mockRejectedValueOnce(createDevDomainNameTakenError()); + + await expect( + assignDevDomainNameAsync({ + graphqlClient, + appId: 'test-app-id', + nonInteractive: true, + }) + ).rejects.toThrow(/"suggested-name" is already taken.*--dev-domain/); + + expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledTimes(1); + }); +}); + +describe(getSignedDeploymentUrlAsync, () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('throws when the requested dev domain name differs from the existing one', async () => { + const graphqlClient = {} as ExpoGraphqlClient; + jest.mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync).mockResolvedValueOnce('other-name'); + + await expect( + getSignedDeploymentUrlAsync(graphqlClient, { + appId: 'test-app-id', + devDomainName: 'my-app', + nonInteractive: true, + }) + ).rejects.toThrow(/already set to "other-name\..*" and cannot be changed/); + + expect(DeploymentsMutation.createSignedDeploymentUrlAsync).not.toHaveBeenCalled(); + }); + + it('returns the deployment URL when the requested dev domain name matches the existing one', async () => { + const graphqlClient = {} as ExpoGraphqlClient; + jest.mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync).mockResolvedValueOnce('my-app'); + jest + .mocked(DeploymentsMutation.createSignedDeploymentUrlAsync) + .mockResolvedValueOnce('https://upload-url.example'); + + await expect( + getSignedDeploymentUrlAsync(graphqlClient, { + appId: 'test-app-id', + devDomainName: 'my-app', + nonInteractive: true, + }) + ).resolves.toBe('https://upload-url.example'); + }); + + it('assigns the requested dev domain name when the project has none yet', async () => { + const graphqlClient = {} as ExpoGraphqlClient; + jest + .mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('my-app'); + jest + .mocked(DeploymentsMutation.createSignedDeploymentUrlAsync) + .mockRejectedValueOnce(createMissingDevDomainNameError()) + .mockResolvedValueOnce('https://upload-url.example'); + jest.mocked(DeploymentsMutation.assignDevDomainNameAsync).mockResolvedValueOnce(true); + + await expect( + getSignedDeploymentUrlAsync(graphqlClient, { + appId: 'test-app-id', + devDomainName: 'my-app', + nonInteractive: true, + }) + ).resolves.toBe('https://upload-url.example'); + + expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledWith(graphqlClient, { + appId: 'test-app-id', + name: 'my-app', + }); + expect(promptAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/worker/deployment.ts b/packages/eas-cli/src/worker/deployment.ts index 05b1401d5a..8ad46ed40f 100644 --- a/packages/eas-cli/src/worker/deployment.ts +++ b/packages/eas-cli/src/worker/deployment.ts @@ -16,12 +16,27 @@ export async function getSignedDeploymentUrlAsync( options: { appId: string; deploymentIdentifier?: string | null; + /** Custom dev domain name (preview URL subdomain) requested through the `--dev-domain` flag */ + devDomainName?: string; /** Callback which is invoked when the project is going to setup the dev domain */ onSetupDevDomain?: () => any; /** If the terminal is running in non interactive mode or not */ nonInteractive?: boolean; } ): Promise { + if (options.devDomainName) { + const currentDevDomainName = await DeploymentsQuery.getDevDomainNameByAppIdAsync( + graphqlClient, + { appId: options.appId } + ); + + if (currentDevDomainName && currentDevDomainName !== options.devDomainName) { + throw new Error( + `The project's preview URL is already set to "${currentDevDomainName}.${EXPO_BASE_DOMAIN}.app" and cannot be changed to "${options.devDomainName}.${EXPO_BASE_DOMAIN}.app" through "eas deploy".\nRemove the --dev-domain flag to deploy to the existing preview URL.` + ); + } + } + try { return await DeploymentsMutation.createSignedDeploymentUrlAsync(graphqlClient, { appId: options.appId, @@ -43,6 +58,7 @@ export async function getSignedDeploymentUrlAsync( await assignDevDomainNameAsync({ graphqlClient, appId: options.appId, + devDomainName: options.devDomainName, nonInteractive: options.nonInteractive, }); // Retry creating the signed URL @@ -78,6 +94,40 @@ function formatDevDomainName(name = ''): string { .trim(); } +/** + * Validate a dev domain name (preview URL subdomain), matching the rules of the interactive prompt. + * Returns `true` when valid, or an error message describing why the name is invalid. + */ +function validateDevDomainName(value: string): true | string { + if (!value) { + return 'You have to choose a preview URL for your project'; + } + if (value.length < 3) { + return 'Preview URLs must be at least 3 characters long'; + } + if (value.endsWith('-')) { + return 'Preview URLs cannot end with a hyphen (-)'; + } + return true; +} + +/** + * Assert that a user-provided dev domain name (preview URL subdomain) is valid. + * Unlike the interactive prompt, which reformats invalid input while typing, + * this throws an error when the name contains invalid characters. + */ +export function assertValidDevDomainName(name: string): void { + const validationResult = validateDevDomainName(name); + if (validationResult !== true) { + throw new Error(validationResult); + } + if (formatDevDomainName(name) !== name) { + throw new Error( + `Preview URLs can only contain lowercase letters, numbers, and non-consecutive hyphens (-), and cannot start or end with a hyphen: "${name}"` + ); + } +} + async function promptDevDomainNameAsync(initialDevDomain: string): Promise { const rootDomain = `.${EXPO_BASE_DOMAIN}.app`; const memoizedFormatDevDomainName = memoize(formatDevDomainName); @@ -87,18 +137,7 @@ async function promptDevDomainNameAsync(initialDevDomain: string): Promise { - if (!value) { - return 'You have to choose a preview URL for your project'; - } - if (value.length < 3) { - return 'Preview URLs must be at least 3 characters long'; - } - if (value.endsWith('-')) { - return 'Preview URLs cannot end with a hyphen (-)'; - } - return true; - }, + validate: validateDevDomainName, onState(this: PromptInstance, state: { value?: string }) { const value = memoizedFormatDevDomainName(state.value); if (value !== state.value) { @@ -129,23 +168,28 @@ async function promptDevDomainNameAsync(initialDevDomain: string): Promise { - let devDomainName = await DeploymentsQuery.getSuggestedDevDomainByAppIdAsync(graphqlClient, { - appId, - }); + let devDomainName = + requestedDevDomainName ?? + (await DeploymentsQuery.getSuggestedDevDomainByAppIdAsync(graphqlClient, { + appId, + })); - if (!nonInteractive) { + if (!requestedDevDomainName && !nonInteractive) { devDomainName = await promptDevDomainNameAsync(devDomainName); } @@ -164,10 +208,19 @@ export async function assignDevDomainNameAsync({ throw error; } - if (!nonInteractive) { - Log.error(`The preview URL "${devDomainName}" is already taken, choose a different URL.`); + if (requestedDevDomainName) { + throw new Error( + `The preview URL "${requestedDevDomainName}" is already taken, choose a different URL with the --dev-domain flag.` + ); + } + + if (nonInteractive) { + throw new Error( + `The suggested preview URL "${devDomainName}" is already taken, choose a different URL with the --dev-domain flag.` + ); } + Log.error(`The preview URL "${devDomainName}" is already taken, choose a different URL.`); return await assignDevDomainNameAsync({ graphqlClient, appId, nonInteractive }); } } diff --git a/packages/eas-cli/src/worker/queries.ts b/packages/eas-cli/src/worker/queries.ts index 80736e41f4..ea28c504c4 100644 --- a/packages/eas-cli/src/worker/queries.ts +++ b/packages/eas-cli/src/worker/queries.ts @@ -6,6 +6,8 @@ import { WorkerDeploymentAliasFragmentNode } from './fragments/WorkerDeploymentA import type { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { withErrorHandlingAsync } from '../graphql/client'; import { + DevDomainNameByAppIdQuery, + DevDomainNameByAppIdQueryVariables, type PaginatedWorkerDeploymentsQuery, type PaginatedWorkerDeploymentsQueryVariables, SuggestedDevDomainNameQuery, @@ -89,6 +91,35 @@ export const DeploymentsQuery = { return data.app.byId.suggestedDevDomainName; }, + async getDevDomainNameByAppIdAsync( + graphqlClient: ExpoGraphqlClient, + { appId }: DevDomainNameByAppIdQueryVariables + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query DevDomainNameByAppId($appId: String!) { + app { + byId(appId: $appId) { + id + devDomainName { + id + name + } + } + } + } + `, + { appId }, + { additionalTypenames: ['AppDevDomainName'] } + ) + .toPromise() + ); + + return data.app.byId.devDomainName?.name ?? null; + }, + async getAllAliasesPaginatedAsync( graphqlClient: ExpoGraphqlClient, { From fa5e5a1c106bd7fc1d1a483f93d02e93eb192e97 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Sat, 11 Jul 2026 17:04:49 -0700 Subject: [PATCH 2/2] [eas-cli] Fill in changelog PR number --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84aa97b851..3347df1531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp)) -- [eas-cli] Add `--dev-domain` flag to `eas deploy` to choose the project's preview URL (`.expo.app`) without prompting, allowing first-time deploys with `--non-interactive`. ([#xxxx](https://github.com/expo/eas-cli/pull/xxxx) by [@brentvatne](https://github.com/brentvatne)) +- [eas-cli] Add `--dev-domain` flag to `eas deploy` to choose the project's preview URL (`.expo.app`) without prompting, allowing first-time deploys with `--non-interactive`. ([#4004](https://github.com/expo/eas-cli/pull/4004) by [@brentvatne](https://github.com/brentvatne)) ### 🐛 Bug fixes