Skip to content

Commit a91ef5d

Browse files
Tighten preview create validation tests
Assisted-By: devx/425e6a4b-6ec9-421c-8f98-ab1725d0b996
1 parent 9267035 commit a91ef5d

16 files changed

Lines changed: 1140 additions & 9 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/cli': minor
3+
'@shopify/store': minor
4+
---
5+
6+
Add `shopify store create preview` to create preview stores and persist their Admin API token in local store auth.

docs-shopify.dev/generated/generated_docs_data_v2.json

Lines changed: 9 additions & 9 deletions
Large diffs are not rendered by default.

packages/cli/oclif.manifest.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5812,6 +5812,75 @@
58125812
"strict": true,
58135813
"summary": "Create a new development store."
58145814
},
5815+
"store:create:preview": {
5816+
"aliases": [
5817+
],
5818+
"args": {
5819+
},
5820+
"customPluginName": "@shopify/store",
5821+
"description": "Creates a new Shopify store, with no need for an existing account.",
5822+
"descriptionWithMarkdown": "Creates a new Shopify store, with no need for an existing account.",
5823+
"examples": [
5824+
"<%= config.bin %> <%= command.id %> --name \"Lavender Candles\"",
5825+
"<%= config.bin %> <%= command.id %> --name \"Lavender Candles\" --country US",
5826+
"<%= config.bin %> <%= command.id %> --name \"Lavender Candles\" --json"
5827+
],
5828+
"flags": {
5829+
"country": {
5830+
"description": "Two-letter country code for the store, such as US, CA, or GB.",
5831+
"env": "SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY",
5832+
"hasDynamicHelp": false,
5833+
"multiple": false,
5834+
"name": "country",
5835+
"required": false,
5836+
"type": "option"
5837+
},
5838+
"json": {
5839+
"allowNo": false,
5840+
"char": "j",
5841+
"description": "Output the result as JSON. Automatically disables color output.",
5842+
"env": "SHOPIFY_FLAG_JSON",
5843+
"hidden": false,
5844+
"name": "json",
5845+
"type": "boolean"
5846+
},
5847+
"name": {
5848+
"description": "The name of the store.",
5849+
"env": "SHOPIFY_FLAG_PREVIEW_STORE_NAME",
5850+
"hasDynamicHelp": false,
5851+
"multiple": false,
5852+
"name": "name",
5853+
"required": false,
5854+
"type": "option"
5855+
},
5856+
"no-color": {
5857+
"allowNo": false,
5858+
"description": "Disable color output.",
5859+
"env": "SHOPIFY_FLAG_NO_COLOR",
5860+
"hidden": false,
5861+
"name": "no-color",
5862+
"type": "boolean"
5863+
},
5864+
"verbose": {
5865+
"allowNo": false,
5866+
"description": "Increase the verbosity of the output.",
5867+
"env": "SHOPIFY_FLAG_VERBOSE",
5868+
"hidden": false,
5869+
"name": "verbose",
5870+
"type": "boolean"
5871+
}
5872+
},
5873+
"hasDynamicHelp": false,
5874+
"hidden": true,
5875+
"hiddenAliases": [
5876+
],
5877+
"id": "store:create:preview",
5878+
"pluginAlias": "@shopify/cli",
5879+
"pluginName": "@shopify/cli",
5880+
"pluginType": "core",
5881+
"strict": true,
5882+
"summary": "Create a preview Shopify store."
5883+
},
58155884
"store:execute": {
58165885
"aliases": [
58175886
],

packages/e2e/data/snapshots/commands.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@
9494
├─ search
9595
├─ store
9696
│ ├─ auth
97+
│ ├─ create
98+
│ │ └─ preview
9799
│ ├─ execute
98100
│ └─ info
99101
├─ theme
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import StoreCreatePreview from './preview.js'
2+
import {createPreviewStoreCommand} from '../../../services/store/create/preview/index.js'
3+
import {writeCreatePreviewStoreResult} from '../../../services/store/create/preview/result.js'
4+
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
5+
import {describe, expect, test, vi} from 'vitest'
6+
7+
vi.mock('../../../services/store/create/preview/index.js')
8+
vi.mock('../../../services/store/create/preview/result.js')
9+
vi.mock('@shopify/cli-kit/node/ui', async () => {
10+
const actual = await vi.importActual<typeof import('@shopify/cli-kit/node/ui')>('@shopify/cli-kit/node/ui')
11+
return {...actual, renderSingleTask: vi.fn(async ({task}) => task())}
12+
})
13+
14+
describe('store create preview command', () => {
15+
test('is hidden until preview store creation is generally available', () => {
16+
expect(StoreCreatePreview.hidden).toBe(true)
17+
})
18+
19+
test('passes parsed flags through to the service', async () => {
20+
const result = {
21+
status: 'success' as const,
22+
message: 'Your Shopify store is ready.',
23+
store: {
24+
id: '123',
25+
name: 'Lavender Candles',
26+
subdomain: 'x.myshopify.com',
27+
country: 'US',
28+
storefrontUrl: 'https://x.myshopify.com',
29+
},
30+
nextSteps: [],
31+
}
32+
vi.mocked(createPreviewStoreCommand).mockResolvedValueOnce(result)
33+
34+
await StoreCreatePreview.run(['--name', 'Lavender Candles', '--country', 'us', '--json'])
35+
36+
expect(renderSingleTask).toHaveBeenCalledWith({
37+
title: expect.objectContaining({value: 'Creating store…'}),
38+
task: expect.any(Function),
39+
})
40+
expect(createPreviewStoreCommand).toHaveBeenCalledWith({name: 'Lavender Candles', country: 'US'})
41+
expect(writeCreatePreviewStoreResult).toHaveBeenCalledWith(result, 'json')
42+
})
43+
44+
test('rejects invalid country codes before calling the service', async () => {
45+
await expect(StoreCreatePreview.run(['--country', 'USA'])).rejects.toThrow('process.exit unexpectedly called')
46+
47+
expect(createPreviewStoreCommand).not.toHaveBeenCalled()
48+
})
49+
})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {countryFlag, isCountryCode} from '../../../flags.js'
2+
import {type CreatePreviewStoreResult, createPreviewStoreCommand} from '../../../services/store/create/preview/index.js'
3+
import {writeCreatePreviewStoreResult} from '../../../services/store/create/preview/result.js'
4+
import StoreCommand from '../../../utilities/store-command.js'
5+
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
6+
import {outputContent} from '@shopify/cli-kit/node/output'
7+
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
8+
import {Flags} from '@oclif/core'
9+
10+
export default class StoreCreatePreview extends StoreCommand {
11+
static hidden = true
12+
13+
static summary = 'Create a preview Shopify store.'
14+
15+
static descriptionWithMarkdown = `Creates a new Shopify store, with no need for an existing account.`
16+
17+
static description = this.descriptionWithoutMarkdown()
18+
19+
static examples = [
20+
'<%= config.bin %> <%= command.id %> --name "Lavender Candles"',
21+
'<%= config.bin %> <%= command.id %> --name "Lavender Candles" --country US',
22+
'<%= config.bin %> <%= command.id %> --name "Lavender Candles" --json',
23+
]
24+
25+
static flags = {
26+
...globalFlags,
27+
...jsonFlag,
28+
name: Flags.string({
29+
description: 'The name of the store.',
30+
env: 'SHOPIFY_FLAG_PREVIEW_STORE_NAME',
31+
required: false,
32+
}),
33+
country: countryFlag('SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY'),
34+
}
35+
36+
public async run(): Promise<void> {
37+
const {flags} = await this.parse(StoreCreatePreview)
38+
39+
if (flags.country !== undefined && !isCountryCode(flags.country)) {
40+
this.error('Country must be a two-letter country code, for example: US.')
41+
}
42+
43+
const result = await renderSingleTask<CreatePreviewStoreResult>({
44+
title: outputContent`Creating store…`,
45+
task: async () => createPreviewStoreCommand({name: flags.name, country: flags.country}),
46+
})
47+
48+
writeCreatePreviewStoreResult(result, flags.json ? 'json' : 'text')
49+
}
50+
}

packages/store/src/cli/flags.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
22
import {Flags} from '@oclif/core'
33

4+
export function countryFlag(env: string) {
5+
return Flags.string({
6+
description: 'Two-letter country code for the store, such as US, CA, or GB.',
7+
env,
8+
required: false,
9+
parse: async (value) => value.trim().toUpperCase(),
10+
})
11+
}
12+
13+
export function isCountryCode(value: string): boolean {
14+
return /^[A-Z]{2}$/.test(value)
15+
}
16+
417
export const storeFlags = {
518
store: Flags.string({
619
char: 's',

packages/store/src/cli/services/store/auth/session-store.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,42 @@ describe('store session storage', () => {
144144
})
145145
})
146146

147+
test('round-trips preview store session metadata', () => {
148+
const storage = inMemoryStorage()
149+
const previewSession = buildSession({
150+
userId: 'preview:placeholder-uuid',
151+
scopes: [],
152+
kind: 'preview',
153+
preview: {
154+
placeholderAccountUuid: 'placeholder-uuid',
155+
shopId: '123',
156+
name: 'Lavender Candles',
157+
country: 'US',
158+
createdAt: '2026-06-08T12:00:00.000Z',
159+
},
160+
})
161+
162+
setStoredStoreAppSession(previewSession, storage as any)
163+
164+
expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(previewSession)
165+
})
166+
167+
test('rejects preview store sessions with malformed metadata', () => {
168+
const storage = inMemoryStorage()
169+
storage.set(storeAuthSessionKey('shop.myshopify.com'), {
170+
currentUserId: 'preview:placeholder-uuid',
171+
sessionsByUserId: {
172+
'preview:placeholder-uuid': {
173+
...buildSession({userId: 'preview:placeholder-uuid', kind: 'preview'}),
174+
preview: {placeholderAccountUuid: 'placeholder-uuid'},
175+
},
176+
},
177+
})
178+
179+
expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined()
180+
expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined()
181+
})
182+
147183
test('overwrites a malformed bucket when writing a new session', () => {
148184
const storage = inMemoryStorage()
149185
storage.set(storeAuthSessionKey('shop.myshopify.com'), {

packages/store/src/cli/services/store/auth/session-store.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
11
import {storeAuthSessionKey} from './config.js'
22
import {LocalStorage} from '@shopify/cli-kit/node/local-storage'
33

4+
/**
5+
* Discriminator for a stored store auth session.
6+
*
7+
* - 'standard': created via `shopify store auth`.
8+
* - 'preview': created via `shopify store create preview`; backed by a server-issued Admin API token.
9+
*
10+
* Stored sessions written before this discriminator existed have no `kind` field and are
11+
* read back as 'standard'.
12+
*/
13+
type StoredStoreSessionKind = 'standard' | 'preview'
14+
15+
interface StoredPreviewStoreMetadata {
16+
/** Placeholder account UUID returned by the preview-store backend when available. */
17+
placeholderAccountUuid?: string
18+
/** Numeric shop id returned by the preview-store backend. */
19+
shopId: string
20+
/** Store name returned by the preview-store backend. */
21+
name: string
22+
/** ISO country code requested for the store, when provided by the caller. */
23+
country?: string
24+
/** ISO timestamp for when the preview store was created locally. */
25+
createdAt: string
26+
/** Access URL returned by the preview-store backend. */
27+
accessUrl?: string
28+
}
29+
430
export interface StoredStoreAppSession {
531
store: string
632
clientId: string
@@ -18,6 +44,13 @@ export interface StoredStoreAppSession {
1844
lastName?: string
1945
accountOwner?: boolean
2046
}
47+
/**
48+
* Discriminator. Optional in storage for back-compat with sessions written before the
49+
* field existed; `sessionKind()` resolves missing values to 'standard'.
50+
*/
51+
kind?: StoredStoreSessionKind
52+
/** Preview-store-only metadata. Set iff `kind === 'preview'`. */
53+
preview?: StoredPreviewStoreMetadata
2154
}
2255

2356
interface StoredStoreAppSessionBucket {
@@ -55,6 +88,22 @@ function sanitizeAssociatedUser(value: unknown): StoredStoreAppSession['associat
5588
}
5689
}
5790

91+
function sanitizePreviewMetadata(value: unknown): StoredPreviewStoreMetadata | undefined {
92+
if (!value || typeof value !== 'object') return undefined
93+
94+
const metadata = value as Record<string, unknown>
95+
if (!isString(metadata.shopId) || !isString(metadata.name) || !isString(metadata.createdAt)) return undefined
96+
97+
return {
98+
shopId: metadata.shopId,
99+
name: metadata.name,
100+
createdAt: metadata.createdAt,
101+
...(isString(metadata.placeholderAccountUuid) ? {placeholderAccountUuid: metadata.placeholderAccountUuid} : {}),
102+
...(isString(metadata.country) ? {country: metadata.country} : {}),
103+
...(isString(metadata.accessUrl) ? {accessUrl: metadata.accessUrl} : {}),
104+
}
105+
}
106+
58107
function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | undefined {
59108
if (!value || typeof value !== 'object') return undefined
60109

@@ -71,6 +120,16 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession |
71120
return undefined
72121
}
73122

123+
// Discriminator is optional for back-compat: sessions written before this field existed
124+
// are read back as 'standard'. Unknown values are coerced to 'standard' and the field is
125+
// omitted from the result so it doesn't pollute legacy buckets.
126+
const kind: StoredStoreSessionKind = session.kind === 'preview' ? 'preview' : 'standard'
127+
const preview = kind === 'preview' ? sanitizePreviewMetadata(session.preview) : undefined
128+
129+
// A session declared as 'preview' but missing/malformed metadata is rejected outright,
130+
// because store-list fallback and future re-mint/claim flows rely on this metadata.
131+
if (kind === 'preview' && !preview) return undefined
132+
74133
return {
75134
store: session.store,
76135
clientId: session.clientId,
@@ -84,6 +143,8 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession |
84143
...(sanitizeAssociatedUser(session.associatedUser)
85144
? {associatedUser: sanitizeAssociatedUser(session.associatedUser)}
86145
: {}),
146+
...(kind === 'preview' ? {kind} : {}),
147+
...(preview ? {preview} : {}),
87148
}
88149
}
89150

0 commit comments

Comments
 (0)