From 4e85e16cbcd84fd329c5381b2405157381a2e097 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 25 Aug 2026 10:19:34 -0400 Subject: [PATCH 1/5] Create dev stores inline from app dev store selection Assisted-By: devx/8f022f57-b61c-49a9-a188-5b4c62d597c3 --- .changeset/create-dev-store-from-app.md | 5 + bin/get-graphql-schemas.js | 1 + graphql.config.ts | 5 + .../generated/dev_store_cap_reached.ts | 35 ++++ .../queries/dev_store_cap_reached.graphql | 5 + packages/app/src/cli/prompts/dev.test.ts | 49 +++++ packages/app/src/cli/prompts/dev.ts | 29 ++- packages/app/src/cli/services/dev/cap.test.ts | 42 ++++ packages/app/src/cli/services/dev/cap.ts | 22 +++ .../src/cli/services/dev/select-store.test.ts | 126 ++++++++++-- .../app/src/cli/services/dev/select-store.ts | 102 +++++++++- .../utilities/developer-platform-client.ts | 1 + .../app-management-client.ts | 18 ++ packages/organizations/project.json | 36 +++- .../generated/create_app_development_store.ts | 0 .../generated/poll_store_creation.ts | 0 .../generated/types.d.ts | 92 +++++++++ .../create_app_development_store.graphql | 0 .../queries/poll_store_creation.graphql | 0 packages/organizations/src/cli/prompts/dev.ts | 21 ++ .../src/cli/services/business-platform.ts | 15 ++ .../cli/services/dev/create-dev-store.test.ts | 78 ++++++++ .../src/cli/services/dev/create-dev-store.ts | 183 ++++++++++++++++++ packages/organizations/src/index.ts | 4 + .../generated/types.d.ts | 9 - .../src/cli/commands/store/create/dev.test.ts | 13 +- packages/store/src/cli/prompts/store.ts | 28 +-- .../cli/services/store/business-platform.ts | 20 +- .../store/src/cli/services/store/constants.ts | 29 +-- .../src/cli/services/store/create/dev.ts | 172 +--------------- 30 files changed, 847 insertions(+), 293 deletions(-) create mode 100644 .changeset/create-dev-store-from-app.md create mode 100644 packages/app/src/cli/api/graphql/business-platform-organizations/generated/dev_store_cap_reached.ts create mode 100644 packages/app/src/cli/api/graphql/business-platform-organizations/queries/dev_store_cap_reached.graphql create mode 100644 packages/app/src/cli/services/dev/cap.test.ts create mode 100644 packages/app/src/cli/services/dev/cap.ts rename packages/{store => organizations}/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts (100%) rename packages/{store => organizations}/src/cli/api/graphql/business-platform-organizations/generated/poll_store_creation.ts (100%) create mode 100644 packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts rename packages/{store => organizations}/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql (100%) rename packages/{store => organizations}/src/cli/api/graphql/business-platform-organizations/queries/poll_store_creation.graphql (100%) create mode 100644 packages/organizations/src/cli/prompts/dev.ts create mode 100644 packages/organizations/src/cli/services/business-platform.ts create mode 100644 packages/organizations/src/cli/services/dev/create-dev-store.test.ts create mode 100644 packages/organizations/src/cli/services/dev/create-dev-store.ts diff --git a/.changeset/create-dev-store-from-app.md b/.changeset/create-dev-store-from-app.md new file mode 100644 index 00000000000..da91dfa8929 --- /dev/null +++ b/.changeset/create-dev-store-from-app.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Allow `app dev` to create a development store inline from store selection. diff --git a/bin/get-graphql-schemas.js b/bin/get-graphql-schemas.js index b021a64e991..64aadd00a66 100755 --- a/bin/get-graphql-schemas.js +++ b/bin/get-graphql-schemas.js @@ -54,6 +54,7 @@ const schemas = [ pathToFile: 'areas/platforms/organizations/db/graphql/organizations_schema.graphql', localPaths: [ './packages/app/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql', + './packages/organizations/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql', './packages/store/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql', ], }, diff --git a/graphql.config.ts b/graphql.config.ts index 7461bfe7cb4..9b5ebbe6780 100644 --- a/graphql.config.ts +++ b/graphql.config.ts @@ -85,6 +85,11 @@ export default { functions: projectFactory('functions', 'functions_cli_schema.graphql', 'app'), adminAsApp: projectFactory('admin', 'admin_schema.graphql'), organizationsDestinations: projectFactory('business-platform-destinations', 'destinations_schema.graphql', 'organizations'), + organizationsBusinessPlatformOrganizations: projectFactory( + 'business-platform-organizations', + 'organizations_schema.graphql', + 'organizations', + ), storeBusinessPlatformDestinations: projectFactory('business-platform-destinations', 'destinations_schema.graphql', 'store'), storeBusinessPlatformOrganizations: projectFactory('business-platform-organizations', 'organizations_schema.graphql', 'store'), }, diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/dev_store_cap_reached.ts b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/dev_store_cap_reached.ts new file mode 100644 index 00000000000..d906b50c60b --- /dev/null +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/dev_store_cap_reached.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type DevStoreCapReachedQueryVariables = Types.Exact<{[key: string]: never}> + +export type DevStoreCapReachedQuery = {organization?: {devStoreCapReached: boolean} | null} + +export const DevStoreCapReached = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'DevStoreCapReached'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'organization'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'devStoreCapReached'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/queries/dev_store_cap_reached.graphql b/packages/app/src/cli/api/graphql/business-platform-organizations/queries/dev_store_cap_reached.graphql new file mode 100644 index 00000000000..767ab15a74c --- /dev/null +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/queries/dev_store_cap_reached.graphql @@ -0,0 +1,5 @@ +query DevStoreCapReached { + organization { + devStoreCapReached + } +} diff --git a/packages/app/src/cli/prompts/dev.test.ts b/packages/app/src/cli/prompts/dev.test.ts index 51eee69ee3b..28f65a5d00f 100644 --- a/packages/app/src/cli/prompts/dev.test.ts +++ b/packages/app/src/cli/prompts/dev.test.ts @@ -143,6 +143,36 @@ describe('selectStore', () => { expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project') }) + test('offers creating a store when a creation handler is provided', async () => { + const stores: OrganizationStore[] = [STORE1] + const createdStore = {...STORE2, shopId: 'created'} + const onCreateStore = vi.fn().mockResolvedValue(createdStore) + vi.mocked(renderAutocompletePrompt).mockResolvedValue('__create_new_dev_store__') + + const got = await selectStorePrompt({stores, 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('creates directly when the list is empty and a creation handler is provided', async () => { + const onCreateStore = vi.fn().mockResolvedValue(STORE1) + + const got = await selectStorePrompt({stores: [], showDomainOnPrompt: defaultShowDomainOnPrompt, onCreateStore}) + + expect(got).toEqual(STORE1) + expect(onCreateStore).toHaveBeenCalledOnce() + expect(renderAutocompletePrompt).not.toHaveBeenCalled() + }) + test('returns store if user selects one', async () => { // Given const stores: OrganizationStore[] = [STORE1, STORE2] @@ -189,6 +219,25 @@ describe('selectStore', () => { expect(lastCall[0]).not.toHaveProperty('search') }) + 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 correct store if user selects one after searching', 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 18062fbd867..7c36b4203c9 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -3,6 +3,10 @@ import {getTomls} from '../utilities/app/config/getTomls.js' import {Paginateable} from '../utilities/developer-platform-client.js' import {APP_NAME_MAX_LENGTH} from '../models/app/validation/common.js' import {ApplicationURLs} from '../services/dev/urls.js' +import { + devStoreNamePrompt as sharedDevStoreNamePrompt, + devStorePlanPrompt as sharedDevStorePlanPrompt, +} from '@shopify/organizations' import { RenderAutocompleteOptions, renderAutocompletePrompt, @@ -10,6 +14,15 @@ import { renderTextPrompt, } from '@shopify/cli-kit/node/ui' import {outputCompleted} from '@shopify/cli-kit/node/output' +import type {DevStorePlan} from '@shopify/organizations' + +export function devStoreNamePrompt(): Promise { + return sharedDevStoreNamePrompt() +} + +export function devStorePlanPrompt(): Promise { + return sharedDevStorePlanPrompt() +} export async function selectAppPrompt( onSearchForAppsByName: (term: string) => Promise<{apps: MinimalOrganizationApp[]; hasMorePages: boolean}>, @@ -56,6 +69,7 @@ interface SelectStorePromptOptions { stores: OrganizationStore[] hasMorePages?: boolean showDomainOnPrompt: boolean + onCreateStore?: () => Promise } interface ExtraAutoCompletePropsForStoreSelect { @@ -67,9 +81,10 @@ export async function selectStorePrompt({ hasMorePages = false, onSearchForStoresByName, showDomainOnPrompt = true, + onCreateStore, }: SelectStorePromptOptions): Promise { - if (stores.length === 0) return undefined - if (stores.length === 1) { + if (stores.length === 0) return onCreateStore?.() + if (stores.length === 1 && !onCreateStore) { outputCompleted(`Using your default dev store, ${stores[0]!.shopName}, to preview your project.`) return stores[0] } @@ -83,6 +98,11 @@ export async function selectStorePrompt({ } let currentStores = stores + 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) { @@ -91,7 +111,7 @@ export async function selectStorePrompt({ currentStores = result.stores return { - data: currentStores.map(storeToChoice), + data: choices(), meta: { hasNextPage: result.hasMorePages, }, @@ -101,10 +121,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 currentStores.find((store) => store.shopId === id) } diff --git a/packages/app/src/cli/services/dev/cap.test.ts b/packages/app/src/cli/services/dev/cap.test.ts new file mode 100644 index 00000000000..7b1a26dfd58 --- /dev/null +++ b/packages/app/src/cli/services/dev/cap.test.ts @@ -0,0 +1,42 @@ +import {devStoreCapReached} from './cap.js' +import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' +import {ClientName} from '../../utilities/developer-platform-client.js' +import {describe, expect, test, vi} from 'vitest' + +describe('devStoreCapReached', () => { + test('returns the cap value for an app-management client', async () => { + const client = testDeveloperPlatformClient({ + clientName: ClientName.AppManagement, + devStoreCapReached: vi.fn().mockResolvedValue(true), + }) + + await expect(devStoreCapReached('1', client)).resolves.toBe(true) + expect(client.devStoreCapReached).toHaveBeenCalledWith('1') + }) + + test('fails open when the cap request fails', async () => { + const client = testDeveloperPlatformClient({ + clientName: ClientName.AppManagement, + devStoreCapReached: vi.fn().mockRejectedValue(new Error('field is unavailable')), + }) + + await expect(devStoreCapReached('1', client)).resolves.toBe(false) + }) + + test('fails open when the app-management client does not expose the cap query', async () => { + const client = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + + expect(client.devStoreCapReached).toBeUndefined() + await expect(devStoreCapReached('1', client)).resolves.toBe(false) + }) + + test('does not query Partners clients', async () => { + const client = testDeveloperPlatformClient({ + clientName: ClientName.Partners, + devStoreCapReached: vi.fn().mockResolvedValue(true), + }) + + await expect(devStoreCapReached('1', client)).resolves.toBe(false) + expect(client.devStoreCapReached).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/dev/cap.ts b/packages/app/src/cli/services/dev/cap.ts new file mode 100644 index 00000000000..beb299b6582 --- /dev/null +++ b/packages/app/src/cli/services/dev/cap.ts @@ -0,0 +1,22 @@ +import {ClientName, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' + +/** + * A cap check is advisory. Older Business Platform deployments may not expose the field yet, + * so request and schema errors must not block store creation. + */ +export async function devStoreCapReached( + organizationId: string, + developerPlatformClient: DeveloperPlatformClient, +): Promise { + const capChecker = developerPlatformClient.devStoreCapReached + if (developerPlatformClient.clientName !== ClientName.AppManagement || !capChecker) { + return false + } + + try { + return await capChecker(organizationId) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return false + } +} 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 68a1ad446da..3abfbbad430 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -1,14 +1,20 @@ import {selectStore} from './select-store.js' +import {devStoreCapReached} from './cap.js' +import {fetchStore} from './fetch.js' import {Organization, OrganizationSource, OrganizationStore} from '../../models/organization.js' -import {reloadStoreListPrompt, selectStorePrompt} from '../../prompts/dev.js' +import {devStoreNamePrompt, devStorePlanPrompt, reloadStoreListPrompt, selectStorePrompt} from '../../prompts/dev.js' import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {ClientName} from '../../utilities/developer-platform-client.js' import {sleep} from '@shopify/cli-kit/node/system' -import {renderTasks, Task} from '@shopify/cli-kit/node/ui' -import {describe, expect, vi, test} from 'vitest' +import {isTTY, renderSuccess, renderTasks, Task} from '@shopify/cli-kit/node/ui' +import {AbortError, CancelExecution} from '@shopify/cli-kit/node/error' +import {createDevStore} from '@shopify/organizations' +import {beforeEach, describe, expect, vi, test} from 'vitest' vi.mock('../../prompts/dev') +vi.mock('./cap') vi.mock('./fetch') +vi.mock('@shopify/organizations') vi.mock('@shopify/cli-kit/node/system') vi.mock('@shopify/cli-kit/node/ui') @@ -50,6 +56,23 @@ const STORE3: OrganizationStore = { const defaultShowDomainOnPrompt = false describe('selectStore', async () => { + beforeEach(() => { + vi.mocked(isTTY).mockReturnValue(true) + }) + + test('fails before prompting in a non-interactive environment', async () => { + vi.mocked(isTTY).mockReturnValue(false) + + await expect( + selectStore( + {stores: [STORE1], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + ), + ).rejects.toThrow('No development store was specified.') + expect(selectStorePrompt).not.toHaveBeenCalled() + }) + test('prompts user to select', async () => { // Given vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) @@ -89,6 +112,82 @@ describe('selectStore', async () => { ) }) + test('fails with store guidance when the app-management organization is capped and has no stores', async () => { + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + vi.mocked(devStoreCapReached).mockResolvedValue(true) + + await expect(selectStore({stores: [], hasMorePages: false}, ORG1, developerPlatformClient)).rejects.toThrow( + 'reached its development store limit', + ) + expect(developerPlatformClient.getCreateDevStoreLink).not.toHaveBeenCalled() + expect(selectStorePrompt).not.toHaveBeenCalled() + }) + + test('offers creation when the cap check says the 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], hasMorePages: false}, ORG1, developerPlatformClient)).resolves.toEqual( + STORE1, + ) + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).toHaveProperty('onCreateStore') + }) + + test('hides creation when the app-management organization is capped but has stores', async () => { + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + vi.mocked(devStoreCapReached).mockResolvedValue(true) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + + await expect(selectStore({stores: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient)).resolves.toEqual( + STORE1, + ) + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') + }) + + test('cancels normally when the capped app-management organization has stores and the prompt is cancelled', async () => { + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + vi.mocked(devStoreCapReached).mockResolvedValue(true) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(undefined) + + await expect( + selectStore({stores: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient), + ).rejects.toBeInstanceOf(CancelExecution) + expect(developerPlatformClient.getCreateDevStoreLink).not.toHaveBeenCalled() + }) + + test('creates and refetches an app-management store selected inline', 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) + .mockRejectedValueOnce(new AbortError('Store is still being provisioned')) + .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: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient)).resolves.toEqual( + STORE1, + ) + expect(createDevStore).toHaveBeenCalledWith({ + name: 'created-store', + plan: 'grow', + organization: ORG1, + json: false, + summary: false, + }) + expect(fetchStore).toHaveBeenCalledTimes(2) + expect(renderSuccess).toHaveBeenCalledWith({headline: 'Development store "store1" created successfully.'}) + }) + test('throws if selected store is not transfer-disabled', async () => { // Given vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE2) @@ -187,25 +286,12 @@ describe('selectStore', async () => { expect(res).toContain('https://partners.shopify.com/1234/stores') }) - test('prompts user to create with Developer Dashboard link', async () => { - // Given + test('cancels without the dashboard fallback for app-management', async () => { vi.mocked(selectStorePrompt).mockResolvedValue(undefined) - const developerPlatformClient = testDeveloperPlatformClient({ - clientName: ClientName.AppManagement, - getCreateDevStoreLink: (org: Organization) => - Promise.resolve( - `Looks like you don't have any dev stores associated with ${org.businessName}'s Dev Dashboard. Create a store in Dev Dashboard https://dev.shopify.com/dashboard/1234/stores`, - ), - }) - - // When - const got = selectStore({stores: [], hasMorePages: false}, ORG1, developerPlatformClient) + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) - // Then - await expect(got).rejects.toThrow() - expect(developerPlatformClient.getCreateDevStoreLink).toHaveBeenCalledWith(ORG1) - const res = await Promise.resolve(developerPlatformClient.getCreateDevStoreLink(ORG1)) - expect(res).toContain('https://dev.shopify.com/dashboard/1234/stores') + await expect(selectStore({stores: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient)).rejects.toThrow() + expect(developerPlatformClient.getCreateDevStoreLink).not.toHaveBeenCalled() }) test('enables backend search', async () => { diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index f201d696d53..c202d7b6b47 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -1,15 +1,17 @@ +import {devStoreCapReached} from './cap.js' +import {fetchStore} from './fetch.js' import {Organization, OrganizationStore} from '../../models/organization.js' -import {reloadStoreListPrompt, selectStorePrompt} from '../../prompts/dev.js' +import {devStoreNamePrompt, devStorePlanPrompt, reloadStoreListPrompt, selectStorePrompt} from '../../prompts/dev.js' import {ClientName, DeveloperPlatformClient, Paginateable} from '../../utilities/developer-platform-client.js' import {sleep} from '@shopify/cli-kit/node/system' -import {renderInfo, renderTasks} from '@shopify/cli-kit/node/ui' +import {isTTY, renderInfo, renderSuccess, renderTasks} from '@shopify/cli-kit/node/ui' import {AbortError, CancelExecution} from '@shopify/cli-kit/node/error' +import {createDevStore} from '@shopify/organizations' /** - * Select store from list or - * If a cachedStoreName is provided, we check if it is valid and return it. If it's not valid, ignore it. - * If there are no stores, show a link to create a store and prompt the user to refresh the store list - * If no store is finally selected, exit process + * Select a store from the list or create one when the client supports inline creation. + * If there are no stores, app-management users can create one inline; Partners users use the dashboard link. + * If no store is finally selected, exit the process. * @param stores - List of available stores * @param org - Current organization * @param developerPlatformClient - The client to access the platform API @@ -20,16 +22,46 @@ export async function selectStore( org: Organization, developerPlatformClient: DeveloperPlatformClient, ): Promise { + if (isTTY() === false) { + throw new AbortError( + 'No development store was specified.', + 'Run `app dev --store ` to select a development store.', + ) + } + const showDomainOnPrompt = developerPlatformClient.clientName === ClientName.AppManagement const onSearchForStoresByName = async (term: string) => developerPlatformClient.devStoresForOrg(org.id, term) + const canCreateStore = developerPlatformClient.clientName === ClientName.AppManagement + const creationCapReached = canCreateStore && (await devStoreCapReached(org.id, developerPlatformClient)) + if (creationCapReached && storesSearch.stores.length === 0) { + throw new AbortError(devStoreCapReachedMessage, devStoreCapReachedTryMessage) + } + + const onCreateStore = + canCreateStore && !creationCapReached + ? async () => { + 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 + } + : undefined + // If no stores, guide the developer through creating one. // Then, with a store selected, make sure it's transfer-disabled. let store = await selectStorePrompt({ onSearchForStoresByName, ...storesSearch, showDomainOnPrompt, + ...(onCreateStore ? {onCreateStore} : {}), }) if (!store) { + if (canCreateStore) { + throw new CancelExecution() + } + renderInfo({ body: await developerPlatformClient.getCreateDevStoreLink(org), }) @@ -50,11 +82,58 @@ export async function selectStore( } /** - * Retrieves the list of stores from an organization, retrying a few times if the list is empty. - * That is because after creating the dev store, it can take some seconds for the API to return it. - * @param orgId - Current organization ID + * Retrieves a newly created store by domain, retrying because the API can lag after creation. + * @param org - Current organization + * @param shopDomain - Domain returned by the creation mutation * @param developerPlatformClient - The client to access the platform API - * @returns List of stores + * @returns The created store + */ +async function waitForCreatedStoreByDomain( + org: Organization, + shopDomain: string, + developerPlatformClient: DeveloperPlatformClient, +): Promise { + const retries = 10 + const secondsToWait = 3 + let store: OrganizationStore | undefined + const tasks = [ + { + title: 'Fetching organization data', + task: async () => { + for (let i = 0; i < retries; i++) { + try { + // eslint-disable-next-line no-await-in-loop + const fetchedStore = await fetchStore(org, shopDomain, developerPlatformClient) + if (fetchedStore) { + store = fetchedStore + return + } + } catch (error) { + if (!(error instanceof AbortError)) throw error + } + + // eslint-disable-next-line no-await-in-loop + await sleep(secondsToWait) + } + }, + }, + ] + await renderTasks(tasks) + + if (!store) { + throw new AbortError( + `The newly created development store (${shopDomain}) is not available yet.`, + 'Run `app dev --store ` to select it when it is ready.', + ) + } + + return store +} + +/** + * Retrieves the list of stores from an organization, retrying a few times if the list is empty. + * That is because after creating the dev store through the Partners dashboard, it can take + * some seconds for the API to return it. */ async function waitForCreatedStore( orgId: string, @@ -85,6 +164,9 @@ async function waitForCreatedStore( return data } +const devStoreCapReachedMessage = 'Your organization has reached its development store limit.' +const devStoreCapReachedTryMessage = 'Run `app dev --store ` to select an existing development store.' + /** * Check if the store exists in the current organization and it is a valid store * To be valid, it must be transfer-disabled. diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index 3a1c04790e2..412499c1b8f 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -208,6 +208,7 @@ export interface DeveloperPlatformClient { ) => Promise createApp: (org: Organization, options: CreateAppOptions) => Promise devStoresForOrg: (orgId: string, searchTerm?: string) => Promise> + devStoreCapReached?: (orgId: string) => Promise storeByDomain: (orgId: string, shopDomain: string, storeTypes: Store[]) => Promise ensureUserAccessToStore: (orgId: string, store: OrganizationStore) => Promise appExtensionRegistrations: ( diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index dd868bd532f..225446fb75b 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -70,6 +70,11 @@ import { ListAppDevStores, ListAppDevStoresQuery, } from '../../api/graphql/business-platform-organizations/generated/list_app_dev_stores.js' +import { + DevStoreCapReached, + DevStoreCapReachedQuery, + DevStoreCapReachedQueryVariables, +} from '../../api/graphql/business-platform-organizations/generated/dev_store_cap_reached.js' import { ProvisionShopAccess, ProvisionShopAccessMutationVariables, @@ -557,6 +562,19 @@ export class AppManagementClient implements DeveloperPlatformClient { } } + async devStoreCapReached(orgId: string): Promise { + const result = await this.businessPlatformOrganizationsRequest< + DevStoreCapReachedQuery, + DevStoreCapReachedQueryVariables + >({ + query: DevStoreCapReached, + organizationId: String(numberFromGid(orgId)), + variables: {}, + }) + + return result.organization?.devStoreCapReached ?? false + } + async appExtensionRegistrations( appIdentifiers: MinimalAppIdentifiers, activeAppVersion?: AppVersion, diff --git a/packages/organizations/project.json b/packages/organizations/project.json index 77e6c0edffd..16429e19606 100644 --- a/packages/organizations/project.json +++ b/packages/organizations/project.json @@ -47,23 +47,49 @@ "graphql-codegen:formatting": { "executor": "nx:run-commands", "dependsOn": ["graphql-codegen:postfix"], - "outputs": ["{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts"], + "outputs": [ + "{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/business-platform-organizations/generated/**/*.ts" + ], "options": { - "commands": ["pnpm eslint 'src/cli/api/graphql/business-platform-destinations/generated/**/*.{ts,tsx}' --fix"], + "commands": [ + "pnpm eslint 'src/cli/api/graphql/business-platform-destinations/generated/**/*.{ts,tsx}' --fix", + "pnpm eslint 'src/cli/api/graphql/business-platform-organizations/generated/**/*.{ts,tsx}' --fix" + ], "cwd": "packages/organizations" } }, "graphql-codegen:postfix": { "executor": "nx:run-commands", - "dependsOn": ["graphql-codegen:generate:organizations-destinations"], - "outputs": ["{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts"], + "dependsOn": [ + "graphql-codegen:generate:organizations-destinations", + "graphql-codegen:generate:business-platform-organizations" + ], + "outputs": [ + "{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/business-platform-organizations/generated/**/*.ts" + ], "options": { "commands": [ - "find ./packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" + "find ./packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", + "find ./packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" ], "cwd": "{workspaceRoot}" } }, + "graphql-codegen:generate:business-platform-organizations": { + "executor": "nx:run-commands", + "inputs": [ + "{workspaceRoot}/graphql.config.ts", + "{projectRoot}/src/cli/api/graphql/business-platform-organizations/**/*.graphql", + "sharedGlobals" + ], + "outputs": ["{projectRoot}/src/cli/api/graphql/business-platform-organizations/generated/**/*.ts"], + "options": { + "commands": ["pnpm exec graphql-codegen --project=organizationsBusinessPlatformOrganizations"], + "cwd": "{workspaceRoot}" + } + }, "graphql-codegen:generate:organizations-destinations": { "executor": "nx:run-commands", "inputs": [ diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts b/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts similarity index 100% rename from packages/store/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts rename to packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/poll_store_creation.ts b/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/poll_store_creation.ts similarity index 100% rename from packages/store/src/cli/api/graphql/business-platform-organizations/generated/poll_store_creation.ts rename to packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/poll_store_creation.ts diff --git a/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts new file mode 100644 index 00000000000..8000e6f4115 --- /dev/null +++ b/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, tsdoc/syntax, @typescript-eslint/no-duplicate-type-constituents, @typescript-eslint/no-redundant-type-constituents, @nx/enforce-module-boundaries */ +import {JsonMapType} from '@shopify/cli-kit/node/toml' +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + AccessRoleAssignee: { input: any; output: any; } + /** The ID for a AccessRole. */ + AccessRoleID: { input: any; output: any; } + AccessRoleRecordId: { input: any; output: any; } + /** The ID for a ActionAudit. */ + ActionAuditID: { input: any; output: any; } + /** The ID for a Address. */ + AddressID: { input: any; output: any; } + /** The ID for a Attestation. */ + AttestationID: { input: any; output: any; } + /** The ID for a BulkDataOperation. */ + BulkDataOperationID: { input: any; output: any; } + /** The ID for a BusinessUser. */ + BusinessUserID: { input: any; output: any; } + /** The ID for a BusinessUsersImport. */ + BusinessUsersImportID: { input: any; output: any; } + /** A signed decimal number, which supports arbitrary precision and is serialized as a string. */ + Decimal: { input: any; output: any; } + /** The ID for a DocumentAttachment. */ + DocumentAttachmentID: { input: any; output: any; } + /** The ID for a EntitySupportingDocument. */ + EntitySupportingDocumentID: { input: any; output: any; } + GlobalID: { input: string; output: string; } + /** The ID for a GovernmentIdentifier. */ + GovernmentIdentifierID: { input: any; output: any; } + /** The ID for a Group. */ + GroupID: { input: any; output: any; } + /** An ISO 8601-encoded date */ + ISO8601Date: { input: any; output: any; } + /** An ISO 8601-encoded datetime */ + ISO8601DateTime: { input: any; output: any; } + /** Represents untyped JSON */ + JSON: { input: JsonMapType | string; output: JsonMapType; } + /** The ID for a LegalEntity. */ + LegalEntityID: { input: any; output: any; } + /** The ID for a OrganizationDomain. */ + OrganizationDomainID: { input: any; output: any; } + /** The ID for a Organization. */ + OrganizationID: { input: any; output: any; } + /** The ID for a OrganizationUser. */ + OrganizationUserID: { input: any; output: any; } + /** The ID for a PersonAlias. */ + PersonAliasID: { input: any; output: any; } + /** The ID for a Person. */ + PersonID: { input: any; output: any; } + /** The ID for a Principal. */ + PrincipalID: { input: any; output: any; } + /** The ID for a Property. */ + PropertyID: { input: any; output: any; } + PropertyId: { input: string; output: string; } + PropertyPublicID: { input: string; output: string; } + /** The ID for a PropertyTransferRequest. */ + PropertyTransferRequestID: { input: any; output: any; } + /** The ID for a Role. */ + RoleID: { input: any; output: any; } + /** The ID for a Shop. */ + ShopID: { input: any; output: any; } + /** The ID for a ShopifyShop. */ + ShopifyShopID: { input: any; output: any; } + /** The ID for a StoreAdditionRequest. */ + StoreAdditionRequestID: { input: any; output: any; } + SupportedEntityId: { input: any; output: any; } + /** The ID for a SupportingDocument. */ + SupportingDocumentID: { input: any; output: any; } + /** An RFC 3986 and RFC 3987 compliant URI string. */ + URL: { input: string; output: string; } +}; + +export type StoreCreationStatus = + | 'AWAITING_CORE_STORE_READY' + | 'CALLING_CORE' + | 'COMPLETE' + | 'FAILED' + | 'FINALIZING' + | 'TIMED_OUT' + | 'USER_ERROR'; diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql b/packages/organizations/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql similarity index 100% rename from packages/store/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql rename to packages/organizations/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/queries/poll_store_creation.graphql b/packages/organizations/src/cli/api/graphql/business-platform-organizations/queries/poll_store_creation.graphql similarity index 100% rename from packages/store/src/cli/api/graphql/business-platform-organizations/queries/poll_store_creation.graphql rename to packages/organizations/src/cli/api/graphql/business-platform-organizations/queries/poll_store_creation.graphql diff --git a/packages/organizations/src/cli/prompts/dev.ts b/packages/organizations/src/cli/prompts/dev.ts new file mode 100644 index 00000000000..696fd87d4c1 --- /dev/null +++ b/packages/organizations/src/cli/prompts/dev.ts @@ -0,0 +1,21 @@ +import {devStorePlanHandles} from '../services/dev/create-dev-store.js' +import * as ui from '@shopify/cli-kit/node/ui' +import type {DevStorePlan} from '../services/dev/create-dev-store.js' + +const PLAN_LABELS: {[plan in DevStorePlan]: string} = { + basic: 'Basic', + grow: 'Grow', + advanced: 'Advanced', + plus: 'Plus', +} + +export function devStoreNamePrompt(): Promise { + return ui.renderTextPrompt({message: 'Name for the new development store'}) +} + +export function devStorePlanPrompt(): Promise { + return ui.renderSelectPrompt({ + message: 'Which Shopify plan do you want to use?', + choices: devStorePlanHandles.map((handle) => ({label: PLAN_LABELS[handle], value: handle})), + }) +} diff --git a/packages/organizations/src/cli/services/business-platform.ts b/packages/organizations/src/cli/services/business-platform.ts new file mode 100644 index 00000000000..593a7396f85 --- /dev/null +++ b/packages/organizations/src/cli/services/business-platform.ts @@ -0,0 +1,15 @@ +import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' + +interface BusinessPlatformTokenRefreshHandlerOptions { + noPrompt?: boolean +} + +export function businessPlatformTokenRefreshHandler( + options: BusinessPlatformTokenRefreshHandlerOptions = {}, +): UnauthorizedHandler { + return { + type: 'token_refresh', + handler: async () => ({token: await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt})}), + } +} diff --git a/packages/organizations/src/cli/services/dev/create-dev-store.test.ts b/packages/organizations/src/cli/services/dev/create-dev-store.test.ts new file mode 100644 index 00000000000..d6da30ff688 --- /dev/null +++ b/packages/organizations/src/cli/services/dev/create-dev-store.test.ts @@ -0,0 +1,78 @@ +import {createDevStore} from './create-dev-store.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' + +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {renderSingleTask, renderSuccess} from '@shopify/cli-kit/node/ui' +import {outputResult} from '@shopify/cli-kit/node/output' +import {sleep} from '@shopify/cli-kit/node/system' + +vi.mock('@shopify/cli-kit/node/api/business-platform', () => ({ + businessPlatformOrganizationsRequestDoc: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/session', () => ({ + ensureAuthenticatedBusinessPlatform: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/ui', () => ({ + renderSingleTask: vi.fn(), + renderSuccess: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual: Record = await importOriginal() + return { + ...actual, + outputResult: vi.fn(), + } +}) + +vi.mock('@shopify/cli-kit/node/system', () => ({ + sleep: vi.fn(), +})) + +const defaultOrg = {id: '123', businessName: 'Test Org'} +const defaultMutationResult = { + createAppDevelopmentStore: { + shopAdminUrl: 'https://test-store.myshopify.com/admin', + shopDomain: 'test-store.myshopify.com', + userErrors: [], + }, +} + +beforeEach(() => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + return task(() => {}) + }) + vi.mocked(sleep).mockResolvedValue(undefined) +}) + +describe('createDevStore', () => { + test('returns the polled shop domain without rendering output when summary is false', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + const domain = await createDevStore({ + name: 'test-store', + organization: defaultOrg, + plan: 'plus', + json: false, + summary: false, + }) + + expect(domain).toBe('test-store.myshopify.com') + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenLastCalledWith( + expect.objectContaining({ + variables: {shopDomain: 'test-store.myshopify.com'}, + }), + ) + expect(renderSuccess).not.toHaveBeenCalled() + expect(outputResult).not.toHaveBeenCalled() + }) +}) diff --git a/packages/organizations/src/cli/services/dev/create-dev-store.ts b/packages/organizations/src/cli/services/dev/create-dev-store.ts new file mode 100644 index 00000000000..3622900d74f --- /dev/null +++ b/packages/organizations/src/cli/services/dev/create-dev-store.ts @@ -0,0 +1,183 @@ +import {CreateAppDevelopmentStore} from '../../api/graphql/business-platform-organizations/generated/create_app_development_store.js' +import { + PollStoreCreation, + PollStoreCreationQuery, +} from '../../api/graphql/business-platform-organizations/generated/poll_store_creation.js' +import {Organization} from '../../models/organization.js' +import {businessPlatformTokenRefreshHandler} from '../business-platform.js' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputResult} from '@shopify/cli-kit/node/output' +import {sleep} from '@shopify/cli-kit/node/system' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {renderSingleTask, renderSuccess, type InlineToken} from '@shopify/cli-kit/node/ui' + +/** User-facing plan handles mapped to Business Platform price lookup keys. */ +const DEV_STORE_PLANS = { + basic: 'BASIC_APP_DEVELOPMENT', + grow: 'PROFESSIONAL_APP_DEVELOPMENT', + advanced: 'UNLIMITED_APP_DEVELOPMENT', + plus: 'SHOPIFY_PLUS_APP_DEVELOPMENT', +} as const +export type DevStorePlan = keyof typeof DEV_STORE_PLANS +export const devStorePlanHandles = Object.keys(DEV_STORE_PLANS) as DevStorePlan[] + +const POLL_INTERVAL_SECONDS = 2 +const POLL_TIMEOUT_MS = 5 * 60 * 1000 + +export interface CreateDevStoreOptions { + name: string + plan: DevStorePlan + organization: Organization + featurePreview?: string + withDemoData?: boolean + country?: string + json?: boolean + summary?: boolean +} + +type StoreCreationStatus = NonNullable< + NonNullable['storeCreation']>['status'] +> + +function friendlyStatus(status: StoreCreationStatus): string { + switch (status) { + case 'CALLING_CORE': + return 'Initiating store creation' + case 'AWAITING_CORE_STORE_READY': + return 'Waiting for store to be ready' + case 'FINALIZING': + return 'Finalizing store setup' + case 'COMPLETE': + return 'Store creation complete!' + case 'FAILED': + return 'Store creation failed.' + case 'TIMED_OUT': + return 'Store creation timed out.' + case 'USER_ERROR': + return 'Store creation encountered a user error.' + default: + return `Store creation status: ${status}` + } +} + +export async function createDevStore(options: CreateDevStoreOptions): Promise { + const {organization: org, name, plan} = options + const token = await ensureAuthenticatedBusinessPlatform() + const unauthorizedHandler = businessPlatformTokenRefreshHandler() + + const mutationResult = await businessPlatformOrganizationsRequestDoc({ + query: CreateAppDevelopmentStore, + token, + organizationId: org.id, + variables: { + shopName: name, + priceLookupKey: DEV_STORE_PLANS[plan], + prepopulateTestData: options.withDemoData ?? false, + developerPreviewHandle: options.featurePreview, + country: options.country, + }, + unauthorizedHandler, + }) + + const createAppDevelopmentStore = mutationResult.createAppDevelopmentStore + if (!createAppDevelopmentStore) { + throw new AbortError('Store creation failed: unexpected empty response.') + } + const userErrors = createAppDevelopmentStore.userErrors + if (userErrors && userErrors.length > 0) { + const messages = userErrors.map((error) => error.message).join(', ') + throw new AbortError(`Failed to create development store: ${messages}`) + } + + const {shopDomain, shopAdminUrl} = createAppDevelopmentStore + if (!shopDomain) { + throw new AbortError('Store creation succeeded but no shop domain was returned.') + } + + await renderSingleTask({ + title: outputContent`Waiting for store to be ready`, + task: async (updateStatus) => { + const startTime = Date.now() + while (true) { + if (Date.now() - startTime > POLL_TIMEOUT_MS) { + throw new AbortError('Store creation timed out after 5 minutes.') + } + + // eslint-disable-next-line no-await-in-loop + const pollResult = await businessPlatformOrganizationsRequestDoc({ + query: PollStoreCreation, + token, + organizationId: org.id, + variables: {shopDomain}, + unauthorizedHandler, + }) + + const status = pollResult.organization?.storeCreation?.status + if (!status) { + throw new AbortError('Unable to determine store creation status.') + } + + if (status === 'COMPLETE') { + return + } + if (status === 'FAILED' || status === 'TIMED_OUT' || status === 'USER_ERROR') { + throw new AbortError(`Store creation failed with status: ${status}`) + } + + updateStatus(outputContent`${friendlyStatus(status)}`) + + // eslint-disable-next-line no-await-in-loop + await sleep(POLL_INTERVAL_SECONDS) + } + }, + renderOptions: {stdout: process.stderr}, + }) + + if (options.json) { + outputResult( + JSON.stringify( + { + store: { + name, + domain: shopDomain, + adminUrl: shopAdminUrl, + plan, + ...(options.featurePreview ? {featurePreview: options.featurePreview} : {}), + ...(options.country ? {country: options.country} : {}), + demoData: options.withDemoData ?? false, + }, + organization: { + id: org.id, + name: org.businessName, + }, + }, + null, + 2, + ), + ) + } else if (options.summary !== false) { + const rows: InlineToken[][] = [] + pushRow(rows, 'Domain', shopDomain) + // Admin always renders, falling back to 'N/A' when the URL is missing, so the + // summary never silently drops this commonly expected field. + rows.push(['Admin', shopAdminUrl ? {link: {label: shopAdminUrl, url: shopAdminUrl}} : 'N/A']) + pushRow(rows, 'Plan', plan) + pushRow(rows, 'Feature preview', options.featurePreview) + pushRow(rows, 'Country', options.country) + pushRow(rows, 'Demo data', options.withDemoData ? 'enabled' : 'disabled') + + renderSuccess({ + headline: `Development store "${name}" created successfully.`, + customSections: [{body: {tabularData: rows, firstColumnSubdued: true}}], + }) + } + + return shopDomain +} + +function pushRow(rows: InlineToken[][], label: string, value: InlineToken | undefined): void { + if (value !== undefined && value !== null && value !== '') { + rows.push([label, value]) + } +} diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 317d39c4fd1..2f8ee150163 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -2,3 +2,7 @@ export {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './cli/servic export {selectOrg} from './cli/services/select.js' export {selectOrganizationPrompt} from './cli/prompts/organization.js' export type {Organization} from './cli/models/organization.js' +export {businessPlatformTokenRefreshHandler} from './cli/services/business-platform.js' +export {createDevStore, devStorePlanHandles} from './cli/services/dev/create-dev-store.js' +export type {CreateDevStoreOptions, DevStorePlan} from './cli/services/dev/create-dev-store.js' +export {devStoreNamePrompt, devStorePlanPrompt} from './cli/prompts/dev.js' diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts index 22db1914766..521f2d4b79b 100644 --- a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -89,12 +89,3 @@ export type Store = | 'DEVELOPMENT' | 'DEVELOPMENT_SUPERSET' | 'PRODUCTION'; - -export type StoreCreationStatus = - | 'AWAITING_CORE_STORE_READY' - | 'CALLING_CORE' - | 'COMPLETE' - | 'FAILED' - | 'FINALIZING' - | 'TIMED_OUT' - | 'USER_ERROR'; diff --git a/packages/store/src/cli/commands/store/create/dev.test.ts b/packages/store/src/cli/commands/store/create/dev.test.ts index 5e77f815bcc..962c04a6364 100644 --- a/packages/store/src/cli/commands/store/create/dev.test.ts +++ b/packages/store/src/cli/commands/store/create/dev.test.ts @@ -11,9 +11,16 @@ vi.mock('../../../services/store/create/dev.js') vi.mock('../../../prompts/store.js') vi.mock('@shopify/cli-kit/node/system') -vi.mock('@shopify/organizations', () => ({ - selectOrg: vi.fn(), -})) +vi.mock('@shopify/organizations', async (importOriginal) => { + const actual: Record = await importOriginal() + return { + ...actual, + selectOrg: vi.fn(), + createDevStore: vi.fn(), + devStoreNamePrompt: vi.fn(), + devStorePlanPrompt: vi.fn(), + } +}) vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { const actual: Record = await importOriginal() diff --git a/packages/store/src/cli/prompts/store.ts b/packages/store/src/cli/prompts/store.ts index f184c793c4d..e004a6cffb8 100644 --- a/packages/store/src/cli/prompts/store.ts +++ b/packages/store/src/cli/prompts/store.ts @@ -1,26 +1,4 @@ -import {DevStorePlan, devStorePlanHandles} from '../services/store/constants.js' -import {renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui' +import {devStoreNamePrompt, devStorePlanPrompt} from '@shopify/organizations' -/** Human-readable labels for each `--plan` handle, shown in the interactive plan selector. */ -const PLAN_LABELS: {[plan in DevStorePlan]: string} = { - basic: 'Basic', - grow: 'Grow', - advanced: 'Advanced', - plus: 'Plus', -} - -export async function storeNamePrompt(): Promise { - return renderTextPrompt({ - message: 'Name for the new development store', - }) -} - -export async function storePlanPrompt(): Promise { - return renderSelectPrompt({ - message: 'Which Shopify plan do you want to use?', - choices: devStorePlanHandles.map((handle) => ({ - label: PLAN_LABELS[handle], - value: handle, - })), - }) -} +export const storeNamePrompt = devStoreNamePrompt +export const storePlanPrompt = devStorePlanPrompt diff --git a/packages/store/src/cli/services/store/business-platform.ts b/packages/store/src/cli/services/store/business-platform.ts index 1ce2fefbcfa..01385af7b63 100644 --- a/packages/store/src/cli/services/store/business-platform.ts +++ b/packages/store/src/cli/services/store/business-platform.ts @@ -1,19 +1 @@ -import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' -import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' - -interface BusinessPlatformTokenRefreshHandlerOptions { - noPrompt?: boolean -} - -export function businessPlatformTokenRefreshHandler( - options: BusinessPlatformTokenRefreshHandlerOptions = {}, -): UnauthorizedHandler { - return { - type: 'token_refresh', - handler: async () => ({token: await refreshBusinessPlatformToken(options)}), - } -} - -async function refreshBusinessPlatformToken(options: BusinessPlatformTokenRefreshHandlerOptions): Promise { - return ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt}) -} +export {businessPlatformTokenRefreshHandler} from '@shopify/organizations' diff --git a/packages/store/src/cli/services/store/constants.ts b/packages/store/src/cli/services/store/constants.ts index d0b7b0168a6..6540ac548d8 100644 --- a/packages/store/src/cli/services/store/constants.ts +++ b/packages/store/src/cli/services/store/constants.ts @@ -1,30 +1,5 @@ -/** - * Plan data shared by `store create dev` and `store info`. - * - * The two commands need the plan taxonomy in opposite directions, so each direction is its - * own explicit map below. They're co-located here so the overlap is easy to keep in sync, - * but deliberately written out in full rather than derived from one another — the explicit - * form is far easier to read and reason about than any clever de-duplication. - */ - -/** - * `store create dev`: each user-facing `--plan` handle → the price lookup key the Business - * Platform `createAppDevelopmentStore` mutation expects. The backend argument is a plain - * string with no reusable enum, so this is the canonical source. The handles mirror the - * labels shown in the Dev Dashboard store-creation form. - */ -export const DEV_STORE_PLANS = { - basic: 'BASIC_APP_DEVELOPMENT', - grow: 'PROFESSIONAL_APP_DEVELOPMENT', - advanced: 'UNLIMITED_APP_DEVELOPMENT', - plus: 'SHOPIFY_PLUS_APP_DEVELOPMENT', -} as const - -/** A public, user-facing plan handle accepted by `--plan`. */ -export type DevStorePlan = keyof typeof DEV_STORE_PLANS - -/** The accepted `--plan` values, in display order (the keys of {@link DEV_STORE_PLANS}). */ -export const devStorePlanHandles = Object.keys(DEV_STORE_PLANS) as DevStorePlan[] +export {devStorePlanHandles} from '@shopify/organizations' +export type {DevStorePlan} from '@shopify/organizations' /** * `store info`: a raw BP plan name (`Shop.planName`) → the public plan handle it reports. diff --git a/packages/store/src/cli/services/store/create/dev.ts b/packages/store/src/cli/services/store/create/dev.ts index f6c4bf0ed6e..485ebdb0da3 100644 --- a/packages/store/src/cli/services/store/create/dev.ts +++ b/packages/store/src/cli/services/store/create/dev.ts @@ -1,171 +1 @@ -import {businessPlatformTokenRefreshHandler} from '../business-platform.js' -import {DEV_STORE_PLANS, DevStorePlan} from '../constants.js' -import {CreateAppDevelopmentStore} from '../../../api/graphql/business-platform-organizations/generated/create_app_development_store.js' -import { - PollStoreCreation, - PollStoreCreationQuery, -} from '../../../api/graphql/business-platform-organizations/generated/poll_store_creation.js' -import {Organization} from '@shopify/organizations' -import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' -import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' -import {renderSingleTask, renderSuccess, type InlineToken} from '@shopify/cli-kit/node/ui' -import {outputContent, outputResult} from '@shopify/cli-kit/node/output' -import {AbortError} from '@shopify/cli-kit/node/error' -import {sleep} from '@shopify/cli-kit/node/system' - -const POLL_INTERVAL_SECONDS = 2 -const POLL_TIMEOUT_MS = 5 * 60 * 1000 - -interface CreateDevStoreOptions { - name: string - plan: DevStorePlan - organization: Organization - featurePreview?: string - withDemoData?: boolean - country?: string - json: boolean -} - -type StoreCreationStatus = NonNullable< - NonNullable['storeCreation']>['status'] -> - -function friendlyStatus(status: StoreCreationStatus): string { - switch (status) { - case 'CALLING_CORE': - return 'Initiating store creation' - case 'AWAITING_CORE_STORE_READY': - return 'Waiting for store to be ready' - case 'FINALIZING': - return 'Finalizing store setup' - case 'COMPLETE': - return 'Store creation complete!' - case 'FAILED': - return 'Store creation failed.' - case 'TIMED_OUT': - return 'Store creation timed out.' - case 'USER_ERROR': - return 'Store creation encountered a user error.' - default: - return `Store creation status: ${status}` - } -} - -export async function createDevStore(options: CreateDevStoreOptions): Promise { - const {organization: org, name, plan} = options - const token = await ensureAuthenticatedBusinessPlatform() - const unauthorizedHandler = businessPlatformTokenRefreshHandler() - - const mutationResult = await businessPlatformOrganizationsRequestDoc({ - query: CreateAppDevelopmentStore, - token, - organizationId: org.id, - variables: { - shopName: name, - priceLookupKey: DEV_STORE_PLANS[plan], - prepopulateTestData: options.withDemoData ?? false, - developerPreviewHandle: options.featurePreview, - country: options.country, - }, - unauthorizedHandler, - }) - - const createAppDevelopmentStore = mutationResult.createAppDevelopmentStore - if (!createAppDevelopmentStore) { - throw new AbortError('Store creation failed: unexpected empty response.') - } - const userErrors = createAppDevelopmentStore.userErrors - if (userErrors && userErrors.length > 0) { - const messages = userErrors.map((error) => error.message).join(', ') - throw new AbortError(`Failed to create development store: ${messages}`) - } - - const {shopDomain, shopAdminUrl} = createAppDevelopmentStore - if (!shopDomain) { - throw new AbortError('Store creation succeeded but no shop domain was returned.') - } - - await renderSingleTask({ - title: outputContent`Waiting for store to be ready`, - task: async (updateStatus) => { - const startTime = Date.now() - while (true) { - if (Date.now() - startTime > POLL_TIMEOUT_MS) { - throw new AbortError('Store creation timed out after 5 minutes.') - } - - // eslint-disable-next-line no-await-in-loop - const pollResult = await businessPlatformOrganizationsRequestDoc({ - query: PollStoreCreation, - token, - organizationId: org.id, - variables: {shopDomain}, - unauthorizedHandler, - }) - - const status = pollResult.organization?.storeCreation?.status - if (!status) { - throw new AbortError('Unable to determine store creation status.') - } - - if (status === 'COMPLETE') { - return - } - if (status === 'FAILED' || status === 'TIMED_OUT' || status === 'USER_ERROR') { - throw new AbortError(`Store creation failed with status: ${status}`) - } - - updateStatus(outputContent`${friendlyStatus(status)}`) - - // eslint-disable-next-line no-await-in-loop - await sleep(POLL_INTERVAL_SECONDS) - } - }, - renderOptions: {stdout: process.stderr}, - }) - - if (options.json) { - outputResult( - JSON.stringify( - { - store: { - name, - domain: shopDomain, - adminUrl: shopAdminUrl, - plan, - ...(options.featurePreview ? {featurePreview: options.featurePreview} : {}), - ...(options.country ? {country: options.country} : {}), - demoData: options.withDemoData ?? false, - }, - organization: { - id: org.id, - name: org.businessName, - }, - }, - null, - 2, - ), - ) - } else { - const rows: InlineToken[][] = [] - pushRow(rows, 'Domain', shopDomain) - // Admin always renders, falling back to 'N/A' when the URL is missing, so the - // summary never silently drops this commonly expected field. - rows.push(['Admin', shopAdminUrl ? {link: {label: shopAdminUrl, url: shopAdminUrl}} : 'N/A']) - pushRow(rows, 'Plan', plan) - pushRow(rows, 'Feature preview', options.featurePreview) - pushRow(rows, 'Country', options.country) - pushRow(rows, 'Demo data', options.withDemoData ? 'enabled' : 'disabled') - - renderSuccess({ - headline: `Development store "${name}" created successfully.`, - customSections: [{body: {tabularData: rows, firstColumnSubdued: true}}], - }) - } -} - -function pushRow(rows: InlineToken[][], label: string, value: InlineToken | undefined): void { - if (value !== undefined && value !== null && value !== '') { - rows.push([label, value]) - } -} +export {createDevStore} from '@shopify/organizations' From 5d64c7642a2f54a3a77517b8fe090b0f2020c151 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 25 Aug 2026 11:18:43 -0400 Subject: [PATCH 2/5] Reduce inline store creation comments Assisted-By: devx/f7a432be-8dfa-4e60-9adb-26f49a4df23f --- packages/app/src/cli/services/dev/cap.ts | 4 ---- .../app/src/cli/services/dev/select-store.ts | 23 ++----------------- .../src/cli/services/dev/create-dev-store.ts | 1 - 3 files changed, 2 insertions(+), 26 deletions(-) diff --git a/packages/app/src/cli/services/dev/cap.ts b/packages/app/src/cli/services/dev/cap.ts index beb299b6582..df65a31f6ee 100644 --- a/packages/app/src/cli/services/dev/cap.ts +++ b/packages/app/src/cli/services/dev/cap.ts @@ -1,9 +1,5 @@ import {ClientName, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -/** - * A cap check is advisory. Older Business Platform deployments may not expose the field yet, - * so request and schema errors must not block store creation. - */ export async function devStoreCapReached( organizationId: string, developerPlatformClient: DeveloperPlatformClient, diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index c202d7b6b47..10420cd87be 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -8,15 +8,7 @@ import {isTTY, renderInfo, renderSuccess, renderTasks} from '@shopify/cli-kit/no import {AbortError, CancelExecution} from '@shopify/cli-kit/node/error' import {createDevStore} from '@shopify/organizations' -/** - * Select a store from the list or create one when the client supports inline creation. - * If there are no stores, app-management users can create one inline; Partners users use the dashboard link. - * If no store is finally selected, exit the process. - * @param stores - List of available stores - * @param org - Current organization - * @param developerPlatformClient - The client to access the platform API - * @returns The selected store - */ +/** Selects or creates an eligible development store. */ export async function selectStore( storesSearch: Paginateable<{stores: OrganizationStore[]}>, org: Organization, @@ -81,13 +73,6 @@ export async function selectStore( return store } -/** - * Retrieves a newly created store by domain, retrying because the API can lag after creation. - * @param org - Current organization - * @param shopDomain - Domain returned by the creation mutation - * @param developerPlatformClient - The client to access the platform API - * @returns The created store - */ async function waitForCreatedStoreByDomain( org: Organization, shopDomain: string, @@ -130,11 +115,7 @@ async function waitForCreatedStoreByDomain( return store } -/** - * Retrieves the list of stores from an organization, retrying a few times if the list is empty. - * That is because after creating the dev store through the Partners dashboard, it can take - * some seconds for the API to return it. - */ +/** Store list updates can lag after dashboard creation. */ async function waitForCreatedStore( orgId: string, developerPlatformClient: DeveloperPlatformClient, diff --git a/packages/organizations/src/cli/services/dev/create-dev-store.ts b/packages/organizations/src/cli/services/dev/create-dev-store.ts index 3622900d74f..6a96e667450 100644 --- a/packages/organizations/src/cli/services/dev/create-dev-store.ts +++ b/packages/organizations/src/cli/services/dev/create-dev-store.ts @@ -12,7 +12,6 @@ import {sleep} from '@shopify/cli-kit/node/system' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' import {renderSingleTask, renderSuccess, type InlineToken} from '@shopify/cli-kit/node/ui' -/** User-facing plan handles mapped to Business Platform price lookup keys. */ const DEV_STORE_PLANS = { basic: 'BASIC_APP_DEVELOPMENT', grow: 'PROFESSIONAL_APP_DEVELOPMENT', From e612c9ccfa6b5dc20d2385cecdfa8bddcdc14d1b Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 25 Aug 2026 11:57:56 -0400 Subject: [PATCH 3/5] Limit inline store creation to app dev with no usable stores Assisted-By: devx/15860998-2e86-4ccf-83df-18c0908d7bed --- .changeset/create-dev-store-from-app.md | 2 +- packages/app/src/cli/commands/app/dev.test.ts | 1 + packages/app/src/cli/commands/app/dev.ts | 1 + packages/app/src/cli/prompts/dev.test.ts | 61 +++++------- packages/app/src/cli/prompts/dev.ts | 18 ++-- .../src/cli/services/dev/select-store.test.ts | 93 ++++++++++++++----- .../app/src/cli/services/dev/select-store.ts | 46 ++++----- .../src/cli/services/store-context.test.ts | 26 ++++++ .../app/src/cli/services/store-context.ts | 7 +- 9 files changed, 155 insertions(+), 100 deletions(-) diff --git a/.changeset/create-dev-store-from-app.md b/.changeset/create-dev-store-from-app.md index da91dfa8929..fd55986e428 100644 --- a/.changeset/create-dev-store-from-app.md +++ b/.changeset/create-dev-store-from-app.md @@ -2,4 +2,4 @@ '@shopify/app': minor --- -Allow `app dev` to create a development store inline from store selection. +Allow `app dev` to create a development store when the organization has none. diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index 26ac10c63ee..e0eab301ac4 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -58,6 +58,7 @@ describe('app dev command', () => { tunnelUrl: undefined, localhostPort: undefined, }) + expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeCreationMode: 'when-empty'})) 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 387824ba0ac..b0af6478d64 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -134,6 +134,7 @@ export default class Dev extends AppLinkedCommand { appContextResult, storeFqdn: flags.store, forceReselectStore: flags.reset, + storeCreationMode: 'when-empty', }) const devOptions: DevOptions = { diff --git a/packages/app/src/cli/prompts/dev.test.ts b/packages/app/src/cli/prompts/dev.test.ts index 28f65a5d00f..8d28e35180e 100644 --- a/packages/app/src/cli/prompts/dev.test.ts +++ b/packages/app/src/cli/prompts/dev.test.ts @@ -143,34 +143,34 @@ describe('selectStore', () => { expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project') }) - test('offers creating a store when a creation handler is provided', async () => { - const stores: OrganizationStore[] = [STORE1] - const createdStore = {...STORE2, shopId: 'created'} - const onCreateStore = vi.fn().mockResolvedValue(createdStore) - vi.mocked(renderAutocompletePrompt).mockResolvedValue('__create_new_dev_store__') - - const got = await selectStorePrompt({stores, showDomainOnPrompt: defaultShowDomainOnPrompt, onCreateStore}) + test('creates directly when the list is empty and a creation handler is provided', async () => { + const onCreateStoreWhenEmpty = vi.fn().mockResolvedValue(STORE1) - 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, + const got = await selectStorePrompt({ + stores: [], + showDomainOnPrompt: defaultShowDomainOnPrompt, + onCreateStoreWhenEmpty, }) + + expect(got).toEqual(STORE1) + expect(onCreateStoreWhenEmpty).toHaveBeenCalledOnce() + expect(renderAutocompletePrompt).not.toHaveBeenCalled() }) - test('creates directly when the list is empty and a creation handler is provided', async () => { - const onCreateStore = vi.fn().mockResolvedValue(STORE1) + test('returns the only store without creating when a creation handler is provided', async () => { + const onCreateStoreWhenEmpty = vi.fn().mockResolvedValue(STORE2) + const outputMock = mockAndCaptureOutput() - const got = await selectStorePrompt({stores: [], showDomainOnPrompt: defaultShowDomainOnPrompt, onCreateStore}) + const got = await selectStorePrompt({ + stores: [STORE1], + showDomainOnPrompt: defaultShowDomainOnPrompt, + onCreateStoreWhenEmpty, + }) expect(got).toEqual(STORE1) - expect(onCreateStore).toHaveBeenCalledOnce() - expect(renderAutocompletePrompt).not.toHaveBeenCalled() + expect(onCreateStoreWhenEmpty).not.toHaveBeenCalled() + expect(renderAutocompletePrompt).not.toBeCalled() + expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project') }) test('returns store if user selects one', async () => { @@ -219,25 +219,6 @@ describe('selectStore', () => { expect(lastCall[0]).not.toHaveProperty('search') }) - 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 correct store if user selects one after searching', 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 7c36b4203c9..6bd86c6bfad 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -69,7 +69,7 @@ interface SelectStorePromptOptions { stores: OrganizationStore[] hasMorePages?: boolean showDomainOnPrompt: boolean - onCreateStore?: () => Promise + onCreateStoreWhenEmpty?: () => Promise } interface ExtraAutoCompletePropsForStoreSelect { @@ -81,10 +81,10 @@ export async function selectStorePrompt({ hasMorePages = false, onSearchForStoresByName, showDomainOnPrompt = true, - onCreateStore, + onCreateStoreWhenEmpty, }: SelectStorePromptOptions): Promise { - if (stores.length === 0) return onCreateStore?.() - if (stores.length === 1 && !onCreateStore) { + if (stores.length === 0) return onCreateStoreWhenEmpty?.() + if (stores.length === 1) { outputCompleted(`Using your default dev store, ${stores[0]!.shopName}, to preview your project.`) return stores[0] } @@ -98,11 +98,6 @@ export async function selectStorePrompt({ } let currentStores = stores - 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) { @@ -111,7 +106,7 @@ export async function selectStorePrompt({ currentStores = result.stores return { - data: choices(), + data: currentStores.map(storeToChoice), meta: { hasNextPage: result.hasMorePages, }, @@ -121,11 +116,10 @@ export async function selectStorePrompt({ const id = await renderAutocompletePrompt({ message: 'Which store would you like to use to view your project?', - choices: choices(), + choices: currentStores.map(storeToChoice), hasMorePages, ...extraAutocompletePromptProps, }) - if (id === createStoreChoice) return onCreateStore?.() return currentStores.find((store) => store.shopId === 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 3abfbbad430..285407085bf 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -60,7 +60,7 @@ describe('selectStore', async () => { vi.mocked(isTTY).mockReturnValue(true) }) - test('fails before prompting in a non-interactive environment', async () => { + test('fails before prompting in a non-interactive environment when store creation is enabled', async () => { vi.mocked(isTTY).mockReturnValue(false) await expect( @@ -68,11 +68,25 @@ describe('selectStore', async () => { {stores: [STORE1], hasMorePages: false}, ORG1, testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + 'when-empty', ), ).rejects.toThrow('No development store was specified.') expect(selectStorePrompt).not.toHaveBeenCalled() }) + test('keeps prompting in a non-interactive environment when store creation is disabled', async () => { + vi.mocked(isTTY).mockReturnValue(false) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + + await expect( + selectStore( + {stores: [STORE1], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + ), + ).resolves.toEqual(STORE1) + }) + test('prompts user to select', async () => { // Given vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) @@ -110,53 +124,66 @@ describe('selectStore', async () => { showDomainOnPrompt: true, }), ) + expect(devStoreCapReached).not.toHaveBeenCalled() }) - test('fails with store guidance when the app-management organization is capped and has no stores', async () => { + test('fails with store guidance when the enabled app-management organization is capped and has no stores', async () => { const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) vi.mocked(devStoreCapReached).mockResolvedValue(true) - await expect(selectStore({stores: [], hasMorePages: false}, ORG1, developerPlatformClient)).rejects.toThrow( - 'reached its development store limit', - ) + await expect( + selectStore({stores: [], hasMorePages: false}, ORG1, developerPlatformClient, 'when-empty'), + ).rejects.toThrow('reached its development store limit') expect(developerPlatformClient.getCreateDevStoreLink).not.toHaveBeenCalled() expect(selectStorePrompt).not.toHaveBeenCalled() }) - test('offers creation when the cap check says the organization is not capped', async () => { + test('offers creation when the enabled app-management organization has no stores and 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], hasMorePages: false}, ORG1, developerPlatformClient)).resolves.toEqual( - STORE1, - ) - expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).toHaveProperty('onCreateStore') + await expect( + selectStore({stores: [], hasMorePages: false}, ORG1, developerPlatformClient, 'when-empty'), + ).resolves.toEqual(STORE1) + expect(devStoreCapReached).toHaveBeenCalledWith(ORG1.id, developerPlatformClient) + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).toHaveProperty('onCreateStoreWhenEmpty') }) - test('hides creation when the app-management organization is capped but has stores', async () => { + test('does not query the cap or offer creation when the enabled app-management organization has stores', async () => { + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + + await expect( + 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') + }) + + test('does not offer creation when store creation is disabled', async () => { const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) - vi.mocked(devStoreCapReached).mockResolvedValue(true) vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) await expect(selectStore({stores: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient)).resolves.toEqual( STORE1, ) - expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') + expect(devStoreCapReached).not.toHaveBeenCalled() + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty') }) - test('cancels normally when the capped app-management organization has stores and the prompt is cancelled', async () => { + test('cancels normally when the enabled app-management organization has stores and the prompt is cancelled', async () => { const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) - vi.mocked(devStoreCapReached).mockResolvedValue(true) vi.mocked(selectStorePrompt).mockResolvedValueOnce(undefined) await expect( - selectStore({stores: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient), + selectStore({stores: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient, 'when-empty'), ).rejects.toBeInstanceOf(CancelExecution) expect(developerPlatformClient.getCreateDevStoreLink).not.toHaveBeenCalled() + expect(devStoreCapReached).not.toHaveBeenCalled() }) - test('creates and refetches an app-management store selected inline', async () => { + test('creates and refetches an app-management store when the organization has none', async () => { const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) vi.mocked(devStoreCapReached).mockResolvedValue(false) vi.mocked(devStoreNamePrompt).mockResolvedValue('created-store') @@ -172,11 +199,11 @@ describe('selectStore', async () => { } return {} }) - vi.mocked(selectStorePrompt).mockImplementation(async ({onCreateStore}) => onCreateStore!()) + vi.mocked(selectStorePrompt).mockImplementation(async ({onCreateStoreWhenEmpty}) => onCreateStoreWhenEmpty!()) - await expect(selectStore({stores: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient)).resolves.toEqual( - STORE1, - ) + await expect( + selectStore({stores: [], hasMorePages: false}, ORG1, developerPlatformClient, 'when-empty'), + ).resolves.toEqual(STORE1) expect(createDevStore).toHaveBeenCalledWith({ name: 'created-store', plan: 'grow', @@ -286,12 +313,30 @@ describe('selectStore', async () => { expect(res).toContain('https://partners.shopify.com/1234/stores') }) - test('cancels without the dashboard fallback for app-management', async () => { + test('keeps the dashboard fallback for app-management when store creation is disabled', async () => { vi.mocked(selectStorePrompt).mockResolvedValue(undefined) + vi.mocked(reloadStoreListPrompt).mockResolvedValue(false) const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) - await expect(selectStore({stores: [STORE1], hasMorePages: false}, ORG1, developerPlatformClient)).rejects.toThrow() - expect(developerPlatformClient.getCreateDevStoreLink).not.toHaveBeenCalled() + await expect(selectStore({stores: [], hasMorePages: false}, ORG1, developerPlatformClient)).rejects.toBeInstanceOf( + CancelExecution, + ) + expect(developerPlatformClient.getCreateDevStoreLink).toHaveBeenCalledWith(ORG1) + expect(devStoreCapReached).not.toHaveBeenCalled() + expect(createDevStore).not.toHaveBeenCalled() + }) + + test('keeps the Partners dashboard fallback when store creation is enabled', async () => { + vi.mocked(selectStorePrompt).mockResolvedValue(undefined) + vi.mocked(reloadStoreListPrompt).mockResolvedValue(false) + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.Partners}) + + await expect( + selectStore({stores: [], hasMorePages: false}, ORG1, developerPlatformClient, 'when-empty'), + ).rejects.toBeInstanceOf(CancelExecution) + expect(developerPlatformClient.getCreateDevStoreLink).toHaveBeenCalledWith(ORG1) + expect(devStoreCapReached).not.toHaveBeenCalled() + expect(createDevStore).not.toHaveBeenCalled() }) test('enables backend search', async () => { diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index 10420cd87be..eef93abc3ac 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -8,13 +8,17 @@ import {isTTY, renderInfo, renderSuccess, renderTasks} from '@shopify/cli-kit/no import {AbortError, CancelExecution} from '@shopify/cli-kit/node/error' import {createDevStore} from '@shopify/organizations' -/** Selects or creates an eligible development store. */ +/** Store creation from store selection is an explicit command opt-in. */ +export type StoreCreationMode = 'disabled' | 'when-empty' + +/** Selects an eligible development store, or creates one when creation is enabled and the organization has none. */ export async function selectStore( storesSearch: Paginateable<{stores: OrganizationStore[]}>, org: Organization, developerPlatformClient: DeveloperPlatformClient, + storeCreationMode: StoreCreationMode = 'disabled', ): Promise { - if (isTTY() === false) { + if (storeCreationMode === 'when-empty' && isTTY() === false) { throw new AbortError( 'No development store was specified.', 'Run `app dev --store ` to select a development store.', @@ -23,23 +27,23 @@ export async function selectStore( const showDomainOnPrompt = developerPlatformClient.clientName === ClientName.AppManagement const onSearchForStoresByName = async (term: string) => developerPlatformClient.devStoresForOrg(org.id, term) - const canCreateStore = developerPlatformClient.clientName === ClientName.AppManagement - const creationCapReached = canCreateStore && (await devStoreCapReached(org.id, developerPlatformClient)) - if (creationCapReached && storesSearch.stores.length === 0) { - throw new AbortError(devStoreCapReachedMessage, devStoreCapReachedTryMessage) - } + const storeCreationEnabled = + storeCreationMode === 'when-empty' && developerPlatformClient.clientName === ClientName.AppManagement - const onCreateStore = - canCreateStore && !creationCapReached - ? async () => { - 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 - } - : undefined + let onCreateStoreWhenEmpty: (() => Promise) | undefined + if (storeCreationEnabled && storesSearch.stores.length === 0) { + if (await devStoreCapReached(org.id, developerPlatformClient)) { + throw new AbortError(devStoreCapReachedMessage, devStoreCapReachedTryMessage) + } + onCreateStoreWhenEmpty = async () => { + 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 + } + } // If no stores, guide the developer through creating one. // Then, with a store selected, make sure it's transfer-disabled. @@ -47,10 +51,10 @@ export async function selectStore( onSearchForStoresByName, ...storesSearch, showDomainOnPrompt, - ...(onCreateStore ? {onCreateStore} : {}), + ...(onCreateStoreWhenEmpty ? {onCreateStoreWhenEmpty} : {}), }) if (!store) { - if (canCreateStore) { + if (storeCreationEnabled) { throw new CancelExecution() } @@ -65,7 +69,7 @@ export async function selectStore( } const stores = await waitForCreatedStore(org.id, developerPlatformClient) - store = await selectStore({stores, hasMorePages: false}, org, developerPlatformClient) + store = await selectStore({stores, hasMorePages: false}, org, developerPlatformClient, storeCreationMode) } ensureTransferDisabledStore(store) diff --git a/packages/app/src/cli/services/store-context.test.ts b/packages/app/src/cli/services/store-context.test.ts index 7312aeb52c1..5b45cb206b0 100644 --- a/packages/app/src/cli/services/store-context.test.ts +++ b/packages/app/src/cli/services/store-context.test.ts @@ -111,11 +111,36 @@ describe('storeContext', () => { {stores: allStores, hasMorePages: false}, mockOrganization, mockDeveloperPlatformClient, + 'disabled', ) expect(result).toEqual(mockStore) }) }) + test('forwards the store creation mode to selectStore', async () => { + await inTemporaryDirectory(async (dir) => { + const appWithoutCachedStore = testAppLinked() + await prepareAppFolder(appWithoutCachedStore, dir) + + vi.mocked(mockDeveloperPlatformClient.devStoresForOrg).mockResolvedValue({stores: [], hasMorePages: false}) + vi.mocked(selectStore).mockResolvedValue(mockStore) + + const updatedAppContextResult = {...appContextResult, app: appWithoutCachedStore} + await storeContext({ + appContextResult: updatedAppContextResult, + forceReselectStore: false, + storeCreationMode: 'when-empty', + }) + + expect(selectStore).toHaveBeenCalledWith( + {stores: [], hasMorePages: false}, + mockOrganization, + mockDeveloperPlatformClient, + 'when-empty', + ) + }) + }) + test('fetches and selects store when forceReselectStore is true', async () => { await inTemporaryDirectory(async (dir) => { const allStores = [mockStore, {...mockStore, shopId: 'store2', shopDomain: 'another-store.myshopify.com'}] @@ -133,6 +158,7 @@ describe('storeContext', () => { {stores: allStores, hasMorePages: false}, mockOrganization, mockDeveloperPlatformClient, + 'disabled', ) expect(result).toEqual(mockStore) }) diff --git a/packages/app/src/cli/services/store-context.ts b/packages/app/src/cli/services/store-context.ts index 05f9f9b3673..2c6bc2b06ff 100644 --- a/packages/app/src/cli/services/store-context.ts +++ b/packages/app/src/cli/services/store-context.ts @@ -1,5 +1,5 @@ import {fetchStore} from './dev/fetch.js' -import {ensureTransferDisabledStore, selectStore} from './dev/select-store.js' +import {ensureTransferDisabledStore, selectStore, StoreCreationMode} from './dev/select-store.js' import {LoadedAppContextOutput} from './app-context.js' import {OrganizationStore} from '../models/organization.js' import {Store} from '../utilities/developer-platform-client.js' @@ -14,12 +14,14 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' * @param forceReselectStore - Whether to force reselecting the store. * @param storeFqdn - a store FQDN, optional, when explicitly provided it has preference over anything else. * @param storeTypes - Store types to filter by. Defaults to dev stores only. + * @param storeCreationMode - When store creation is offered during store selection. Defaults to disabled. */ interface StoreContextOptions { appContextResult: LoadedAppContextOutput forceReselectStore: boolean storeFqdn?: string storeTypes?: Store[] + storeCreationMode?: StoreCreationMode } /** @@ -34,6 +36,7 @@ export async function storeContext({ storeFqdn, forceReselectStore, storeTypes = ['APP_DEVELOPMENT'], + storeCreationMode = 'disabled', }: StoreContextOptions): Promise { const {app, organization, developerPlatformClient} = appContextResult let selectedStore: OrganizationStore @@ -60,7 +63,7 @@ export async function storeContext({ } else { // If no storeFqdn is provided, fetch all stores for the organization and let the user select one. const allStores = await developerPlatformClient.devStoresForOrg(organization.id) - selectedStore = await selectStore(allStores, organization, developerPlatformClient) + selectedStore = await selectStore(allStores, organization, developerPlatformClient, storeCreationMode) } selectedStore.shopDomain = normalizeStoreFqdn(selectedStore.shopDomain) From cf825a30bc6e4562005af3e3025aa2afefc75789 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 25 Aug 2026 12:04:41 -0400 Subject: [PATCH 4/5] Keep the client receiver for the dev store cap query Assisted-By: devx/15860998-2e86-4ccf-83df-18c0908d7bed --- packages/app/src/cli/services/dev/cap.test.ts | 11 ++++++++++- packages/app/src/cli/services/dev/cap.ts | 5 ++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/services/dev/cap.test.ts b/packages/app/src/cli/services/dev/cap.test.ts index 7b1a26dfd58..41576bf384b 100644 --- a/packages/app/src/cli/services/dev/cap.test.ts +++ b/packages/app/src/cli/services/dev/cap.test.ts @@ -1,6 +1,6 @@ import {devStoreCapReached} from './cap.js' import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' -import {ClientName} from '../../utilities/developer-platform-client.js' +import {ClientName, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {describe, expect, test, vi} from 'vitest' describe('devStoreCapReached', () => { @@ -14,6 +14,15 @@ describe('devStoreCapReached', () => { expect(client.devStoreCapReached).toHaveBeenCalledWith('1') }) + test('keeps the client receiver when the cap query uses this', async () => { + const client = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + client.devStoreCapReached = async function (this: DeveloperPlatformClient) { + return this.clientName === ClientName.AppManagement + } + + await expect(devStoreCapReached('1', client)).resolves.toBe(true) + }) + test('fails open when the cap request fails', async () => { const client = testDeveloperPlatformClient({ clientName: ClientName.AppManagement, diff --git a/packages/app/src/cli/services/dev/cap.ts b/packages/app/src/cli/services/dev/cap.ts index df65a31f6ee..8bc93833e20 100644 --- a/packages/app/src/cli/services/dev/cap.ts +++ b/packages/app/src/cli/services/dev/cap.ts @@ -4,13 +4,12 @@ export async function devStoreCapReached( organizationId: string, developerPlatformClient: DeveloperPlatformClient, ): Promise { - const capChecker = developerPlatformClient.devStoreCapReached - if (developerPlatformClient.clientName !== ClientName.AppManagement || !capChecker) { + if (developerPlatformClient.clientName !== ClientName.AppManagement || !developerPlatformClient.devStoreCapReached) { return false } try { - return await capChecker(organizationId) + return await developerPlatformClient.devStoreCapReached(organizationId) // eslint-disable-next-line no-catch-all/no-catch-all } catch { return false From 025856396f9c65f9ad0fcb5b33a50f95e049f3ec Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 25 Aug 2026 12:35:23 -0400 Subject: [PATCH 5/5] Scope the non-TTY store guard to zero-store inline creation Assisted-By: devx/db75e101-7096-4e34-99c9-44980a6d6919 --- .../src/cli/services/dev/select-store.test.ts | 47 +++++++++++++++++-- .../app/src/cli/services/dev/select-store.ts | 14 +++--- 2 files changed, 50 insertions(+), 11 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 285407085bf..fea2dad3bd4 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -60,18 +60,56 @@ describe('selectStore', async () => { vi.mocked(isTTY).mockReturnValue(true) }) - test('fails before prompting in a non-interactive environment when store creation is enabled', async () => { + test('fails before the prompt in a non-interactive environment when the enabled app-management organization has no stores', async () => { vi.mocked(isTTY).mockReturnValue(false) await expect( selectStore( - {stores: [STORE1], hasMorePages: false}, + {stores: [], hasMorePages: false}, ORG1, testDeveloperPlatformClient({clientName: ClientName.AppManagement}), 'when-empty', ), - ).rejects.toThrow('No development store was specified.') + ).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('auto-selects the only app-management store in a non-interactive environment when store creation is enabled', async () => { + vi.mocked(isTTY).mockReturnValue(false) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + + await expect( + selectStore( + {stores: [STORE1], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + 'when-empty', + ), + ).resolves.toEqual(STORE1) + expect(devStoreCapReached).not.toHaveBeenCalled() + expect(createDevStore).not.toHaveBeenCalled() + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty') + }) + + test('auto-selects the only Partners store in a non-interactive environment when store creation is enabled', async () => { + vi.mocked(isTTY).mockReturnValue(false) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + + await expect( + selectStore( + {stores: [STORE1], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.Partners}), + 'when-empty', + ), + ).resolves.toEqual(STORE1) + expect(devStoreCapReached).not.toHaveBeenCalled() + expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty') }) test('keeps prompting in a non-interactive environment when store creation is disabled', async () => { @@ -326,7 +364,8 @@ describe('selectStore', async () => { expect(createDevStore).not.toHaveBeenCalled() }) - test('keeps the Partners dashboard fallback when store creation is enabled', async () => { + test('keeps the Partners dashboard fallback in a non-interactive environment when store creation is enabled', async () => { + vi.mocked(isTTY).mockReturnValue(false) vi.mocked(selectStorePrompt).mockResolvedValue(undefined) vi.mocked(reloadStoreListPrompt).mockResolvedValue(false) const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.Partners}) diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index eef93abc3ac..cb502868c1e 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -18,13 +18,6 @@ export async function selectStore( developerPlatformClient: DeveloperPlatformClient, storeCreationMode: StoreCreationMode = 'disabled', ): Promise { - if (storeCreationMode === 'when-empty' && isTTY() === false) { - throw new AbortError( - 'No development store was specified.', - 'Run `app dev --store ` to select a development store.', - ) - } - const showDomainOnPrompt = developerPlatformClient.clientName === ClientName.AppManagement const onSearchForStoresByName = async (term: string) => developerPlatformClient.devStoresForOrg(org.id, term) const storeCreationEnabled = @@ -32,6 +25,13 @@ export async function selectStore( let onCreateStoreWhenEmpty: (() => Promise) | undefined if (storeCreationEnabled && storesSearch.stores.length === 0) { + // Inline store creation needs an interactive terminal. + if (isTTY() === false) { + throw new AbortError( + 'No development store was specified.', + 'Run `app dev --store ` to select a development store.', + ) + } if (await devStoreCapReached(org.id, developerPlatformClient)) { throw new AbortError(devStoreCapReachedMessage, devStoreCapReachedTryMessage) }