From 35daf6b7ab782b02abb377e5c963ab434911240e Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 25 Aug 2026 12:52:17 -0400 Subject: [PATCH 1/4] Add a create store option to the app dev store picker Assisted-By: devx/389e4e7c-5ebc-4d59-bf3e-d0eb8c0187d3 --- .../app-dev-store-picker-create-option.md | 5 + packages/app/src/cli/commands/app/dev.test.ts | 2 +- packages/app/src/cli/commands/app/dev.ts | 2 +- packages/app/src/cli/prompts/dev.test.ts | 69 +++++++++- packages/app/src/cli/prompts/dev.ts | 14 +- .../src/cli/services/dev/select-store.test.ts | 124 +++++++++++++++++- .../app/src/cli/services/dev/select-store.ts | 37 ++++-- 7 files changed, 228 insertions(+), 25 deletions(-) create mode 100644 .changeset/app-dev-store-picker-create-option.md diff --git a/.changeset/app-dev-store-picker-create-option.md b/.changeset/app-dev-store-picker-create-option.md new file mode 100644 index 00000000000..430b09ddda1 --- /dev/null +++ b/.changeset/app-dev-store-picker-create-option.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Add a "Create a new dev store" option to the store picker in `app dev`. diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index e0eab301ac4..db22bb0df8f 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -58,7 +58,7 @@ describe('app dev command', () => { tunnelUrl: undefined, localhostPort: undefined, }) - expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeCreationMode: 'when-empty'})) + expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeCreationMode: 'selection-option'})) expect(dev).toHaveBeenCalledWith(expect.objectContaining({installMkcert: false, tunnel: {mode: 'auto'}})) }) }) diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index b0af6478d64..02abcde46c0 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -134,7 +134,7 @@ export default class Dev extends AppLinkedCommand { appContextResult, storeFqdn: flags.store, forceReselectStore: flags.reset, - storeCreationMode: 'when-empty', + storeCreationMode: 'selection-option', }) const devOptions: DevOptions = { diff --git a/packages/app/src/cli/prompts/dev.test.ts b/packages/app/src/cli/prompts/dev.test.ts index 742c0ad11fa..37b283ba39e 100644 --- a/packages/app/src/cli/prompts/dev.test.ts +++ b/packages/app/src/cli/prompts/dev.test.ts @@ -143,7 +143,7 @@ describe('selectStore', () => { expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project') }) - test('creates directly when the list is empty and a creation handler is provided', async () => { + test('creates directly when the list is empty and a zero-store creation handler is provided', async () => { const onCreateStoreWhenEmpty = vi.fn().mockResolvedValue(STORE1) const got = await selectStorePrompt({ @@ -157,7 +157,7 @@ describe('selectStore', () => { expect(renderAutocompletePrompt).not.toHaveBeenCalled() }) - test('returns the only store without creating when a creation handler is provided', async () => { + test('returns the only store without creating when a zero-store creation handler is provided', async () => { const onCreateStoreWhenEmpty = vi.fn().mockResolvedValue(STORE2) const outputMock = mockAndCaptureOutput() @@ -173,6 +173,71 @@ describe('selectStore', () => { expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project') }) + test('offers a create choice instead of auto-selecting the only store when a picker creation handler is provided', async () => { + const createdStore = {...STORE2, shopId: 'created'} + const onCreateStore = vi.fn().mockResolvedValue(createdStore) + vi.mocked(renderAutocompletePrompt).mockResolvedValue('__create_new_dev_store__') + + const got = await selectStorePrompt({ + stores: [STORE1], + showDomainOnPrompt: defaultShowDomainOnPrompt, + onCreateStore, + }) + + expect(got).toEqual(createdStore) + expect(onCreateStore).toHaveBeenCalledOnce() + expect(renderAutocompletePrompt).toHaveBeenCalledWith({ + message: 'Which store would you like to use to view your project?', + choices: [ + {label: 'store1', value: '1'}, + {label: 'Create a new dev store', value: '__create_new_dev_store__'}, + ], + hasMorePages: false, + }) + }) + + test('returns the selected store when the create choice is offered with multiple stores', async () => { + const onCreateStore = vi.fn().mockResolvedValue(STORE3) + vi.mocked(renderAutocompletePrompt).mockResolvedValue('2') + + const got = await selectStorePrompt({ + stores: [STORE1, STORE2], + showDomainOnPrompt: defaultShowDomainOnPrompt, + onCreateStore, + }) + + expect(got).toEqual(STORE2) + expect(onCreateStore).not.toHaveBeenCalled() + expect(renderAutocompletePrompt).toHaveBeenCalledWith({ + message: 'Which store would you like to use to view your project?', + choices: [ + {label: 'store1', value: '1'}, + {label: 'store2', value: '2'}, + {label: 'Create a new dev store', value: '__create_new_dev_store__'}, + ], + hasMorePages: false, + }) + }) + + test('keeps the create choice when the store list is searched', async () => { + const onCreateStore = vi.fn().mockResolvedValue(STORE3) + vi.mocked(renderAutocompletePrompt).mockImplementation(async ({search}) => { + const searchResults = await search!('new') + expect(searchResults.data).toContainEqual({label: 'Create a new dev store', value: '__create_new_dev_store__'}) + return '__create_new_dev_store__' + }) + + const got = await selectStorePrompt({ + stores: [STORE1], + showDomainOnPrompt: defaultShowDomainOnPrompt, + onCreateStore, + onSearchForStoresByName: (_term: string) => Promise.resolve({stores: [STORE3], hasMorePages: false}), + }) + + expect(got).toEqual(STORE3) + expect(onCreateStore).toHaveBeenCalledOnce() + }) + test('returns store if user selects one', async () => { // Given const stores: OrganizationStore[] = [STORE1, STORE2] diff --git a/packages/app/src/cli/prompts/dev.ts b/packages/app/src/cli/prompts/dev.ts index 93ef6d300a0..1dbcbab2e25 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -70,6 +70,7 @@ interface SelectStorePromptOptions { hasMorePages?: boolean showDomainOnPrompt: boolean onCreateStoreWhenEmpty?: () => Promise + onCreateStore?: () => Promise } interface ExtraAutoCompletePropsForStoreSelect { @@ -82,9 +83,10 @@ export async function selectStorePrompt({ onSearchForStoresByName, showDomainOnPrompt = true, onCreateStoreWhenEmpty, + onCreateStore, }: SelectStorePromptOptions): Promise { if (stores.length === 0) return onCreateStoreWhenEmpty?.() - if (stores.length === 1) { + if (stores.length === 1 && !onCreateStore) { outputCompleted(`Using your default dev store, ${stores[0]!.shopName}, to preview your project.`) return stores[0] } @@ -99,6 +101,11 @@ export async function selectStorePrompt({ let currentStores = stores const storesById = new Map(stores.map((store) => [store.shopId, store])) + const createStoreChoice = '__create_new_dev_store__' + const choices = () => [ + ...currentStores.map(storeToChoice), + ...(onCreateStore ? [{label: 'Create a new dev store', value: createStoreChoice}] : []), + ] const extraAutocompletePromptProps: ExtraAutoCompletePropsForStoreSelect = {} if (onSearchForStoresByName) { @@ -110,7 +117,7 @@ export async function selectStorePrompt({ } return { - data: currentStores.map(storeToChoice), + data: choices(), meta: { hasNextPage: result.hasMorePages, }, @@ -120,10 +127,11 @@ export async function selectStorePrompt({ const id = await renderAutocompletePrompt({ message: 'Which store would you like to use to view your project?', - choices: currentStores.map(storeToChoice), + choices: choices(), hasMorePages, ...extraAutocompletePromptProps, }) + if (id === createStoreChoice) return onCreateStore?.() return storesById.get(id) } diff --git a/packages/app/src/cli/services/dev/select-store.test.ts b/packages/app/src/cli/services/dev/select-store.test.ts index 1ae53e72ff0..debc2648e52 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -115,7 +115,7 @@ describe('selectStore', async () => { ).resolves.toEqual(STORE1) expect(devStoreCapReached).not.toHaveBeenCalled() expect(createDevStore).not.toHaveBeenCalled() - expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty') + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') }) test('auto-selects the only Partners store in a non-interactive environment when store creation is enabled', async () => { @@ -131,7 +131,7 @@ describe('selectStore', async () => { ), ).resolves.toEqual(STORE1) expect(devStoreCapReached).not.toHaveBeenCalled() - expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty') + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') }) test('keeps prompting in a non-interactive environment when store creation is disabled', async () => { @@ -225,7 +225,7 @@ describe('selectStore', async () => { selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'when-empty'), ).resolves.toEqual(STORE1) expect(devStoreCapReached).not.toHaveBeenCalled() - expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty') + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') }) test('does not offer creation when store creation is disabled', async () => { @@ -236,7 +236,123 @@ describe('selectStore', async () => { STORE1, ) expect(devStoreCapReached).not.toHaveBeenCalled() - expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty') + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') + }) + + test('offers creation with existing stores when the selection-option app-management organization is not capped', async () => { + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + vi.mocked(devStoreCapReached).mockResolvedValue(false) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + + await expect( + selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'), + ).resolves.toEqual(STORE1) + expect(devStoreCapReached).toHaveBeenCalledWith(ORG1.id, developerPlatformClient) + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).toHaveProperty('onCreateStore') + }) + + test('hides creation but keeps store selection when the selection-option app-management organization is capped', async () => { + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + vi.mocked(devStoreCapReached).mockResolvedValue(true) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + + await expect( + selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'), + ).resolves.toEqual(STORE1) + expect(devStoreCapReached).toHaveBeenCalledWith(ORG1.id, developerPlatformClient) + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') + expect(createDevStore).not.toHaveBeenCalled() + }) + + test('keeps the dashboard fallback when the capped selection-option app-management store prompt is cancelled', async () => { + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + vi.mocked(devStoreCapReached).mockResolvedValue(true) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(undefined) + vi.mocked(reloadStoreListPrompt).mockResolvedValue(false) + + await expect( + selectStore({stores: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'), + ).rejects.toBeInstanceOf(CancelExecution) + expect(developerPlatformClient.getCreateDevStoreLink).toHaveBeenCalledWith(ORG1) + expect(sleep).toHaveBeenCalledWith(5) + expect(reloadStoreListPrompt).toHaveBeenCalledWith(ORG1) + expect(createDevStore).not.toHaveBeenCalled() + }) + + test('does not query the cap or offer creation for Partners with the selection-option mode', async () => { + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.Partners}) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + + await expect( + selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'), + ).resolves.toEqual(STORE1) + expect(devStoreCapReached).not.toHaveBeenCalled() + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') + }) + + test('fails before the prompt in a non-interactive environment when the selection-option app-management organization has no stores', async () => { + vi.mocked(isTTY).mockReturnValue(false) + + await expect( + selectStore( + {stores: [], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + 'selection-option', + ), + ).rejects.toMatchObject({ + message: 'No development store was specified.', + tryMessage: 'Create a development store in Dev Dashboard, then run `app dev` again.', + }) + expect(selectStorePrompt).not.toHaveBeenCalled() + expect(devStoreCapReached).not.toHaveBeenCalled() + expect(createDevStore).not.toHaveBeenCalled() + }) + + test('auto-selects the only app-management store in a non-interactive environment with the selection-option mode', async () => { + vi.mocked(isTTY).mockReturnValue(false) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + + await expect( + selectStore( + {stores: [STORE1], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + 'selection-option', + ), + ).resolves.toEqual(STORE1) + expect(devStoreCapReached).not.toHaveBeenCalled() + expect(createDevStore).not.toHaveBeenCalled() + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') + }) + + test('creates and refetches an app-management store selected from a non-empty list', async () => { + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + vi.mocked(devStoreCapReached).mockResolvedValue(false) + vi.mocked(devStoreNamePrompt).mockResolvedValue('created-store') + vi.mocked(devStorePlanPrompt).mockResolvedValue('grow') + vi.mocked(createDevStore).mockResolvedValue('created-store.myshopify.com') + vi.mocked(fetchStore).mockResolvedValueOnce(STORE1) + vi.mocked(renderTasks).mockImplementation(async (tasks: Task[]) => { + for (const task of tasks) { + // eslint-disable-next-line no-await-in-loop + await task.task({}, task) + } + return {} + }) + vi.mocked(selectStorePrompt).mockImplementation(async ({onCreateStore}) => onCreateStore!()) + + await expect( + selectStore({stores: [STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'), + ).resolves.toEqual(STORE1) + expect(createDevStore).toHaveBeenCalledWith({ + name: 'created-store', + plan: 'grow', + organization: ORG1, + json: false, + summary: false, + }) + expect(renderSuccess).toHaveBeenCalledWith({headline: 'Development store "store1" created successfully.'}) }) test('keeps the dashboard fallback when the app-management store prompt is cancelled', async () => { diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index 52fb9ea2ecb..6640f16c452 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -9,9 +9,9 @@ import {AbortError, CancelExecution} from '@shopify/cli-kit/node/error' import {createDevStore} from '@shopify/organizations' /** Store creation from store selection is an explicit command opt-in. */ -export type StoreCreationMode = 'disabled' | 'when-empty' +export type StoreCreationMode = 'disabled' | 'when-empty' | 'selection-option' -/** Selects an eligible development store, or creates one when creation is enabled and the organization has none. */ +/** Selects an eligible development store, or creates one when store creation is enabled. */ export async function selectStore( storesSearch: Paginateable<{stores: OrganizationStore[]}>, org: Organization, @@ -21,9 +21,23 @@ export async function selectStore( const showDomainOnPrompt = developerPlatformClient.clientName === ClientName.AppManagement const onSearchForStoresByName = async (term: string) => developerPlatformClient.devStoresForOrg(org.id, term) const storeCreationEnabled = - storeCreationMode === 'when-empty' && developerPlatformClient.clientName === ClientName.AppManagement + storeCreationMode !== 'disabled' && developerPlatformClient.clientName === ClientName.AppManagement + + const createStoreInline = async () => { + if (await devStoreCapReached(org.id, developerPlatformClient)) { + throw new AbortError(devStoreCapReachedMessage, devStoreCapReachedTryMessage(org.id)) + } + + const name = await devStoreNamePrompt() + const plan = await devStorePlanPrompt() + const domain = await createDevStore({name, plan, organization: org, json: false, summary: false}) + const createdStore = await waitForCreatedStoreByDomain(org, domain, developerPlatformClient) + renderSuccess({headline: `Development store "${createdStore.shopName}" created successfully.`}) + return createdStore + } let onCreateStoreWhenEmpty: (() => Promise) | undefined + let onCreateStore: (() => Promise) | undefined if (storeCreationEnabled && storesSearch.stores.length === 0) { if (await devStoreCapReached(org.id, developerPlatformClient)) { throw new AbortError(devStoreCapReachedMessage, devStoreCapReachedTryMessage(org.id)) @@ -32,17 +46,11 @@ export async function selectStore( if (isTTY() === false) { throw new AbortError('No development store was specified.', createDevStoreTryMessage(org.id)) } - onCreateStoreWhenEmpty = async () => { - if (await devStoreCapReached(org.id, developerPlatformClient)) { - throw new AbortError(devStoreCapReachedMessage, devStoreCapReachedTryMessage(org.id)) - } - - const name = await devStoreNamePrompt() - const plan = await devStorePlanPrompt() - const domain = await createDevStore({name, plan, organization: org, json: false, summary: false}) - const createdStore = await waitForCreatedStoreByDomain(org, domain, developerPlatformClient) - renderSuccess({headline: `Development store "${createdStore.shopName}" created successfully.`}) - return createdStore + onCreateStoreWhenEmpty = createStoreInline + } else if (storeCreationEnabled && storeCreationMode === 'selection-option' && isTTY()) { + // A capped organization keeps normal store selection without the create choice. + if (!(await devStoreCapReached(org.id, developerPlatformClient))) { + onCreateStore = createStoreInline } } @@ -53,6 +61,7 @@ export async function selectStore( ...storesSearch, showDomainOnPrompt, ...(onCreateStoreWhenEmpty ? {onCreateStoreWhenEmpty} : {}), + ...(onCreateStore ? {onCreateStore} : {}), }) if (!store) { if (onCreateStoreWhenEmpty) { From bacfe78ed9bcd4735a57b1149d9dcdce635f16d7 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 25 Aug 2026 13:18:35 -0400 Subject: [PATCH 2/4] Handle non-interactive store picker selection Assisted-By: devx/4593ba0e-0e3a-4487-a065-4ae504486df8 --- packages/app/src/cli/prompts/dev.test.ts | 20 +++++++++ .../src/cli/services/dev/select-store.test.ts | 42 ++++++++++++++++++- .../app/src/cli/services/dev/select-store.ts | 11 ++++- .../src/cli/services/store-context.test.ts | 10 ++++- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/app/src/cli/prompts/dev.test.ts b/packages/app/src/cli/prompts/dev.test.ts index 37b283ba39e..b7704720748 100644 --- a/packages/app/src/cli/prompts/dev.test.ts +++ b/packages/app/src/cli/prompts/dev.test.ts @@ -238,6 +238,26 @@ describe('selectStore', () => { expect(onCreateStore).toHaveBeenCalledOnce() }) + test('returns a searched store without creating when the create choice is offered', async () => { + const onCreateStore = vi.fn().mockResolvedValue(STORE2) + vi.mocked(renderAutocompletePrompt).mockImplementation(async ({search}) => { + const searchResults = await search!('new') + expect(searchResults.data).toContainEqual({label: 'store3', value: '3'}) + expect(searchResults.data).toContainEqual({label: 'Create a new dev store', value: '__create_new_dev_store__'}) + return '3' + }) + + const got = await selectStorePrompt({ + stores: [STORE1], + showDomainOnPrompt: defaultShowDomainOnPrompt, + onCreateStore, + onSearchForStoresByName: (_term: string) => Promise.resolve({stores: [STORE3], hasMorePages: false}), + }) + + expect(got).toEqual(STORE3) + expect(onCreateStore).not.toHaveBeenCalled() + }) + test('returns store if user selects one', async () => { // Given const stores: OrganizationStore[] = [STORE1, STORE2] diff --git a/packages/app/src/cli/services/dev/select-store.test.ts b/packages/app/src/cli/services/dev/select-store.test.ts index debc2648e52..f1db71ccbac 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -302,7 +302,46 @@ describe('selectStore', async () => { ), ).rejects.toMatchObject({ message: 'No development store was specified.', - tryMessage: 'Create a development store in Dev Dashboard, then run `app dev` again.', + tryMessage: + 'Create a development store with `shopify store create dev --organization-id 1 --name --plan `, then run `shopify app dev --store `.', + }) + expect(selectStorePrompt).not.toHaveBeenCalled() + expect(devStoreCapReached).toHaveBeenCalled() + expect(createDevStore).not.toHaveBeenCalled() + }) + + test('fails before the prompt in a non-interactive environment when selection-option requires choosing between stores', async () => { + vi.mocked(isTTY).mockReturnValue(false) + + await expect( + selectStore( + {stores: [STORE1, STORE2], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + 'selection-option', + ), + ).rejects.toMatchObject({ + message: 'No development store was specified.', + tryMessage: 'Run `app dev --store ` to select a development store.', + }) + expect(selectStorePrompt).not.toHaveBeenCalled() + expect(devStoreCapReached).not.toHaveBeenCalled() + expect(createDevStore).not.toHaveBeenCalled() + }) + + test('fails before the prompt in a non-interactive environment when selection-option has more stores to load', async () => { + vi.mocked(isTTY).mockReturnValue(false) + + await expect( + selectStore( + {stores: [STORE1], hasMorePages: true}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + 'selection-option', + ), + ).rejects.toMatchObject({ + message: 'No development store was specified.', + tryMessage: 'Run `app dev --store ` to select a development store.', }) expect(selectStorePrompt).not.toHaveBeenCalled() expect(devStoreCapReached).not.toHaveBeenCalled() @@ -345,6 +384,7 @@ describe('selectStore', async () => { await expect( selectStore({stores: [STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'), ).resolves.toEqual(STORE1) + expect(devStoreCapReached).toHaveBeenCalledTimes(2) expect(createDevStore).toHaveBeenCalledWith({ name: 'created-store', plan: 'grow', diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index 6640f16c452..7731e6bbcfa 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -47,9 +47,16 @@ export async function selectStore( throw new AbortError('No development store was specified.', createDevStoreTryMessage(org.id)) } onCreateStoreWhenEmpty = createStoreInline - } else if (storeCreationEnabled && storeCreationMode === 'selection-option' && isTTY()) { + } else if (storeCreationEnabled && storeCreationMode === 'selection-option') { + if (isTTY() === false && (storesSearch.stores.length > 1 || storesSearch.hasMorePages)) { + throw new AbortError( + 'No development store was specified.', + 'Run `app dev --store ` to select a development store.', + ) + } + // A capped organization keeps normal store selection without the create choice. - if (!(await devStoreCapReached(org.id, developerPlatformClient))) { + if (isTTY() && !(await devStoreCapReached(org.id, developerPlatformClient))) { onCreateStore = createStoreInline } } diff --git a/packages/app/src/cli/services/store-context.test.ts b/packages/app/src/cli/services/store-context.test.ts index 5b45cb206b0..110abd2aa82 100644 --- a/packages/app/src/cli/services/store-context.test.ts +++ b/packages/app/src/cli/services/store-context.test.ts @@ -49,7 +49,7 @@ describe('storeContext', () => { activeConfig: {isLinked: true, hiddenConfig: {}} as unknown as ActiveConfig, } - test('uses explicitly provided storeFqdn', async () => { + test('uses an explicitly provided store before selection-option store selection', async () => { await inTemporaryDirectory(async (dir) => { vi.mocked(fetchStore).mockResolvedValue(mockStore) await prepareAppFolder(mockApp, dir) @@ -58,6 +58,7 @@ describe('storeContext', () => { appContextResult, storeFqdn: 'explicit-store.myshopify.com', forceReselectStore: false, + storeCreationMode: 'selection-option', }) expect(fetchStore).toHaveBeenCalledWith( @@ -66,12 +67,14 @@ describe('storeContext', () => { mockDeveloperPlatformClient, ['APP_DEVELOPMENT'], ) + expect(mockDeveloperPlatformClient.devStoresForOrg).not.toHaveBeenCalled() + expect(selectStore).not.toHaveBeenCalled() expect(ensureTransferDisabledStore).toHaveBeenCalledWith(mockStore) expect(result).toEqual(mockStore) }) }) - test('uses cached dev_store_url when no explicit storeFqdn is provided', async () => { + test('uses the cached dev_store_url before selection-option store selection', async () => { await inTemporaryDirectory(async (dir) => { vi.mocked(fetchStore).mockResolvedValue(mockStore) await prepareAppFolder(mockApp, dir) @@ -79,6 +82,7 @@ describe('storeContext', () => { const result = await storeContext({ appContextResult, forceReselectStore: false, + storeCreationMode: 'selection-option', }) expect(fetchStore).toHaveBeenCalledWith( @@ -87,6 +91,8 @@ describe('storeContext', () => { mockDeveloperPlatformClient, ['APP_DEVELOPMENT'], ) + expect(mockDeveloperPlatformClient.devStoresForOrg).not.toHaveBeenCalled() + expect(selectStore).not.toHaveBeenCalled() expect(result).toEqual(mockStore) }) }) From 6dc60f649df31d8ab489b82df0aa06b7e041869a Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 27 Aug 2026 14:52:30 -0400 Subject: [PATCH 3/4] Preserve paginated store selection Assisted-By: devx/4593ba0e-0e3a-4487-a065-4ae504486df8 --- packages/app/src/cli/prompts/dev.test.ts | 18 ++++++++++++++++++ packages/app/src/cli/prompts/dev.ts | 2 +- .../src/cli/services/dev/select-store.test.ts | 6 +++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/prompts/dev.test.ts b/packages/app/src/cli/prompts/dev.test.ts index b7704720748..b8d1cbb53d3 100644 --- a/packages/app/src/cli/prompts/dev.test.ts +++ b/packages/app/src/cli/prompts/dev.test.ts @@ -143,6 +143,24 @@ describe('selectStore', () => { expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project') }) + test('prompts when only one store is loaded but more pages are available', async () => { + const stores: OrganizationStore[] = [STORE1] + vi.mocked(renderAutocompletePrompt).mockResolvedValue(STORE1.shopId) + + const got = await selectStorePrompt({ + stores, + hasMorePages: true, + showDomainOnPrompt: defaultShowDomainOnPrompt, + }) + + expect(got).toEqual(STORE1) + expect(renderAutocompletePrompt).toHaveBeenCalledWith({ + message: 'Which store would you like to use to view your project?', + choices: [{label: 'store1', value: '1'}], + hasMorePages: true, + }) + }) + test('creates directly when the list is empty and a zero-store creation handler is provided', async () => { const onCreateStoreWhenEmpty = vi.fn().mockResolvedValue(STORE1) diff --git a/packages/app/src/cli/prompts/dev.ts b/packages/app/src/cli/prompts/dev.ts index 1dbcbab2e25..d745e5641bb 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -86,7 +86,7 @@ export async function selectStorePrompt({ onCreateStore, }: SelectStorePromptOptions): Promise { if (stores.length === 0) return onCreateStoreWhenEmpty?.() - if (stores.length === 1 && !onCreateStore) { + if (stores.length === 1 && !hasMorePages && !onCreateStore) { outputCompleted(`Using your default dev store, ${stores[0]!.shopName}, to preview your project.`) return stores[0] } diff --git a/packages/app/src/cli/services/dev/select-store.test.ts b/packages/app/src/cli/services/dev/select-store.test.ts index f1db71ccbac..d10bf14a2a8 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -251,13 +251,13 @@ describe('selectStore', async () => { expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).toHaveProperty('onCreateStore') }) - test('hides creation but keeps store selection when the selection-option app-management organization is capped', async () => { + test('hides creation but keeps paginated store selection when the selection-option organization is capped', async () => { const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) vi.mocked(devStoreCapReached).mockResolvedValue(true) vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) await expect( - selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'), + selectStore({stores: [STORE1], hasMorePages: true}, ORG1, developerPlatformClient, 'selection-option'), ).resolves.toEqual(STORE1) expect(devStoreCapReached).toHaveBeenCalledWith(ORG1.id, developerPlatformClient) expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') @@ -271,7 +271,7 @@ describe('selectStore', async () => { vi.mocked(reloadStoreListPrompt).mockResolvedValue(false) await expect( - selectStore({stores: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'), + selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'), ).rejects.toBeInstanceOf(CancelExecution) expect(developerPlatformClient.getCreateDevStoreLink).toHaveBeenCalledWith(ORG1) expect(sleep).toHaveBeenCalledWith(5) From 16f05405e7fee0d4f9a41555f28a42f87c7507a8 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 27 Aug 2026 17:18:35 -0400 Subject: [PATCH 4/4] Make short paginated choices searchable Assisted-By: devx/4593ba0e-0e3a-4487-a065-4ae504486df8 --- .../src/cli/services/dev/select-store.test.ts | 4 +-- .../app/src/cli/services/dev/select-store.ts | 2 +- .../ui/components/AutocompletePrompt.test.tsx | 28 +++++++++++++++++++ .../node/ui/components/AutocompletePrompt.tsx | 2 +- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/services/dev/select-store.test.ts b/packages/app/src/cli/services/dev/select-store.test.ts index d10bf14a2a8..00e7f4bafb6 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -322,7 +322,7 @@ describe('selectStore', async () => { ), ).rejects.toMatchObject({ message: 'No development store was specified.', - tryMessage: 'Run `app dev --store ` to select a development store.', + tryMessage: 'Run `shopify app dev --store ` to select a development store.', }) expect(selectStorePrompt).not.toHaveBeenCalled() expect(devStoreCapReached).not.toHaveBeenCalled() @@ -341,7 +341,7 @@ describe('selectStore', async () => { ), ).rejects.toMatchObject({ message: 'No development store was specified.', - tryMessage: 'Run `app dev --store ` to select a development store.', + tryMessage: 'Run `shopify app dev --store ` to select a development store.', }) expect(selectStorePrompt).not.toHaveBeenCalled() expect(devStoreCapReached).not.toHaveBeenCalled() diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index 7731e6bbcfa..52fd9606f0d 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -51,7 +51,7 @@ export async function selectStore( if (isTTY() === false && (storesSearch.stores.length > 1 || storesSearch.hasMorePages)) { throw new AbortError( 'No development store was specified.', - 'Run `app dev --store ` to select a development store.', + 'Run `shopify app dev --store ` to select a development store.', ) } diff --git a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx index b909d64b7e3..60b7ce40a49 100644 --- a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx @@ -128,6 +128,34 @@ describe('AutocompletePrompt', async () => { expect(onEnter).toHaveBeenCalledWith(items[1]!.value) }) + test('searches and selects later-page items from a short paginated list', async () => { + const onSubmit = vi.fn() + const search = vi.fn(async (term: string) => ({ + data: term === 'l' ? [{label: 'later-page-store', value: 'later-page-store'}] : [], + })) + + const renderInstance = render( + , + ) + + expect(renderInstance.lastFrame()).toContain('ype to search...') + + await waitForInputsToBeReady() + await sendInputAndWaitForContent(renderInstance, 'ater-page-store', 'l') + await sendInputAndWait(renderInstance, 10, ARROW_DOWN) + await sendInputAndWaitForChange(renderInstance, ENTER) + + expect(search).toHaveBeenCalledWith('l') + expect(onSubmit).toHaveBeenCalledWith('later-page-store') + }) + test('renders groups', async () => { const items = [ {label: 'first', value: 'first', group: 'Automations'}, diff --git a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx index 7214cec2b11..500d66a3f12 100644 --- a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx @@ -55,7 +55,7 @@ function AutocompletePrompt({ const complete = useComplete() const [searchTerm, setSearchTerm] = useState('') const [searchResults, setSearchResults] = useState[]>(choices) - const canSearch = choices.length > MIN_NUMBER_OF_ITEMS_FOR_SEARCH + const canSearch = initialHasMorePages || choices.length > MIN_NUMBER_OF_ITEMS_FOR_SEARCH const [hasMorePages, setHasMorePages] = useState(initialHasMorePages) const {promptState, setPromptState, answer, setAnswer} = usePrompt | undefined>({ initialAnswer: undefined,