diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 8c3ff713253..4ef90b4481b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5747,6 +5747,14 @@ "descriptionWithMarkdown": "Creates a new app development store in your organization.", "enableJsonFlag": false, "flags": { + "feature-preview": { + "description": "The handle of a feature preview to enable on the new development store.", + "env": "SHOPIFY_FLAG_STORE_FEATURE_PREVIEW", + "hasDynamicHelp": false, + "multiple": false, + "name": "feature-preview", + "type": "option" + }, "json": { "allowNo": false, "char": "j", @@ -5781,6 +5789,21 @@ "name": "organization-id", "type": "option" }, + "plan": { + "description": "The Shopify plan to use for the new development store.", + "env": "SHOPIFY_FLAG_STORE_PLAN", + "hasDynamicHelp": false, + "multiple": false, + "name": "plan", + "options": [ + "basic", + "grow", + "advanced", + "plus" + ], + "required": true, + "type": "option" + }, "verbose": { "allowNo": false, "description": "Increase the verbosity of the output.", @@ -5788,6 +5811,13 @@ "hidden": false, "name": "verbose", "type": "boolean" + }, + "with-demo-data": { + "allowNo": false, + "description": "Populate the new development store with demo data.", + "env": "SHOPIFY_FLAG_STORE_WITH_DEMO_DATA", + "name": "with-demo-data", + "type": "boolean" } }, "hasDynamicHelp": false, 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 index cc226285301..ed940362975 100644 --- 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 @@ -7,6 +7,7 @@ export type CreateAppDevelopmentStoreMutationVariables = Types.Exact<{ shopName: Types.Scalars['String']['input'] priceLookupKey: Types.Scalars['String']['input'] prepopulateTestData?: Types.InputMaybe + developerPreviewHandle?: Types.InputMaybe }> export type CreateAppDevelopmentStoreMutation = { @@ -40,6 +41,11 @@ export const CreateAppDevelopmentStore = { variable: {kind: 'Variable', name: {kind: 'Name', value: 'prepopulateTestData'}}, type: {kind: 'NamedType', name: {kind: 'Name', value: 'Boolean'}}, }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'developerPreviewHandle'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, ], selectionSet: { kind: 'SelectionSet', @@ -63,6 +69,11 @@ export const CreateAppDevelopmentStore = { name: {kind: 'Name', value: 'prepopulateTestData'}, value: {kind: 'Variable', name: {kind: 'Name', value: 'prepopulateTestData'}}, }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'developerPreviewHandle'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'developerPreviewHandle'}}, + }, ], selectionSet: { kind: 'SelectionSet', 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 index 58d998e875f..4c2b7277baf 100644 --- 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 @@ -1,8 +1,9 @@ -mutation CreateAppDevelopmentStore($shopName: String!, $priceLookupKey: String!, $prepopulateTestData: Boolean) { +mutation CreateAppDevelopmentStore($shopName: String!, $priceLookupKey: String!, $prepopulateTestData: Boolean, $developerPreviewHandle: String) { createAppDevelopmentStore( shopName: $shopName priceLookupKey: $priceLookupKey prepopulateTestData: $prepopulateTestData + developerPreviewHandle: $developerPreviewHandle ) { shopAdminUrl shopDomain 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 92d38d6b4bb..405c9f85b0b 100644 --- a/packages/store/src/cli/commands/store/create/dev.test.ts +++ b/packages/store/src/cli/commands/store/create/dev.test.ts @@ -16,38 +16,81 @@ vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { describe('store create dev command', () => { test('passes parsed flags through to the service', async () => { - await StoreCreateDev.run(['--name', 'my-test-store']) + await StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'plus']) expect(createDevStore).toHaveBeenCalledWith({ name: 'my-test-store', organizationId: undefined, + plan: 'plus', + featurePreview: undefined, + withDemoData: false, json: false, }) }) test('passes organization-id flag through to the service', async () => { - await StoreCreateDev.run(['--name', 'my-test-store', '--organization-id', '12345']) + await StoreCreateDev.run(['--name', 'my-test-store', '--organization-id', '12345', '--plan', 'plus']) expect(createDevStore).toHaveBeenCalledWith({ name: 'my-test-store', organizationId: 12345, + plan: 'plus', + featurePreview: undefined, + withDemoData: false, json: false, }) }) test('passes json flag through to the service', async () => { - await StoreCreateDev.run(['--name', 'my-test-store', '--json']) + await StoreCreateDev.run(['--name', 'my-test-store', '--json', '--plan', 'plus']) expect(createDevStore).toHaveBeenCalledWith({ name: 'my-test-store', organizationId: undefined, + plan: 'plus', + featurePreview: undefined, + withDemoData: false, json: true, }) }) + test('passes plan, feature-preview, and with-demo-data flags through to the service', async () => { + await StoreCreateDev.run([ + '--name', + 'my-test-store', + '--plan', + 'basic', + '--feature-preview', + 'extended_variants', + '--with-demo-data', + ]) + + expect(createDevStore).toHaveBeenCalledWith({ + name: 'my-test-store', + organizationId: undefined, + plan: 'basic', + featurePreview: 'extended_variants', + withDemoData: true, + json: false, + }) + }) + + test('rejects an invalid plan value without calling the service', async () => { + await expect(StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'enterprise'])).rejects.toThrow() + expect(createDevStore).not.toHaveBeenCalled() + }) + + test('requires the plan flag', async () => { + await expect(StoreCreateDev.run(['--name', 'my-test-store'])).rejects.toThrow() + expect(createDevStore).not.toHaveBeenCalled() + }) + test('defines the expected flags', () => { expect(StoreCreateDev.flags.name).toBeDefined() expect(StoreCreateDev.flags['organization-id']).toBeDefined() + expect(StoreCreateDev.flags.plan).toBeDefined() + expect(StoreCreateDev.flags['feature-preview']).toBeDefined() + expect(StoreCreateDev.flags['with-demo-data']).toBeDefined() expect(StoreCreateDev.flags.json).toBeDefined() }) @@ -57,7 +100,9 @@ describe('store create dev command', () => { throw new Error('process.exit') }) as never) - await expect(StoreCreateDev.run(['--name', 'my-test-store', '--json'])).rejects.toThrow('process.exit') + await expect(StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'plus', '--json'])).rejects.toThrow( + 'process.exit', + ) const call = vi.mocked(outputResult).mock.calls[0]![0] as string const parsed = JSON.parse(call) @@ -75,14 +120,14 @@ describe('store create dev command', () => { 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() + await expect(StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'plus', '--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() + await expect(StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'plus'])).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 index a9648d9c671..5fade41803f 100644 --- a/packages/store/src/cli/commands/store/create/dev.ts +++ b/packages/store/src/cli/commands/store/create/dev.ts @@ -1,4 +1,5 @@ import {createDevStore} from '../../../services/store/create/dev.js' +import {devStorePlanHandles, DevStorePlan} from '../../../services/store/constants.js' import {storeFlags} from '../../../flags.js' import Command from '@shopify/cli-kit/node/base-command' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' @@ -24,6 +25,21 @@ export default class StoreCreateDev extends Command { env: 'SHOPIFY_FLAG_STORE_NAME', }), 'organization-id': storeFlags['organization-id'], + plan: Flags.string({ + description: 'The Shopify plan to use for the new development store.', + options: devStorePlanHandles, + required: true, + env: 'SHOPIFY_FLAG_STORE_PLAN', + }), + 'feature-preview': Flags.string({ + description: 'The handle of a feature preview to enable on the new development store.', + env: 'SHOPIFY_FLAG_STORE_FEATURE_PREVIEW', + }), + 'with-demo-data': Flags.boolean({ + description: 'Populate the new development store with demo data.', + default: false, + env: 'SHOPIFY_FLAG_STORE_WITH_DEMO_DATA', + }), } async run(): Promise { @@ -32,6 +48,9 @@ export default class StoreCreateDev extends Command { await createDevStore({ name: flags.name, organizationId: flags['organization-id'], + plan: flags.plan as DevStorePlan, + featurePreview: flags['feature-preview'], + withDemoData: flags['with-demo-data'], json: flags.json, }) } catch (error) { diff --git a/packages/store/src/cli/services/store/constants.ts b/packages/store/src/cli/services/store/constants.ts new file mode 100644 index 00000000000..d0b7b0168a6 --- /dev/null +++ b/packages/store/src/cli/services/store/constants.ts @@ -0,0 +1,44 @@ +/** + * 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[] + +/** + * `store info`: a raw BP plan name (`Shop.planName`) → the public plan handle it reports. + * The raw names are Shopify-internal and intentionally differ from the marketing names + * (e.g. `professional` is Grow, `unlimited` is Advanced). The public handle is also accepted + * as a key, because the exact form BP returns isn't pinned down by the schema. Anything not + * listed here is treated as unrecognized and omitted from the output. + */ +export const PLAN_HANDLES_BY_NAME: {[planName: string]: string} = { + basic: 'basic', + professional: 'grow', + grow: 'grow', + unlimited: 'advanced', + advanced: 'advanced', + shopify_plus: 'plus', + plus: 'plus', +} diff --git a/packages/store/src/cli/services/store/create/dev.test.ts b/packages/store/src/cli/services/store/create/dev.test.ts index 2de09072f6a..724cb4e0ca9 100644 --- a/packages/store/src/cli/services/store/create/dev.test.ts +++ b/packages/store/src/cli/services/store/create/dev.test.ts @@ -64,7 +64,7 @@ describe('createDevStore', () => { organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, }) - await createDevStore({name: 'test-store', json: false}) + await createDevStore({name: 'test-store', plan: 'plus', json: false}) expect(selectOrg).toHaveBeenCalledWith(undefined) expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalled() @@ -77,6 +77,7 @@ describe('createDevStore', () => { shopName: 'test-store', priceLookupKey: 'SHOPIFY_PLUS_APP_DEVELOPMENT', prepopulateTestData: false, + developerPreviewHandle: undefined, }, }), ) @@ -87,6 +88,79 @@ describe('createDevStore', () => { ) }) + test.each([ + ['basic', 'BASIC_APP_DEVELOPMENT'], + ['grow', 'PROFESSIONAL_APP_DEVELOPMENT'], + ['advanced', 'UNLIMITED_APP_DEVELOPMENT'], + ['plus', 'SHOPIFY_PLUS_APP_DEVELOPMENT'], + ] as const)('maps the %s plan to the %s price lookup key', async (plan, priceLookupKey) => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({name: 'test-store', plan, json: false}) + + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({priceLookupKey}), + }), + ) + }) + + test('passes the feature preview as developerPreviewHandle', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({name: 'test-store', plan: 'plus', featurePreview: 'extended_variants', json: false}) + + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({developerPreviewHandle: 'extended_variants'}), + }), + ) + }) + + test('passes prepopulateTestData when --with-demo-data is set', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({name: 'test-store', plan: 'plus', withDemoData: true, json: false}) + + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({prepopulateTestData: true}), + }), + ) + }) + + test('includes plan, feature preview, and demo data in JSON output', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({ + name: 'test-store', + plan: 'basic', + featurePreview: 'extended_variants', + withDemoData: true, + json: true, + }) + + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"plan": "basic"')) + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"featurePreview": "extended_variants"')) + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"demoData": true')) + }) + test('outputs JSON when --json flag is set', async () => { vi.mocked(businessPlatformOrganizationsRequestDoc) .mockResolvedValueOnce(defaultMutationResult) @@ -94,7 +168,7 @@ describe('createDevStore', () => { organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, }) - await createDevStore({name: 'test-store', json: true}) + await createDevStore({name: 'test-store', plan: 'plus', json: true}) expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"domain": "test-store.myshopify.com"')) expect(renderSuccess).not.toHaveBeenCalled() @@ -105,7 +179,9 @@ describe('createDevStore', () => { createAppDevelopmentStore: null, }) - await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow('unexpected empty response') + await expect(createDevStore({name: 'test-store', plan: 'plus', json: false})).rejects.toThrow( + 'unexpected empty response', + ) }) test('throws AbortError when mutation returns userErrors', async () => { @@ -117,7 +193,7 @@ describe('createDevStore', () => { }, }) - await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow('Name is taken') + await expect(createDevStore({name: 'test-store', plan: 'plus', json: false})).rejects.toThrow('Name is taken') }) test('throws AbortError when mutation returns no shopDomain', async () => { @@ -129,7 +205,9 @@ describe('createDevStore', () => { }, }) - await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow('no shop domain was returned') + await expect(createDevStore({name: 'test-store', plan: 'plus', json: false})).rejects.toThrow( + 'no shop domain was returned', + ) }) test('throws AbortError when polling returns FAILED status', async () => { @@ -139,7 +217,7 @@ describe('createDevStore', () => { organization: {id: '123', storeCreation: {status: 'FAILED'}}, }) - await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow( + await expect(createDevStore({name: 'test-store', plan: 'plus', json: false})).rejects.toThrow( 'Store creation failed with status: FAILED', ) }) @@ -151,7 +229,7 @@ describe('createDevStore', () => { organization: {id: '123', storeCreation: {status: 'TIMED_OUT'}}, }) - await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow( + await expect(createDevStore({name: 'test-store', plan: 'plus', json: false})).rejects.toThrow( 'Store creation failed with status: TIMED_OUT', ) }) @@ -163,7 +241,7 @@ describe('createDevStore', () => { organization: {id: '123', storeCreation: {status: 'USER_ERROR'}}, }) - await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow( + await expect(createDevStore({name: 'test-store', plan: 'plus', json: false})).rejects.toThrow( 'Store creation failed with status: USER_ERROR', ) }) @@ -179,7 +257,7 @@ describe('createDevStore', () => { vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(defaultMutationResult) - await expect(createDevStore({name: 'test-store', json: false})).rejects.toThrow( + await expect(createDevStore({name: 'test-store', plan: 'plus', json: false})).rejects.toThrow( 'Store creation timed out after 5 minutes', ) }) @@ -191,7 +269,7 @@ describe('createDevStore', () => { organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, }) - await createDevStore({name: 'test-store', organizationId: 456, json: false}) + await createDevStore({name: 'test-store', plan: 'plus', organizationId: 456, json: false}) expect(selectOrg).toHaveBeenCalledWith('456') }) @@ -206,7 +284,7 @@ describe('createDevStore', () => { organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, }) - await createDevStore({name: 'test-store', json: false}) + await createDevStore({name: 'test-store', plan: 'plus', json: false}) expect(sleep).toHaveBeenCalledWith(2) }) @@ -218,7 +296,7 @@ describe('createDevStore', () => { organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, }) - await createDevStore({name: 'test-store', json: false}) + await createDevStore({name: 'test-store', plan: 'plus', json: false}) expect(renderSingleTask).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/store/src/cli/services/store/create/dev.ts b/packages/store/src/cli/services/store/create/dev.ts index bfb43918c96..3792283032c 100644 --- a/packages/store/src/cli/services/store/create/dev.ts +++ b/packages/store/src/cli/services/store/create/dev.ts @@ -1,3 +1,4 @@ +import {DEV_STORE_PLANS, DevStorePlan} from '../constants.js' import {CreateAppDevelopmentStore} from '../../../api/graphql/business-platform-organizations/generated/create_app_development_store.js' import { PollStoreCreation, @@ -16,7 +17,10 @@ const POLL_TIMEOUT_MS = 5 * 60 * 1000 interface CreateDevStoreOptions { name: string + plan: DevStorePlan organizationId?: number + featurePreview?: string + withDemoData?: boolean json: boolean } @@ -62,8 +66,9 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise