Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -5781,13 +5789,35 @@
"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.",
"env": "SHOPIFY_FLAG_VERBOSE",
"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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type CreateAppDevelopmentStoreMutationVariables = Types.Exact<{
shopName: Types.Scalars['String']['input']
priceLookupKey: Types.Scalars['String']['input']
prepopulateTestData?: Types.InputMaybe<Types.Scalars['Boolean']['input']>
developerPreviewHandle?: Types.InputMaybe<Types.Scalars['String']['input']>
}>

export type CreateAppDevelopmentStoreMutation = {
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
57 changes: 51 additions & 6 deletions packages/store/src/cli/commands/store/create/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand All @@ -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)
Expand All @@ -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()
})
})
19 changes: 19 additions & 0 deletions packages/store/src/cli/commands/store/create/dev.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<void> {
Expand All @@ -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) {
Expand Down
44 changes: 44 additions & 0 deletions packages/store/src/cli/services/store/constants.ts
Original file line number Diff line number Diff line change
@@ -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',
}
Loading
Loading