diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index a43dda537c7..8c3ff713253 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5737,6 +5737,70 @@ "strict": true, "summary": "Authenticate an app against a store for store commands." }, + "store:create:dev": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/store", + "description": "Creates a new app development store in your organization.", + "descriptionWithMarkdown": "Creates a new app development store in your organization.", + "enableJsonFlag": false, + "flags": { + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "name": { + "description": "Name for the new development store.", + "env": "SHOPIFY_FLAG_STORE_NAME", + "hasDynamicHelp": false, + "multiple": false, + "name": "name", + "required": true, + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "organization-id": { + "description": "The organization to create the store in (numeric ID). Auto-selects if you belong to a single org.", + "env": "SHOPIFY_FLAG_ORGANIZATION_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "organization-id", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "store:create:dev", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Create a new development store." + }, "store:execute": { "aliases": [ ], diff --git a/packages/store/package.json b/packages/store/package.json index 432e2ecb4d2..203f0674b1a 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -41,7 +41,8 @@ "dependencies": { "@graphql-typed-document-node/core": "3.2.0", "@oclif/core": "4.11.4", - "@shopify/cli-kit": "4.1.0" + "@shopify/cli-kit": "4.1.0", + "@shopify/organizations": "4.1.0" }, "devDependencies": { "@vitest/coverage-istanbul": "^3.2.6" diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts new file mode 100644 index 00000000000..cc226285301 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts @@ -0,0 +1,93 @@ +/* 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 CreateAppDevelopmentStoreMutationVariables = Types.Exact<{ + shopName: Types.Scalars['String']['input'] + priceLookupKey: Types.Scalars['String']['input'] + prepopulateTestData?: Types.InputMaybe +}> + +export type CreateAppDevelopmentStoreMutation = { + createAppDevelopmentStore: { + shopAdminUrl?: string | null + shopDomain?: string | null + userErrors?: {code?: string | null; field: string[]; message: string}[] | null + } +} + +export const CreateAppDevelopmentStore = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: {kind: 'Name', value: 'CreateAppDevelopmentStore'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'shopName'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'priceLookupKey'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'prepopulateTestData'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'Boolean'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'createAppDevelopmentStore'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'shopName'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'shopName'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'priceLookupKey'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'priceLookupKey'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'prepopulateTestData'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'prepopulateTestData'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'shopAdminUrl'}}, + {kind: 'Field', name: {kind: 'Name', value: 'shopDomain'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'userErrors'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'code'}}, + {kind: 'Field', name: {kind: 'Name', value: 'field'}}, + {kind: 'Field', name: {kind: 'Name', value: 'message'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/poll_store_creation.ts b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/poll_store_creation.ts new file mode 100644 index 00000000000..7134e5f1982 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/poll_store_creation.ts @@ -0,0 +1,64 @@ +/* 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 PollStoreCreationQueryVariables = Types.Exact<{ + shopDomain: Types.Scalars['String']['input'] +}> + +export type PollStoreCreationQuery = { + organization?: {id: string; storeCreation?: {status: Types.StoreCreationStatus} | null} | null +} + +export const PollStoreCreation = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'PollStoreCreation'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'shopDomain'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'organization'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'storeCreation'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'shopDomain'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'shopDomain'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode 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 521f2d4b79b..22db1914766 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,3 +89,12 @@ 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/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql b/packages/store/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql new file mode 100644 index 00000000000..58d998e875f --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql @@ -0,0 +1,15 @@ +mutation CreateAppDevelopmentStore($shopName: String!, $priceLookupKey: String!, $prepopulateTestData: Boolean) { + createAppDevelopmentStore( + shopName: $shopName + priceLookupKey: $priceLookupKey + prepopulateTestData: $prepopulateTestData + ) { + shopAdminUrl + shopDomain + userErrors { + code + field + message + } + } +} diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/queries/poll_store_creation.graphql b/packages/store/src/cli/api/graphql/business-platform-organizations/queries/poll_store_creation.graphql new file mode 100644 index 00000000000..9ce8bfaa94a --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/queries/poll_store_creation.graphql @@ -0,0 +1,8 @@ +query PollStoreCreation($shopDomain: String!) { + organization { + id + storeCreation(shopDomain: $shopDomain) { + status + } + } +} diff --git a/packages/store/src/cli/commands/store/create/dev.test.ts b/packages/store/src/cli/commands/store/create/dev.test.ts new file mode 100644 index 00000000000..92d38d6b4bb --- /dev/null +++ b/packages/store/src/cli/commands/store/create/dev.test.ts @@ -0,0 +1,88 @@ +import StoreCreateDev from './dev.js' +import {createDevStore} from '../../../services/store/create/dev.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../../services/store/create/dev.js') + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual: Record = await importOriginal() + return { + ...actual, + outputResult: vi.fn(), + } +}) + +describe('store create dev command', () => { + test('passes parsed flags through to the service', async () => { + await StoreCreateDev.run(['--name', 'my-test-store']) + + expect(createDevStore).toHaveBeenCalledWith({ + name: 'my-test-store', + organizationId: undefined, + json: false, + }) + }) + + test('passes organization-id flag through to the service', async () => { + await StoreCreateDev.run(['--name', 'my-test-store', '--organization-id', '12345']) + + expect(createDevStore).toHaveBeenCalledWith({ + name: 'my-test-store', + organizationId: 12345, + json: false, + }) + }) + + test('passes json flag through to the service', async () => { + await StoreCreateDev.run(['--name', 'my-test-store', '--json']) + + expect(createDevStore).toHaveBeenCalledWith({ + name: 'my-test-store', + organizationId: undefined, + json: true, + }) + }) + + test('defines the expected flags', () => { + expect(StoreCreateDev.flags.name).toBeDefined() + expect(StoreCreateDev.flags['organization-id']).toBeDefined() + expect(StoreCreateDev.flags.json).toBeDefined() + }) + + test('outputs structured JSON error when --json is active and service throws AbortError', async () => { + vi.mocked(createDevStore).mockRejectedValueOnce(new AbortError('Something went wrong')) + const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + + await expect(StoreCreateDev.run(['--name', 'my-test-store', '--json'])).rejects.toThrow('process.exit') + + const call = vi.mocked(outputResult).mock.calls[0]![0] as string + const parsed = JSON.parse(call) + expect(parsed).toEqual({ + error: true, + message: 'Something went wrong', + nextSteps: [], + exitCode: 1, + }) + expect(mockExit).toHaveBeenCalledWith(1) + + mockExit.mockRestore() + }) + + test('does not output JSON for non-AbortError even when --json is active', async () => { + vi.mocked(createDevStore).mockRejectedValueOnce(new Error('unexpected')) + + await expect(StoreCreateDev.run(['--name', 'my-test-store', '--json'])).rejects.toThrow() + expect(vi.mocked(outputResult)).not.toHaveBeenCalled() + }) + + test('does not output JSON for AbortError when --json is not active', async () => { + vi.mocked(createDevStore).mockRejectedValueOnce(new AbortError('Something went wrong')) + + await expect(StoreCreateDev.run(['--name', 'my-test-store'])).rejects.toThrow() + expect(vi.mocked(outputResult)).not.toHaveBeenCalled() + }) +}) diff --git a/packages/store/src/cli/commands/store/create/dev.ts b/packages/store/src/cli/commands/store/create/dev.ts new file mode 100644 index 00000000000..a9648d9c671 --- /dev/null +++ b/packages/store/src/cli/commands/store/create/dev.ts @@ -0,0 +1,56 @@ +import {createDevStore} from '../../../services/store/create/dev.js' +import {storeFlags} from '../../../flags.js' +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' +import {Flags} from '@oclif/core' + +export default class StoreCreateDev extends Command { + static hidden = true + + static summary = 'Create a new development store.' + + static descriptionWithMarkdown = 'Creates a new app development store in your organization.' + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + ...jsonFlag, + name: Flags.string({ + description: 'Name for the new development store.', + required: true, + env: 'SHOPIFY_FLAG_STORE_NAME', + }), + 'organization-id': storeFlags['organization-id'], + } + + async run(): Promise { + const {flags} = await this.parse(StoreCreateDev) + try { + await createDevStore({ + name: flags.name, + organizationId: flags['organization-id'], + json: flags.json, + }) + } catch (error) { + if (flags.json && error instanceof AbortError) { + outputResult( + JSON.stringify( + { + error: true, + message: error.message, + nextSteps: error.nextSteps ?? [], + exitCode: 1, + }, + null, + 2, + ), + ) + process.exit(1) + } + throw error + } + } +} diff --git a/packages/store/src/cli/flags.ts b/packages/store/src/cli/flags.ts index 541d7ecda73..167df2d1371 100644 --- a/packages/store/src/cli/flags.ts +++ b/packages/store/src/cli/flags.ts @@ -9,4 +9,8 @@ export const storeFlags = { parse: async (input) => normalizeStoreFqdn(input), required: true, }), + 'organization-id': Flags.integer({ + description: 'The organization to create the store in (numeric ID). Auto-selects if you belong to a single org.', + env: 'SHOPIFY_FLAG_ORGANIZATION_ID', + }), } diff --git a/packages/store/src/cli/services/store/create/dev.test.ts b/packages/store/src/cli/services/store/create/dev.test.ts new file mode 100644 index 00000000000..2de09072f6a --- /dev/null +++ b/packages/store/src/cli/services/store/create/dev.test.ts @@ -0,0 +1,229 @@ +import {createDevStore} from './dev.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' + +import {selectOrg} from '@shopify/organizations' +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/organizations', () => ({ + selectOrg: vi.fn(), +})) + +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(selectOrg).mockResolvedValue(defaultOrg) + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue(defaultMutationResult) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + return task(() => {}) + }) + vi.mocked(sleep).mockResolvedValue(undefined) +}) + +describe('createDevStore', () => { + test('creates a development store and renders success', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({name: 'test-store', json: false}) + + expect(selectOrg).toHaveBeenCalledWith(undefined) + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalled() + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.anything(), + token: 'test-token', + organizationId: '123', + variables: { + shopName: 'test-store', + priceLookupKey: 'SHOPIFY_PLUS_APP_DEVELOPMENT', + prepopulateTestData: false, + }, + }), + ) + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + headline: expect.stringContaining('test-store'), + }), + ) + }) + + test('outputs JSON when --json flag is set', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({name: 'test-store', json: true}) + + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"domain": "test-store.myshopify.com"')) + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('throws AbortError when mutation returns null createAppDevelopmentStore', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + createAppDevelopmentStore: null, + }) + + await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow('unexpected empty response') + }) + + test('throws AbortError when mutation returns userErrors', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + createAppDevelopmentStore: { + shopAdminUrl: null, + shopDomain: null, + userErrors: [{code: 'INVALID', field: ['shopName'], message: 'Name is taken'}], + }, + }) + + await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow('Name is taken') + }) + + test('throws AbortError when mutation returns no shopDomain', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + createAppDevelopmentStore: { + shopAdminUrl: null, + shopDomain: null, + userErrors: [], + }, + }) + + await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow('no shop domain was returned') + }) + + test('throws AbortError when polling returns FAILED status', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'FAILED'}}, + }) + + await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow( + 'Store creation failed with status: FAILED', + ) + }) + + test('throws AbortError when polling returns TIMED_OUT status', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'TIMED_OUT'}}, + }) + + await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow( + 'Store creation failed with status: TIMED_OUT', + ) + }) + + test('throws AbortError when polling returns USER_ERROR status', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'USER_ERROR'}}, + }) + + await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow( + 'Store creation failed with status: USER_ERROR', + ) + }) + + test('throws AbortError when polling times out after 5 minutes', async () => { + let callCount = 0 + vi.spyOn(Date, 'now').mockImplementation(() => { + callCount++ + // First call returns start time, second call exceeds timeout + if (callCount <= 1) return 0 + return 6 * 60 * 1000 + }) + + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(defaultMutationResult) + + await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow( + 'Store creation timed out after 5 minutes', + ) + }) + + test('passes organization-id flag to selectOrg as a string', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({name: 'test-store', organizationId: 456, json: false}) + + expect(selectOrg).toHaveBeenCalledWith('456') + }) + + test('calls sleep with 2 seconds between polls', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'CALLING_CORE'}}, + }) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({name: 'test-store', json: false}) + + expect(sleep).toHaveBeenCalledWith(2) + }) + + test('renders progress to stderr via renderOptions', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({name: 'test-store', json: false}) + + expect(renderSingleTask).toHaveBeenCalledWith( + expect.objectContaining({ + renderOptions: {stdout: process.stderr}, + }), + ) + }) +}) diff --git a/packages/store/src/cli/services/store/create/dev.ts b/packages/store/src/cli/services/store/create/dev.ts new file mode 100644 index 00000000000..bfb43918c96 --- /dev/null +++ b/packages/store/src/cli/services/store/create/dev.ts @@ -0,0 +1,149 @@ +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 {selectOrg} from '@shopify/organizations' +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 {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 + organizationId?: number + 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 org = await selectOrg(options.organizationId?.toString()) + const token = await ensureAuthenticatedBusinessPlatform() + const unauthorizedHandler = { + type: 'token_refresh' as const, + handler: async () => { + const newToken = await ensureAuthenticatedBusinessPlatform() + return {token: newToken} + }, + } + + const mutationResult = await businessPlatformOrganizationsRequestDoc({ + query: CreateAppDevelopmentStore, + token, + organizationId: org.id, + variables: { + shopName: options.name, + priceLookupKey: 'SHOPIFY_PLUS_APP_DEVELOPMENT', + prepopulateTestData: false, + }, + 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: options.name, + domain: shopDomain, + adminUrl: shopAdminUrl, + }, + organization: { + id: org.id, + name: org.businessName, + }, + }, + null, + 2, + ), + ) + } else { + renderSuccess({ + headline: `Development store "${options.name}" created successfully.`, + body: [`Domain: ${shopDomain}`, `Admin: ${shopAdminUrl ?? 'N/A'}`], + }) + } +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 98924c2328f..341b95abd3c 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,9 +1,11 @@ import StoreAuth from './cli/commands/store/auth.js' +import StoreCreateDev from './cli/commands/store/create/dev.js' import StoreExecute from './cli/commands/store/execute.js' import StoreInfo from './cli/commands/store/info.js' const COMMANDS = { 'store:auth': StoreAuth, + 'store:create:dev': StoreCreateDev, 'store:execute': StoreExecute, 'store:info': StoreInfo, } diff --git a/packages/store/tsconfig.json b/packages/store/tsconfig.json index ea7490fa22f..b860755b2f5 100644 --- a/packages/store/tsconfig.json +++ b/packages/store/tsconfig.json @@ -8,6 +8,7 @@ "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, "references": [ - {"path": "../cli-kit"} + {"path": "../cli-kit"}, + {"path": "../organizations"} ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4849c71fbd..e073fd26aea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -676,6 +676,9 @@ importers: '@shopify/cli-kit': specifier: 4.1.0 version: link:../cli-kit + '@shopify/organizations': + specifier: 4.1.0 + version: link:../organizations devDependencies: '@vitest/coverage-istanbul': specifier: ^3.2.6