Skip to content
Merged
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
2 changes: 0 additions & 2 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5965,7 +5965,6 @@
"hasDynamicHelp": false,
"multiple": false,
"name": "name",
"required": true,
"type": "option"
},
"no-color": {
Expand Down Expand Up @@ -5996,7 +5995,6 @@
"advanced",
"plus"
],
"required": true,
"type": "option"
},
"verbose": {
Expand Down
112 changes: 83 additions & 29 deletions packages/store/src/cli/commands/store/create/dev.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import StoreCreateDev from './dev.js'
import {createDevStore} from '../../../services/store/create/dev.js'
import {storeNamePrompt, storePlanPrompt} from '../../../prompts/store.js'
import {selectOrg} from '@shopify/organizations'
import {AbortError} from '@shopify/cli-kit/node/error'
import {outputResult} from '@shopify/cli-kit/node/output'
import {describe, expect, test, vi} from 'vitest'
import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system'
import {describe, expect, test, vi, beforeEach} from 'vitest'

vi.mock('../../../services/store/create/dev.js')
vi.mock('../../../prompts/store.js')
vi.mock('@shopify/cli-kit/node/system')

vi.mock('@shopify/organizations', () => ({
selectOrg: vi.fn(),
}))

vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal()
Expand All @@ -14,26 +23,21 @@ 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', '--plan', 'plus'])
const defaultOrg = {id: '12345', businessName: 'Test Org'}

expect(createDevStore).toHaveBeenCalledWith({
name: 'my-test-store',
organizationId: undefined,
plan: 'plus',
featurePreview: undefined,
withDemoData: false,
json: false,
})
})
beforeEach(() => {
vi.mocked(selectOrg).mockResolvedValue(defaultOrg)
vi.mocked(terminalSupportsPrompting).mockReturnValue(true)
})

