diff --git a/packages/app/src/cli/commands/app/init.ts b/packages/app/src/cli/commands/app/init.ts index c293518696..9b4e708004 100644 --- a/packages/app/src/cli/commands/app/init.ts +++ b/packages/app/src/cli/commands/app/init.ts @@ -167,6 +167,7 @@ async function selectAppOrNewAppName( return {result: 'new', name, org} } else { const app = await selectAppPrompt(searchForAppsByNameFactory(developerPlatformClient, org.id), apps, hasMorePages) + if (!app) throw new AbortError('Unable to select an app: the selection prompt was interrupted.') const fullSelectedApp = await developerPlatformClient.appFromIdentifiers(app.apiKey) if (!fullSelectedApp) throw new AbortError(`App with id ${app.id} not found`) diff --git a/packages/app/src/cli/prompts/dev.ts b/packages/app/src/cli/prompts/dev.ts index ab16c8612e..4f168caa53 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -32,7 +32,7 @@ export async function selectAppPrompt( options?: { directory?: string }, -): Promise { +): Promise { const tomls = await getTomls(options?.directory) if (tomls) setCachedCommandTomlMap(tomls) @@ -64,15 +64,7 @@ export async function selectAppPrompt( }, }) - const appChoice = currentAppChoices.find((app) => app.apiKey === apiKey)! - - if (!appChoice) { - throw new Error( - `Unable to select an app: the selection prompt was interrupted multiple times./n - Api key ${apiKey} was selected but not found in ${currentAppChoices.map((app) => app.apiKey).join(', ')}`, - ) - } - return appChoice + return currentAppChoices.find((app) => app.apiKey === apiKey) } interface SelectStorePromptOptions { diff --git a/packages/app/src/cli/services/dev/select-app.test.ts b/packages/app/src/cli/services/dev/select-app.test.ts index d0ce4811d3..14c19b6941 100644 --- a/packages/app/src/cli/services/dev/select-app.test.ts +++ b/packages/app/src/cli/services/dev/select-app.test.ts @@ -3,9 +3,11 @@ import {AppInterface, WebType} from '../../models/app/app.js' import {Organization, OrganizationSource} from '../../models/organization.js' import {appNamePrompt, createAsNewAppPrompt, selectAppPrompt} from '../../prompts/dev.js' import {testApp, testOrganizationApp, testDeveloperPlatformClient} from '../../models/app/app.test-data.js' +import {BugError} from '@shopify/cli-kit/node/error' import {describe, expect, vi, test} from 'vitest' vi.mock('../../prompts/dev') +vi.mock('@shopify/cli-kit/node/output') const LOCAL_APP: AppInterface = testApp({ directory: '', @@ -82,4 +84,48 @@ describe('selectOrCreateApp', () => { expect(appNamePrompt).toHaveBeenCalledWith(LOCAL_APP.name) expect(developerPlatformClient.createApp).toHaveBeenCalledWith(ORG1, {name: 'app-name'}) }) + + test('retries when selectAppPrompt returns undefined and succeeds on next attempt', async () => { + // Given + vi.mocked(selectAppPrompt).mockResolvedValueOnce(undefined).mockResolvedValueOnce(APPS[0]) + vi.mocked(createAsNewAppPrompt).mockResolvedValue(false) + + // When + const {developerPlatformClient} = mockDeveloperPlatformClient() + const got = await selectOrCreateApp(APPS, false, ORG1, developerPlatformClient, {name: LOCAL_APP.name}) + + // Then + expect(got).toEqual(APP1) + expect(selectAppPrompt).toHaveBeenCalledTimes(2) + }) + + test('throws BugError when selectAppPrompt returns undefined for all attempts', async () => { + // Given + vi.mocked(selectAppPrompt).mockResolvedValue(undefined) + vi.mocked(createAsNewAppPrompt).mockResolvedValue(false) + + // When/Then + const {developerPlatformClient} = mockDeveloperPlatformClient() + await expect(selectOrCreateApp(APPS, false, ORG1, developerPlatformClient, {name: LOCAL_APP.name})).rejects.toThrow( + BugError, + ) + expect(selectAppPrompt).toHaveBeenCalledTimes(2) + }) + + test('throws BugError when appFromIdentifiers returns undefined for all attempts', async () => { + // Given + vi.mocked(selectAppPrompt).mockResolvedValue(APPS[0]) + vi.mocked(createAsNewAppPrompt).mockResolvedValue(false) + const developerPlatformClient = testDeveloperPlatformClient({ + async appFromIdentifiers(_apiKey) { + return undefined + }, + }) + + // When/Then + await expect(selectOrCreateApp(APPS, false, ORG1, developerPlatformClient, {name: LOCAL_APP.name})).rejects.toThrow( + BugError, + ) + expect(selectAppPrompt).toHaveBeenCalledTimes(2) + }) }) diff --git a/packages/app/src/cli/services/dev/select-app.ts b/packages/app/src/cli/services/dev/select-app.ts index 2a32754f13..c0fea4a5bf 100644 --- a/packages/app/src/cli/services/dev/select-app.ts +++ b/packages/app/src/cli/services/dev/select-app.ts @@ -5,7 +5,7 @@ import {getCachedCommandInfo, setCachedCommandTomlPreference} from '../local-sto import {CreateAppOptions, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppConfigurationFileName} from '../../models/app/loader.js' import {BugError} from '@shopify/cli-kit/node/error' -import {outputInfo, outputDebug} from '@shopify/cli-kit/node/output' +import {outputInfo} from '@shopify/cli-kit/node/output' const MAX_PROMPT_RETRIES = 2 @@ -51,52 +51,33 @@ export async function selectOrCreateApp( const tomls = (cachedData?.tomls as {[key: string]: AppConfigurationFileName}) ?? {} for (let attempt = 0; attempt < MAX_PROMPT_RETRIES; attempt++) { - try { - // eslint-disable-next-line no-await-in-loop - const app = await selectAppPrompt( - searchForAppsByNameFactory(developerPlatformClient, org.id), - apps, - hasMorePages, - {directory: options.directory}, - ) + // eslint-disable-next-line no-await-in-loop + const app = await selectAppPrompt( + searchForAppsByNameFactory(developerPlatformClient, org.id), + apps, + hasMorePages, + {directory: options.directory}, + ) + if (!app) { + if (attempt < MAX_PROMPT_RETRIES - 1) outputInfo('App selection failed. Retrying...') + continue + } else { const selectedToml = tomls[app.apiKey] if (selectedToml) setCachedCommandTomlPreference(selectedToml) // eslint-disable-next-line no-await-in-loop const fullSelectedApp = await developerPlatformClient.appFromIdentifiers(app.apiKey) - if (!fullSelectedApp) { - throw new BugError( - `Unable to fetch app ${app.apiKey} from Shopify`, - 'Try running `shopify app config link` to connect to an app you have access to.', - ) - } - - return fullSelectedApp - } catch (error) { - // Don't retry BugError - those indicate actual bugs, not transient issues - if (error instanceof BugError) { - throw error - } - - const errorObj = error as Error - - // Log each attempt for observability - outputDebug(`App selection attempt ${attempt + 1}/${MAX_PROMPT_RETRIES} failed: ${errorObj.message}`) - - // If we have retries left, inform user and retry - if (attempt < MAX_PROMPT_RETRIES - 1) { - outputInfo('App selection failed. Retrying...') - } else { - throw new BugError(errorObj.message, TRY_MESSAGE) + if (fullSelectedApp) { + return fullSelectedApp } } } // User-facing error message with key diagnostic info const errorMessage = [ - 'Unable to select an app: the selection prompt was interrupted multiple times.', + 'Unable to select an app: the selection prompt failed multiple times.', '', `Available apps: ${apps.length}`, ].join('\n')