Skip to content
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
1 change: 1 addition & 0 deletions packages/app/src/cli/commands/app/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
12 changes: 2 additions & 10 deletions packages/app/src/cli/prompts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function selectAppPrompt(
options?: {
directory?: string
},
): Promise<MinimalAppIdentifiers> {
): Promise<MinimalAppIdentifiers | undefined> {
const tomls = await getTomls(options?.directory)

if (tomls) setCachedCommandTomlMap(tomls)
Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions packages/app/src/cli/services/dev/select-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down Expand Up @@ -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)
})
})
49 changes: 15 additions & 34 deletions packages/app/src/cli/services/dev/select-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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')
Expand Down
Loading