test('passes organization-id flag through to the service', async () => {
await StoreCreateDev.run(['--name', 'my-test-store', '--organization-id', '12345', '--plan', 'plus'])
describe('store create dev command', () => {
test('resolves the organization and passes parsed flags through to the service', async () => {
await StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'plus', '--organization-id', '12345'])

expect(selectOrg).toHaveBeenCalledWith('12345')
expect(createDevStore).toHaveBeenCalledWith({
name: 'my-test-store',
organizationId: 12345,
organization: defaultOrg,
plan: 'plus',
featurePreview: undefined,
withDemoData: false,
Expand All @@ -42,11 +46,11 @@ describe('store create dev command', () => {
})

test('passes json flag through to the service', async () => {
await StoreCreateDev.run(['--name', 'my-test-store', '--json', '--plan', 'plus'])
await StoreCreateDev.run(['--name', 'my-test-store', '--json', '--plan', 'plus', '--organization-id', '12345'])

expect(createDevStore).toHaveBeenCalledWith({
name: 'my-test-store',
organizationId: undefined,
organization: defaultOrg,
plan: 'plus',
featurePreview: undefined,
withDemoData: false,
Expand All @@ -60,31 +64,77 @@ describe('store create dev command', () => {
'my-test-store',
'--plan',
'basic',
'--organization-id',
'12345',
'--feature-preview',
'extended_variants',
'--with-demo-data',
])

expect(createDevStore).toHaveBeenCalledWith({
name: 'my-test-store',
organizationId: undefined,
organization: defaultOrg,
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('prompts for the name when --name is omitted in an interactive environment', async () => {
vi.mocked(storeNamePrompt).mockResolvedValue('prompted-store')

await StoreCreateDev.run(['--organization-id', '12345', '--plan', 'plus'])

expect(storeNamePrompt).toHaveBeenCalled()
expect(createDevStore).toHaveBeenCalledWith(expect.objectContaining({name: 'prompted-store'}))
})

test('prompts for the plan when --plan is omitted in an interactive environment', async () => {
vi.mocked(storePlanPrompt).mockResolvedValue('advanced')

await StoreCreateDev.run(['--organization-id', '12345', '--name', 'my-test-store'])

expect(storePlanPrompt).toHaveBeenCalled()
expect(createDevStore).toHaveBeenCalledWith(expect.objectContaining({plan: 'advanced'}))
})

test('does not prompt when all flags are provided', async () => {
await StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'plus', '--organization-id', '12345'])

expect(storeNamePrompt).not.toHaveBeenCalled()
expect(storePlanPrompt).not.toHaveBeenCalled()
})

test('requires the plan flag', async () => {
await expect(StoreCreateDev.run(['--name', 'my-test-store'])).rejects.toThrow()
test('rejects an invalid plan value without calling the service', async () => {
await expect(
StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'enterprise', '--organization-id', '12345']),
).rejects.toThrow()
expect(createDevStore).not.toHaveBeenCalled()
})

test.each(['name', 'organization-id', 'plan'])(
'fails in a non-interactive environment when --%s is missing',
async (missingFlag) => {
vi.mocked(terminalSupportsPrompting).mockReturnValue(false)
const argv = ['--name', 'my-test-store', '--organization-id', '12345', '--plan', 'plus'].filter(
(value, index, all) => {
// Drop the missing flag and its value.
if (value === `--${missingFlag}`) return false
return all[index - 1] !== `--${missingFlag}`
},
)
const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit')
}) as never)

await expect(StoreCreateDev.run(argv)).rejects.toThrow()
expect(createDevStore).not.toHaveBeenCalled()

mockExit.mockRestore()
},
)

test('defines the expected flags', () => {
expect(StoreCreateDev.flags.name).toBeDefined()
expect(StoreCreateDev.flags['organization-id']).toBeDefined()
Expand All @@ -100,9 +150,9 @@ describe('store create dev command', () => {
throw new Error('process.exit')
}) as never)

await expect(StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'plus', '--json'])).rejects.toThrow(
'process.exit',
)
await expect(
StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'plus', '--organization-id', '12345', '--json']),
).rejects.toThrow('process.exit')

const call = vi.mocked(outputResult).mock.calls[0]![0] as string
const parsed = JSON.parse(call)
Expand All @@ -120,14 +170,18 @@ 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', '--plan', 'plus', '--json'])).rejects.toThrow()
await expect(
StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'plus', '--organization-id', '12345', '--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', '--plan', 'plus'])).rejects.toThrow()
await expect(
StoreCreateDev.run(['--name', 'my-test-store', '--plan', 'plus', '--organization-id', '12345']),
).rejects.toThrow()
expect(vi.mocked(outputResult)).not.toHaveBeenCalled()
})
})
16 changes: 11 additions & 5 deletions packages/store/src/cli/commands/store/create/dev.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {createDevStore} from '../../../services/store/create/dev.js'
import {devStorePlanHandles, DevStorePlan} from '../../../services/store/constants.js'
import {storeNamePrompt, storePlanPrompt} from '../../../prompts/store.js'
import {storeFlags} from '../../../flags.js'
import {selectOrg} from '@shopify/organizations'
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'
Expand All @@ -21,14 +23,12 @@ export default class StoreCreateDev extends Command {
...jsonFlag,
name: Flags.string({
description: 'Name for the new development store.',
required: true,
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({
Expand All @@ -44,11 +44,17 @@ export default class StoreCreateDev extends Command {

async run(): Promise<void> {
const {flags} = await this.parse(StoreCreateDev)
this.failMissingNonTTYFlags(flags, ['name', 'organization-id', 'plan'])

const organization = await selectOrg(flags['organization-id']?.toString())
const name = flags.name ?? (await storeNamePrompt())
const plan = (flags.plan as DevStorePlan | undefined) ?? (await storePlanPrompt())

try {
await createDevStore({
name: flags.name,
organizationId: flags['organization-id'],
plan: flags.plan as DevStorePlan,
name,
organization,
plan,
featurePreview: flags['feature-preview'],
withDemoData: flags['with-demo-data'],
json: flags.json,
Expand Down
37 changes: 37 additions & 0 deletions packages/store/src/cli/prompts/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {storeNamePrompt, storePlanPrompt} from './store.js'
import {describe, expect, test, vi} from 'vitest'
import {renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui'

vi.mock('@shopify/cli-kit/node/ui')

describe('storeNamePrompt', () => {
test('asks for the store name and returns the entered value', async () => {
vi.mocked(renderTextPrompt).mockResolvedValue('my-store')

const result = await storeNamePrompt()

expect(result).toBe('my-store')
expect(renderTextPrompt).toHaveBeenCalledWith(
expect.objectContaining({message: 'Name for the new development store'}),
)
})
})

describe('storePlanPrompt', () => {
test('offers every plan handle and returns the selected value', async () => {
vi.mocked(renderSelectPrompt).mockResolvedValue('advanced')

const result = await storePlanPrompt()

expect(result).toBe('advanced')
expect(renderSelectPrompt).toHaveBeenCalledWith({
message: 'Which Shopify plan do you want to use?',
choices: [
{label: 'Basic', value: 'basic'},
{label: 'Grow', value: 'grow'},
{label: 'Advanced', value: 'advanced'},
{label: 'Plus', value: 'plus'},
],
})
})
})
26 changes: 26 additions & 0 deletions packages/store/src/cli/prompts/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {DevStorePlan, devStorePlanHandles} from '../services/store/constants.js'
import {renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui'

/** 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<string> {
return renderTextPrompt({
message: 'Name for the new development store',
})
}

export async function storePlanPrompt(): Promise<DevStorePlan> {
return renderSelectPrompt({
message: 'Which Shopify plan do you want to use?',
choices: devStorePlanHandles.map((handle) => ({
label: PLAN_LABELS[handle],
value: handle,
})),
})
}
Loading
Loading