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..68275751e56 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -1,4 +1,9 @@ import {Organization, MinimalOrganizationApp, OrganizationStore, MinimalAppIdentifiers} from '../models/organization.js' +import { + devStoreNamePrompt as sharedDevStoreNamePrompt, + devStorePlanPrompt as sharedDevStorePlanPrompt, +} from '@shopify/organizations' +import type {DevStorePlan} from '@shopify/organizations' 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' @@ -11,6 +16,16 @@ import { } from '@shopify/cli-kit/node/ui' import {outputCompleted} from '@shopify/cli-kit/node/output' +export type {DevStorePlan} + +export function devStoreNamePrompt(): Promise { + return sharedDevStoreNamePrompt() +} + +export function devStorePlanPrompt(): Promise { + return sharedDevStorePlanPrompt() +} + export async function selectAppPrompt( onSearchForAppsByName: (term: string) => Promise<{apps: MinimalOrganizationApp[]; hasMorePages: boolean}>, apps: MinimalOrganizationApp[], @@ -56,6 +71,7 @@ interface SelectStorePromptOptions { stores: OrganizationStore[] hasMorePages?: boolean showDomainOnPrompt: boolean + onCreateStore?: () => Promise } interface ExtraAutoCompletePropsForStoreSelect { @@ -67,9 +83,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 +100,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 +113,7 @@ export async function selectStorePrompt({ currentStores = result.stores return { - data: currentStores.map(storeToChoice), + data: choices(), meta: { hasNextPage: result.hasMorePages, }, @@ -101,10 +123,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/context.test.ts b/packages/app/src/cli/services/context.test.ts index 252ea3f06cf..13a035b47bd 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -132,7 +132,7 @@ beforeEach(async () => { vi.mocked(getAppIdentifiers).mockReturnValue({}) vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) vi.mocked(selectOrCreateApp).mockResolvedValue(APP1) - vi.mocked(selectStore).mockResolvedValue(STORE1) + vi.mocked(selectStore).mockResolvedValue({store: STORE1, created: false}) vi.mocked(fetchOrganizations).mockResolvedValue([ORG1, ORG2]) vi.mocked(fetchOrgFromId).mockResolvedValue(ORG1) vi.mocked(getPackageManager).mockResolvedValue('npm') 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..e03db016964 --- /dev/null +++ b/packages/app/src/cli/services/dev/cap.test.ts @@ -0,0 +1,35 @@ +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('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..98ab9bb9bc5 --- /dev/null +++ b/packages/app/src/cli/services/dev/cap.ts @@ -0,0 +1,21 @@ +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) + } 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..80a368162b2 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,25 @@ 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} 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 +61,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('Run `app dev --store `') + expect(selectStorePrompt).not.toHaveBeenCalled() + }) + test('prompts user to select', async () => { // Given vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) @@ -62,7 +90,7 @@ describe('selectStore', async () => { ) // Then - expect(got).toEqual(STORE1) + expect(got).toEqual({store: STORE1, created: false}) expect(selectStorePrompt).toHaveBeenCalledWith( expect.objectContaining({ stores: [STORE1, STORE2], @@ -80,7 +108,7 @@ describe('selectStore', async () => { const got = await selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient) // Then - expect(got).toEqual(STORE1) + expect(got).toEqual({store: STORE1, created: false}) expect(selectStorePrompt).toHaveBeenCalledWith( expect.objectContaining({ stores: [STORE1, STORE2], @@ -89,6 +117,74 @@ 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({ + store: STORE1, + created: false, + }) + expect(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({ + store: STORE1, + created: false, + }) + expect(selectStorePrompt.mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore') + }) + + 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({ + store: STORE1, + created: true, + }) + 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 +283,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 () => { @@ -220,7 +303,7 @@ describe('selectStore', async () => { ) // Then - expect(got).toEqual(STORE1) + expect(got).toEqual({store: STORE1, created: false}) expect(selectStorePrompt).toHaveBeenCalledWith( expect.objectContaining({ stores: [STORE1, STORE2], diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index f201d696d53..837e83acf5e 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -1,35 +1,78 @@ 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 {devStoreCapReached} from './cap.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' +import {fetchStore} from './fetch.js' + +export interface SelectStoreResult { + store: OrganizationStore + created: boolean +} /** - * 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 - * @returns The selected store + * @returns The selected store and whether the CLI created it */ export async function selectStore( storesSearch: Paginateable<{stores: OrganizationStore[]}>, org: Organization, developerPlatformClient: DeveloperPlatformClient, -): Promise { +): 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)) + let created = false + + if (creationCapReached && storesSearch.stores.length === 0) { + throw devStoreCapReachedError() + } + + 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) + created = true + 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 (creationCapReached) { + throw devStoreCapReachedError() + } + if (canCreateStore) { + throw new CancelExecution() + } + renderInfo({ body: await developerPlatformClient.getCreateDevStoreLink(org), }) @@ -41,20 +84,69 @@ export async function selectStore( } const stores = await waitForCreatedStore(org.id, developerPlatformClient) - store = await selectStore({stores, hasMorePages: false}, org, developerPlatformClient) + const selection = await selectStore({stores, hasMorePages: false}, org, developerPlatformClient) + store = selection.store + created = selection.created } ensureTransferDisabledStore(store) + return {store, created} +} + +/** + * 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, + 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, it can take some seconds for the API to return it. - * @param orgId - Current organization ID - * @param developerPlatformClient - The client to access the platform API - * @returns List of stores + * 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 +177,13 @@ async function waitForCreatedStore( return data } +function devStoreCapReachedError(): AbortError { + return new AbortError( + 'Your organization has reached its development store limit.', + '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/services/store-context.test.ts b/packages/app/src/cli/services/store-context.test.ts index 7312aeb52c1..b07c8e859f9 100644 --- a/packages/app/src/cli/services/store-context.test.ts +++ b/packages/app/src/cli/services/store-context.test.ts @@ -98,7 +98,7 @@ describe('storeContext', () => { const allStores = [mockStore, {...mockStore, shopId: 'store2', shopDomain: 'another-store.myshopify.com'}] vi.mocked(mockDeveloperPlatformClient.devStoresForOrg).mockResolvedValue({stores: allStores, hasMorePages: false}) - vi.mocked(selectStore).mockResolvedValue(mockStore) + vi.mocked(selectStore).mockResolvedValue({store: mockStore, created: false}) const updatedAppContextResult = {...appContextResult, app: appWithoutCachedStore} const result = await storeContext({ @@ -121,7 +121,7 @@ describe('storeContext', () => { const allStores = [mockStore, {...mockStore, shopId: 'store2', shopDomain: 'another-store.myshopify.com'}] await prepareAppFolder(mockApp, dir) vi.mocked(mockDeveloperPlatformClient.devStoresForOrg).mockResolvedValue({stores: allStores, hasMorePages: false}) - vi.mocked(selectStore).mockResolvedValue(mockStore) + vi.mocked(selectStore).mockResolvedValue({store: mockStore, created: false}) const result = await storeContext({ appContextResult, @@ -156,6 +156,22 @@ describe('storeContext', () => { ) }) + test('records when the selected store was created inline', async () => { + await inTemporaryDirectory(async (dir) => { + const appWithoutCachedStore = testAppLinked() + await prepareAppFolder(appWithoutCachedStore, dir) + vi.mocked(mockDeveloperPlatformClient.devStoresForOrg).mockResolvedValue({stores: [mockStore], hasMorePages: false}) + vi.mocked(selectStore).mockResolvedValue({store: mockStore, created: true}) + + await storeContext({ + appContextResult: {...appContextResult, app: appWithoutCachedStore}, + forceReselectStore: false, + }) + + expect(metadata.getAllPublicMetadata()).toEqual(expect.objectContaining({cmd_dev_store_created: true})) + }) + }) + test('calls logMetadata', async () => { await inTemporaryDirectory(async (dir) => { vi.mocked(fetchStore).mockResolvedValue(mockStore) diff --git a/packages/app/src/cli/services/store-context.ts b/packages/app/src/cli/services/store-context.ts index 05f9f9b3673..809c6140620 100644 --- a/packages/app/src/cli/services/store-context.ts +++ b/packages/app/src/cli/services/store-context.ts @@ -37,6 +37,7 @@ export async function storeContext({ }: StoreContextOptions): Promise { const {app, organization, developerPlatformClient} = appContextResult let selectedStore: OrganizationStore + let storeCreated = false const devStoreUrlFromAppConfig = app.configuration.build?.dev_store_url const devStoreUrlFromHiddenConfig = app.hiddenConfig.dev_store_url @@ -60,11 +61,13 @@ 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) + const selection = await selectStore(allStores, organization, developerPlatformClient) + selectedStore = selection.store + storeCreated = selection.created } selectedStore.shopDomain = normalizeStoreFqdn(selectedStore.shopDomain) - await logMetadata(selectedStore, forceReselectStore) + await logMetadata(selectedStore, forceReselectStore, storeCreated) // Save the selected store in the hidden config file if (selectedStore.shopDomain !== cachedStoreURL || !devStoreUrlFromHiddenConfig) { @@ -77,11 +80,12 @@ export async function storeContext({ return selectedStore } -async function logMetadata(selectedStore: OrganizationStore, resetUsed: boolean) { +async function logMetadata(selectedStore: OrganizationStore, resetUsed: boolean, storeCreated: boolean) { await metadata.addPublicMetadata(() => ({ cmd_app_reset_used: resetUsed, store_fqdn_hash: hashString(selectedStore.shopDomain), store_domain: selectedStore.shopDomain, + cmd_dev_store_created: storeCreated, })) await metadata.addSensitiveMetadata(() => ({ 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/cli-kit/src/public/node/monorail.ts b/packages/cli-kit/src/public/node/monorail.ts index c797cfe2e88..5517fce31a0 100644 --- a/packages/cli-kit/src/public/node/monorail.ts +++ b/packages/cli-kit/src/public/node/monorail.ts @@ -109,6 +109,7 @@ export interface Schemas { cmd_dev_preview_url_opened?: Optional cmd_dev_graphiql_opened?: Optional cmd_dev_dev_preview_toggle_used?: Optional + cmd_dev_store_created?: Optional // Create-app related commands cmd_create_app_template?: Optional diff --git a/packages/organizations/project.json b/packages/organizations/project.json index 77e6c0edffd..8bae0a698d9 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 sed -i \"\" \"s|from './types'|from './types.js'|g; s|Document =| =|g\" {} +", + "find ./packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/ -type f -name '*.ts' -exec sed -i \"\" \"s|from './types'|from './types.js'|g; s|Document =| =|g\" {} +" ], "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..22db1914766 --- /dev/null +++ b/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -0,0 +1,100 @@ +/* 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 Store = + | 'APP_DEVELOPMENT' + | 'CLIENT_TRANSFER' + | 'COLLABORATOR' + | '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/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..4aeb9eb1d2b --- /dev/null +++ b/packages/organizations/src/cli/prompts/dev.ts @@ -0,0 +1,25 @@ +import * as ui from '@shopify/cli-kit/node/ui' + +import {DEV_STORE_PLANS, devStorePlanHandles} from '../services/dev/create-dev-store.js' +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})), + }) +} + +export {DEV_STORE_PLANS} +export type {DevStorePlan} 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..7f7b73a5769 --- /dev/null +++ b/packages/organizations/src/cli/services/dev/create-dev-store.ts @@ -0,0 +1,196 @@ +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 {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' +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' + +interface BusinessPlatformTokenRefreshHandlerOptions { + noPrompt?: boolean +} + +export function businessPlatformTokenRefreshHandler( + options: BusinessPlatformTokenRefreshHandlerOptions = {}, +): UnauthorizedHandler { + return { + type: 'token_refresh', + handler: async () => ({token: await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt})}), + } +} + +/** User-facing plan handles mapped to Business Platform price lookup keys. */ +export 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..478cacdd34b 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, createDevStore} from './cli/services/dev/create-dev-store.js' +export type {CreateDevStoreOptions, DevStorePlan} from './cli/services/dev/create-dev-store.js' +export {DEV_STORE_PLANS, devStorePlanHandles} 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/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..e73ea522786 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 {DEV_STORE_PLANS, 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..fa9ee311cfb 100644 --- a/packages/store/src/cli/services/store/create/dev.ts +++ b/packages/store/src/cli/services/store/create/dev.ts @@ -1,171 +1,2 @@ -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' +export type {CreateDevStoreOptions} from '@shopify/organizations'