diff --git a/.changeset/preview-store-create-production.md b/.changeset/preview-store-create-production.md new file mode 100644 index 00000000000..d786769e018 --- /dev/null +++ b/.changeset/preview-store-create-production.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli': minor +'@shopify/store': minor +--- + +Add `shopify store create preview` to create preview stores and persist their Admin API token in local store auth. diff --git a/docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts b/docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts new file mode 100644 index 00000000000..b93dce4b71d --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts @@ -0,0 +1,36 @@ +// This is an autogenerated file. Don't edit this file manually. +/** + * The following flags are available for the `store create preview` command: + * @publicDocs + */ +export interface storecreatepreview { + /** + * Two-letter ISO 3166-1 alpha-2 country code for the store, such as US, CA, or GB. + * @environment SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY + */ + '--country '?: string + + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + + /** + * The name of the store. + * @environment SHOPIFY_FLAG_PREVIEW_STORE_NAME + */ + '--name '?: string + + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' +} diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 782fa48b708..acef0c594f2 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4184,6 +4184,62 @@ "value": "export interface storeauth {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, + "storecreatepreview": { + "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", + "name": "storecreatepreview", + "description": "The following flags are available for the `store create preview` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--country ", + "value": "string", + "description": "Two-letter ISO 3166-1 alpha-2 country code for the store, such as US, CA, or GB.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--name ", + "value": "string", + "description": "The name of the store.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PREVIEW_STORE_NAME" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + } + ], + "value": "export interface storecreatepreview {\n /**\n * Two-letter ISO 3166-1 alpha-2 country code for the store, such as US, CA, or GB.\n * @environment SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY\n */\n '--country '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * The name of the store.\n * @environment SHOPIFY_FLAG_PREVIEW_STORE_NAME\n */\n '--name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + }, "storeexecute": { "docs-shopify.dev/commands/interfaces/store-execute.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", diff --git a/packages/cli/README.md b/packages/cli/README.md index f7d6f3620d6..dcf461650f1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -77,6 +77,7 @@ * [`shopify plugins update`](#shopify-plugins-update) * [`shopify search [query]`](#shopify-search-query) * [`shopify store auth`](#shopify-store-auth) +* [`shopify store create preview`](#shopify-store-create-preview) * [`shopify store execute`](#shopify-store-execute) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) @@ -2131,6 +2132,36 @@ EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products --json ``` +## `shopify store create preview` + +Create a preview Shopify store. + +``` +USAGE + $ shopify store create preview [--country ] [-j] [--name ] [--no-color] [--verbose] + +FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. + --country= [env: SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY] Two-letter ISO 3166-1 alpha-2 country code for the + store, such as US, CA, or GB. + --name= [env: SHOPIFY_FLAG_PREVIEW_STORE_NAME] The name of the store. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + Create a preview Shopify store. + + Creates a new preview Shopify store for a merchant who wants to try Shopify without needing to immediately create an + account. + +EXAMPLES + $ shopify store create preview --name "Lavender Candles" + + $ shopify store create preview --name "Lavender Candles" --country US + + $ shopify store create preview --name "Lavender Candles" --json +``` + ## `shopify store execute` Execute GraphQL queries and mutations on a store. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index eeacfe2b751..544642b73b4 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5737,6 +5737,74 @@ "strict": true, "summary": "Authenticate an app against a store for store commands." }, + "store:create:preview": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/store", + "description": "Creates a new preview Shopify store for a merchant who wants to try Shopify without needing to immediately create an account.", + "descriptionWithMarkdown": "Creates a new preview Shopify store for a merchant who wants to try Shopify without needing to immediately create an account.", + "examples": [ + "<%= config.bin %> <%= command.id %> --name \"Lavender Candles\"", + "<%= config.bin %> <%= command.id %> --name \"Lavender Candles\" --country US", + "<%= config.bin %> <%= command.id %> --name \"Lavender Candles\" --json" + ], + "flags": { + "country": { + "description": "Two-letter ISO 3166-1 alpha-2 country code for the store, such as US, CA, or GB.", + "env": "SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY", + "hasDynamicHelp": false, + "multiple": false, + "name": "country", + "required": false, + "type": "option" + }, + "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": "The name of the store.", + "env": "SHOPIFY_FLAG_PREVIEW_STORE_NAME", + "hasDynamicHelp": false, + "multiple": false, + "name": "name", + "required": false, + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "store:create:preview", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Create a preview Shopify store." + }, "store:execute": { "aliases": [ ], diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 9e9afb6c946..eaa3c62c5de 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -94,6 +94,8 @@ ├─ search ├─ store │ ├─ auth +│ ├─ create +│ │ └─ preview │ └─ execute ├─ theme │ ├─ check diff --git a/packages/store/src/cli/commands/store/create/preview.test.ts b/packages/store/src/cli/commands/store/create/preview.test.ts new file mode 100644 index 00000000000..7fef994724c --- /dev/null +++ b/packages/store/src/cli/commands/store/create/preview.test.ts @@ -0,0 +1,39 @@ +import StoreCreatePreview from './preview.js' +import {createPreviewStoreCommand} from '../../../services/store/create/preview/index.js' +import {writeCreatePreviewStoreResult} from '../../../services/store/create/preview/result.js' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../../services/store/create/preview/index.js') +vi.mock('../../../services/store/create/preview/result.js') +vi.mock('@shopify/cli-kit/node/ui', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/ui') + return {...actual, renderSingleTask: vi.fn(async ({task}) => task())} +}) + +describe('store create preview command', () => { + test('passes parsed flags through to the service', async () => { + const result = { + status: 'success' as const, + message: 'Your preview store is ready.', + store: {id: '123', name: 'Lavender Candles', subdomain: 'x.myshopify.com', requestedCountry: 'US'}, + nextSteps: [], + } + vi.mocked(createPreviewStoreCommand).mockResolvedValueOnce(result) + + await StoreCreatePreview.run(['--name', 'Lavender Candles', '--country', 'us', '--json']) + + expect(renderSingleTask).toHaveBeenCalledWith({ + title: expect.objectContaining({value: 'Creating store…'}), + task: expect.any(Function), + }) + expect(createPreviewStoreCommand).toHaveBeenCalledWith({name: 'Lavender Candles', country: 'US'}) + expect(writeCreatePreviewStoreResult).toHaveBeenCalledWith(result, 'json') + }) + + test('rejects invalid country codes before calling the service', async () => { + await expect(StoreCreatePreview.run(['--country', 'USA'])).rejects.toThrow('process.exit unexpectedly called') + + expect(createPreviewStoreCommand).not.toHaveBeenCalled() + }) +}) diff --git a/packages/store/src/cli/commands/store/create/preview.ts b/packages/store/src/cli/commands/store/create/preview.ts new file mode 100644 index 00000000000..6f9a909fa41 --- /dev/null +++ b/packages/store/src/cli/commands/store/create/preview.ts @@ -0,0 +1,56 @@ +import {type CreatePreviewStoreResult, createPreviewStoreCommand} from '../../../services/store/create/preview/index.js' +import {writeCreatePreviewStoreResult} from '../../../services/store/create/preview/result.js' +import StoreCommand from '../../../utilities/store-command.js' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {outputContent} from '@shopify/cli-kit/node/output' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {Flags} from '@oclif/core' + +export default class StoreCreatePreview extends StoreCommand { + static summary = 'Create a preview Shopify store.' + + static descriptionWithMarkdown = `Creates a new preview Shopify store for a merchant who wants to try Shopify without needing to immediately create an account.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --name "Lavender Candles"', + '<%= config.bin %> <%= command.id %> --name "Lavender Candles" --country US', + '<%= config.bin %> <%= command.id %> --name "Lavender Candles" --json', + ] + + static flags = { + ...globalFlags, + ...jsonFlag, + name: Flags.string({ + description: 'The name of the store.', + env: 'SHOPIFY_FLAG_PREVIEW_STORE_NAME', + required: false, + }), + country: Flags.string({ + description: 'Two-letter ISO 3166-1 alpha-2 country code for the store, such as US, CA, or GB.', + env: 'SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY', + required: false, + parse: async (value) => value.trim().toUpperCase(), + }), + } + + public async run(): Promise { + const {flags} = await this.parse(StoreCreatePreview) + + if (flags.country !== undefined && !isCountryCode(flags.country)) { + this.error('Country must be a two-letter ISO country code, for example: US.') + } + + const result = await renderSingleTask({ + title: outputContent`Creating store…`, + task: async () => createPreviewStoreCommand({name: flags.name, country: flags.country}), + }) + + writeCreatePreviewStoreResult(result, flags.json ? 'json' : 'text') + } +} + +function isCountryCode(value: string): boolean { + return /^[A-Z]{2}$/.test(value) +} diff --git a/packages/store/src/cli/services/store/auth/session-store.test.ts b/packages/store/src/cli/services/store/auth/session-store.test.ts index 523060f5975..72f03398aae 100644 --- a/packages/store/src/cli/services/store/auth/session-store.test.ts +++ b/packages/store/src/cli/services/store/auth/session-store.test.ts @@ -144,6 +144,42 @@ describe('store session storage', () => { }) }) + test('round-trips preview store session metadata', () => { + const storage = inMemoryStorage() + const previewSession = buildSession({ + userId: 'preview:placeholder-uuid', + scopes: [], + kind: 'preview', + preview: { + placeholderAccountUuid: 'placeholder-uuid', + shopId: '123', + name: 'Lavender Candles', + country: 'US', + createdAt: '2026-06-08T12:00:00.000Z', + }, + }) + + setStoredStoreAppSession(previewSession, storage as any) + + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(previewSession) + }) + + test('rejects preview store sessions with malformed metadata', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: 'preview:placeholder-uuid', + sessionsByUserId: { + 'preview:placeholder-uuid': { + ...buildSession({userId: 'preview:placeholder-uuid', kind: 'preview'}), + preview: {placeholderAccountUuid: 'placeholder-uuid'}, + }, + }, + }) + + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() + }) + test('overwrites a malformed bucket when writing a new session', () => { const storage = inMemoryStorage() storage.set(storeAuthSessionKey('shop.myshopify.com'), { diff --git a/packages/store/src/cli/services/store/auth/session-store.ts b/packages/store/src/cli/services/store/auth/session-store.ts index 8e10f730e06..527a124aca1 100644 --- a/packages/store/src/cli/services/store/auth/session-store.ts +++ b/packages/store/src/cli/services/store/auth/session-store.ts @@ -1,6 +1,30 @@ import {storeAuthSessionKey} from './config.js' import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +/** + * Discriminator for a stored store auth session. + * + * - 'standard': created via `shopify store auth`. + * - 'preview': created via `shopify store create preview`; backed by a server-issued Admin API token. + * + * Stored sessions written before this discriminator existed have no `kind` field and are + * read back as 'standard'. + */ +type StoredStoreSessionKind = 'standard' | 'preview' + +interface StoredPreviewStoreMetadata { + /** Placeholder account UUID returned by the preview-store backend when available. */ + placeholderAccountUuid?: string + /** Numeric shop id returned by the preview-store backend. */ + shopId: string + /** Store name returned by the preview-store backend. */ + name: string + /** ISO country code requested for the store, when provided by the caller. */ + country?: string + /** ISO timestamp for when the preview store was created locally. */ + createdAt: string +} + export interface StoredStoreAppSession { store: string clientId: string @@ -18,6 +42,13 @@ export interface StoredStoreAppSession { lastName?: string accountOwner?: boolean } + /** + * Discriminator. Optional in storage for back-compat with sessions written before the + * field existed; `sessionKind()` resolves missing values to 'standard'. + */ + kind?: StoredStoreSessionKind + /** Preview-store-only metadata. Set iff `kind === 'preview'`. */ + preview?: StoredPreviewStoreMetadata } interface StoredStoreAppSessionBucket { @@ -55,6 +86,21 @@ function sanitizeAssociatedUser(value: unknown): StoredStoreAppSession['associat } } +function sanitizePreviewMetadata(value: unknown): StoredPreviewStoreMetadata | undefined { + if (!value || typeof value !== 'object') return undefined + + const metadata = value as Record + if (!isString(metadata.shopId) || !isString(metadata.name) || !isString(metadata.createdAt)) return undefined + + return { + shopId: metadata.shopId, + name: metadata.name, + createdAt: metadata.createdAt, + ...(isString(metadata.placeholderAccountUuid) ? {placeholderAccountUuid: metadata.placeholderAccountUuid} : {}), + ...(isString(metadata.country) ? {country: metadata.country} : {}), + } +} + function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | undefined { if (!value || typeof value !== 'object') return undefined @@ -71,6 +117,16 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | return undefined } + // Discriminator is optional for back-compat: sessions written before this field existed + // are read back as 'standard'. Unknown values are coerced to 'standard' and the field is + // omitted from the result so it doesn't pollute legacy buckets. + const kind: StoredStoreSessionKind = session.kind === 'preview' ? 'preview' : 'standard' + const preview = kind === 'preview' ? sanitizePreviewMetadata(session.preview) : undefined + + // A session declared as 'preview' but missing/malformed metadata is rejected outright, + // because store-list fallback and future re-mint/claim flows rely on this metadata. + if (kind === 'preview' && !preview) return undefined + return { store: session.store, clientId: session.clientId, @@ -84,6 +140,8 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | ...(sanitizeAssociatedUser(session.associatedUser) ? {associatedUser: sanitizeAssociatedUser(session.associatedUser)} : {}), + ...(kind === 'preview' ? {kind} : {}), + ...(preview ? {preview} : {}), } } diff --git a/packages/store/src/cli/services/store/create/preview/client.test.ts b/packages/store/src/cli/services/store/create/preview/client.test.ts new file mode 100644 index 00000000000..941030cd251 --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/client.test.ts @@ -0,0 +1,125 @@ +import { + CLI_INSTANCE_HEADER, + CLI_VERSION_HEADER, + createPreviewStore, + getOrCreateCliInstanceId, + previewStoreCreateHeaders, +} from './client.js' +import {shopifyFetch} from '@shopify/cli-kit/node/http' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/context/fqdn', async () => { + const actual = await vi.importActual( + '@shopify/cli-kit/node/context/fqdn', + ) + return {...actual, appManagementFqdn: vi.fn(async () => 'app.shopify.com')} +}) +vi.mock('@shopify/cli-kit/node/crypto', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/crypto') + return {...actual, randomUUID: vi.fn(() => 'cli-install-id')} +}) + +function response(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } as Awaited> +} + +function inMemoryStorage(initial?: string) { + const values = new Map() + if (initial) values.set('cliInstanceId', initial) + return { + get: vi.fn((key: string) => values.get(key) as any), + set: vi.fn((key: string, value: unknown) => values.set(key, value)), + delete: vi.fn((key: string) => values.delete(key)), + } as unknown as LocalStorage<{cliInstanceId?: string}> +} + +describe('preview store client', () => { + test('persists and reuses a stable CLI instance id', () => { + const storage = inMemoryStorage() + + expect(getOrCreateCliInstanceId(storage)).toBe('cli-install-id') + expect(getOrCreateCliInstanceId(storage)).toBe('cli-install-id') + expect(storage.set).toHaveBeenCalledTimes(1) + }) + + test('builds production request headers', () => { + expect(previewStoreCreateHeaders('instance-1')).toEqual({ + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': `Shopify CLI; v=${CLI_KIT_VERSION}`, + [CLI_INSTANCE_HEADER]: 'instance-1', + [CLI_VERSION_HEADER]: CLI_KIT_VERSION, + }) + }) + + test('POSTs to /services/preview-stores with optional name and country_code and no authorization', async () => { + vi.mocked(shopifyFetch).mockResolvedValueOnce( + response(201, { + shop: {id: 123, name: 'Lavender Candles', domain: 'x12y45z.myshopify.com'}, + placeholder_account_uuid: 'placeholder-uuid', + admin_api_token: 'shpat_token', + }), + ) + + const got = await createPreviewStore( + {name: 'Lavender Candles', country: 'US'}, + {storage: inMemoryStorage('instance-1')}, + ) + + expect(shopifyFetch).toHaveBeenCalledWith('https://app.shopify.com/services/preview-stores', { + method: 'POST', + headers: expect.objectContaining({ + [CLI_INSTANCE_HEADER]: 'instance-1', + [CLI_VERSION_HEADER]: CLI_KIT_VERSION, + 'User-Agent': `Shopify CLI; v=${CLI_KIT_VERSION}`, + }), + body: JSON.stringify({name: 'Lavender Candles', country_code: 'US'}), + }) + expect((vi.mocked(shopifyFetch).mock.calls[0]![1]!.headers as Record).Authorization).toBeUndefined() + expect(got).toEqual({ + shop: {id: '123', name: 'Lavender Candles', domain: 'x12y45z.myshopify.com'}, + placeholderAccountUuid: 'placeholder-uuid', + adminApiToken: 'shpat_token', + }) + }) + + test('omits name and country_code when absent', async () => { + vi.mocked(shopifyFetch).mockResolvedValueOnce( + response(201, {shop: {id: 123, name: 'My Store', domain: 'x.myshopify.com'}, admin_api_token: 'shpat_token'}), + ) + + await createPreviewStore({}, {storage: inMemoryStorage('instance-1')}) + + expect(vi.mocked(shopifyFetch).mock.calls[0]![1]!.body).toBe('{}') + }) + + test.each([ + ['service_unavailable', 503, 'Preview store creation is not enabled yet.'], + ['not_in_rollout', 503, 'Preview store creation is not enabled yet.'], + ['dependency_unavailable', 503, 'Preview store creation is temporarily unavailable.'], + ['preview_store_create_failed', 422, 'Preview store creation failed.'], + ['preview_store_create_failed', 500, 'Preview store creation failed.'], + ['shop_name_invalid', 422, 'The preview store name was rejected.'], + ['shop_name_banned_keyword', 422, 'The preview store name was rejected.'], + ])('maps %s errors', async (errorCode, status, message) => { + vi.mocked(shopifyFetch).mockResolvedValueOnce(response(status, {error_code: errorCode, message: 'server message'})) + + await expect(createPreviewStore({}, {storage: inMemoryStorage('instance-1')})).rejects.toThrow(message) + }) + + test('rejects malformed success responses without leaking the admin API token', async () => { + vi.mocked(shopifyFetch).mockResolvedValueOnce(response(201, {shop: {id: 123}, admin_api_token: 'shpat_token'})) + + await expect(createPreviewStore({}, {storage: inMemoryStorage('instance-1')})).rejects.toMatchObject({ + message: 'Preview store creation response is missing required fields.', + tryMessage: expect.stringContaining('"admin_api_token":"[REDACTED]"'), + }) + }) +}) diff --git a/packages/store/src/cli/services/store/create/preview/client.ts b/packages/store/src/cli/services/store/create/preview/client.ts new file mode 100644 index 00000000000..154a3611e91 --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/client.ts @@ -0,0 +1,194 @@ +import {appManagementFqdn, normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {randomUUID} from '@shopify/cli-kit/node/crypto' +import {AbortError} from '@shopify/cli-kit/node/error' +import {shopifyFetch} from '@shopify/cli-kit/node/http' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' + +export const CLI_INSTANCE_HEADER = 'X-Shopify-CLI-Instance' +export const CLI_VERSION_HEADER = 'X-Shopify-CLI-Version' + +interface PreviewStoreClientStorageSchema { + cliInstanceId?: string +} + +let _clientStorage: LocalStorage | undefined + +function clientStorage() { + _clientStorage ??= new LocalStorage({projectName: 'shopify-cli-store'}) + return _clientStorage +} + +export interface PreviewStoreClientOptions { + storage?: LocalStorage +} + +interface PreviewStoreCreateRequest { + name?: string + country?: string +} + +interface PreviewStoreResponseShop { + id: string + name: string + domain: string +} + +export interface PreviewStoreCreateResponse { + shop: PreviewStoreResponseShop + placeholderAccountUuid?: string + adminApiToken: string +} + +interface RawPreviewStoreResponseShop { + id?: unknown + name?: unknown + domain?: unknown +} + +interface RawPreviewStoreCreateResponse { + shop?: RawPreviewStoreResponseShop + placeholder_account_uuid?: unknown + admin_api_token?: unknown +} + +interface RawPreviewStoreErrorResponse { + error_code?: string + message?: string +} + +export function getOrCreateCliInstanceId( + storage: LocalStorage = clientStorage(), +): string { + const existing = storage.get('cliInstanceId') + if (typeof existing === 'string' && existing.length > 0) return existing + + const next = randomUUID() + storage.set('cliInstanceId', next) + return next +} + +export function previewStoreCreateHeaders(cliInstanceId: string): Record { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': `Shopify CLI; v=${CLI_KIT_VERSION}`, + [CLI_INSTANCE_HEADER]: cliInstanceId, + [CLI_VERSION_HEADER]: CLI_KIT_VERSION, + } +} + +export async function createPreviewStore( + request: PreviewStoreCreateRequest, + options: PreviewStoreClientOptions = {}, +): Promise { + const fqdn = await appManagementFqdn() + const url = `https://${fqdn}/services/preview-stores` + const body = JSON.stringify({ + ...(request.name ? {name: request.name} : {}), + ...(request.country ? {country_code: request.country} : {}), + }) + + const response = await shopifyFetch(url, { + method: 'POST', + headers: previewStoreCreateHeaders(getOrCreateCliInstanceId(options.storage)), + body, + }) + + const rawText = await response.text() + if (!response.ok) { + const error = previewStoreError(response.status, rawText) + throw new AbortError(error.message, error.tryMessage) + } + + let parsed: RawPreviewStoreCreateResponse + try { + parsed = JSON.parse(rawText) as RawPreviewStoreCreateResponse + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + throw new AbortError( + 'Preview store creation returned a non-JSON response.', + `Parse error: ${message}. Body (truncated): ${rawText.slice(0, 500)}`, + ) + } + + return narrowResponse(parsed) +} + +function previewStoreError(status: number, rawText: string): {message: string; tryMessage?: string} { + const parsed = parseErrorBody(rawText) + const errorCode = parsed.error_code + const message = parsed.message + + if (errorCode === 'service_unavailable' || errorCode === 'not_in_rollout') { + return { + message: 'Preview store creation is not enabled yet.', + tryMessage: 'Try again later.', + } + } + + if (errorCode === 'dependency_unavailable') { + return {message: 'Preview store creation is temporarily unavailable.', tryMessage: 'Try again in a few minutes.'} + } + + if (errorCode === 'preview_store_create_failed') { + return {message: 'Preview store creation failed.', tryMessage: 'Try again later.'} + } + + if (errorCode === 'shop_name_banned_keyword' || errorCode === 'shop_name_invalid') { + return {message: 'The preview store name was rejected.', tryMessage: 'Use a different store name and try again.'} + } + + return { + message: `Preview store creation failed with HTTP ${status}.`, + tryMessage: message ?? (rawText.length > 0 ? rawText.slice(0, 1000) : 'No response body returned.'), + } +} + +function parseErrorBody(rawText: string): RawPreviewStoreErrorResponse { + if (rawText.length === 0) return {} + + try { + const parsed: unknown = JSON.parse(rawText) + if (!parsed || typeof parsed !== 'object') return {} + + const body = parsed as Record + return { + ...(typeof body.error_code === 'string' ? {error_code: body.error_code} : {}), + ...(typeof body.message === 'string' ? {message: body.message} : {}), + } + } catch (error) { + if (error instanceof SyntaxError) return {} + throw error + } +} + +function narrowResponse(parsed: RawPreviewStoreCreateResponse): PreviewStoreCreateResponse { + const shop = parsed.shop + const id = typeof shop?.id === 'string' || typeof shop?.id === 'number' ? String(shop.id) : undefined + const name = typeof shop?.name === 'string' ? shop.name : undefined + const domain = typeof shop?.domain === 'string' ? normalizeStoreFqdn(shop.domain) : undefined + const adminApiToken = typeof parsed.admin_api_token === 'string' ? parsed.admin_api_token : undefined + const placeholderAccountUuid = + typeof parsed.placeholder_account_uuid === 'string' ? parsed.placeholder_account_uuid : undefined + + if (!id || !name || !domain || !adminApiToken) { + throw new AbortError( + 'Preview store creation response is missing required fields.', + `Got: ${JSON.stringify(redactPreviewStoreResponse(parsed)).slice(0, 500)}`, + ) + } + + return { + shop: {id, name, domain}, + adminApiToken, + ...(placeholderAccountUuid ? {placeholderAccountUuid} : {}), + } +} + +function redactPreviewStoreResponse(parsed: RawPreviewStoreCreateResponse): RawPreviewStoreCreateResponse { + return { + ...parsed, + ...(parsed.admin_api_token ? {admin_api_token: '[REDACTED]'} : {}), + } +} diff --git a/packages/store/src/cli/services/store/create/preview/index.test.ts b/packages/store/src/cli/services/store/create/preview/index.test.ts new file mode 100644 index 00000000000..3fd9da6cdb9 --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/index.test.ts @@ -0,0 +1,135 @@ +import {PREVIEW_USER_ID_PREFIX, createPreviewStoreCommand} from './index.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../../auth/config.js' +import {describe, expect, test, vi} from 'vitest' + +describe('preview store create service', () => { + test('persists the created preview store in the store-auth cache', async () => { + const setStoredStoreAppSession = vi.fn() + const recordStoreFqdnMetadata = vi.fn() + const setLastSeenUserId = vi.fn() + + const result = await createPreviewStoreCommand( + {name: 'Lavender Candles', country: 'US'}, + { + createPreviewStore: vi.fn(async () => ({ + shop: {id: '123', name: 'Lavender Candles', domain: 'x12y45z.myshopify.com'}, + placeholderAccountUuid: 'placeholder-uuid', + adminApiToken: 'shpat_token', + })), + setStoredStoreAppSession, + recordStoreFqdnMetadata, + setLastSeenUserId, + now: () => new Date('2026-06-08T12:00:00.000Z'), + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith({ + store: 'x12y45z.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: `${PREVIEW_USER_ID_PREFIX}placeholder-uuid`, + accessToken: 'shpat_token', + scopes: [], + acquiredAt: '2026-06-08T12:00:00.000Z', + kind: 'preview', + preview: { + shopId: '123', + name: 'Lavender Candles', + placeholderAccountUuid: 'placeholder-uuid', + country: 'US', + createdAt: '2026-06-08T12:00:00.000Z', + }, + }) + expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(1, 'x12y45z.myshopify.com', false) + expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(2, 'x12y45z.myshopify.com', true) + expect(setLastSeenUserId).toHaveBeenCalledWith(`${PREVIEW_USER_ID_PREFIX}placeholder-uuid`) + expect(result).toEqual({ + status: 'success', + message: + 'Your preview store is ready. This preview store is temporary. Create a free Shopify account to save it and start selling.', + store: {id: '123', name: 'Lavender Candles', subdomain: 'x12y45z.myshopify.com', requestedCountry: 'US'}, + nextSteps: [ + 'Use shopify store execute --store x12y45z.myshopify.com to add products, collections, pages, and more.', + 'Use shopify theme pull and shopify theme push to edit your store design.', + ], + }) + }) + + test('uses the shop id as the preview user id when no placeholder account uuid is returned', async () => { + const setStoredStoreAppSession = vi.fn() + const setLastSeenUserId = vi.fn() + + await createPreviewStoreCommand( + {name: 'Lavender Candles'}, + { + createPreviewStore: vi.fn(async () => ({ + shop: {id: '123', name: 'Lavender Candles', domain: 'x12y45z.myshopify.com'}, + adminApiToken: 'shpat_token', + })), + setStoredStoreAppSession, + recordStoreFqdnMetadata: vi.fn(), + setLastSeenUserId, + now: () => new Date('2026-06-08T12:00:00.000Z'), + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({userId: `${PREVIEW_USER_ID_PREFIX}123`}), + ) + expect(setLastSeenUserId).toHaveBeenCalledWith(`${PREVIEW_USER_ID_PREFIX}123`) + }) + + test('does not persist a store session when recording store metadata fails', async () => { + const setStoredStoreAppSession = vi.fn() + const recordStoreFqdnMetadata = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Metadata failed.')) + const setLastSeenUserId = vi.fn() + + await expect( + createPreviewStoreCommand( + {name: 'Lavender Candles'}, + { + createPreviewStore: vi.fn(async () => ({ + shop: {id: '123', name: 'Lavender Candles', domain: 'x12y45z.myshopify.com'}, + adminApiToken: 'shpat_token', + })), + setStoredStoreAppSession, + recordStoreFqdnMetadata, + setLastSeenUserId, + now: () => new Date('2026-06-08T12:00:00.000Z'), + }, + ), + ).rejects.toThrow('Metadata failed.') + + expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(1, 'x12y45z.myshopify.com', false) + expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(2, 'x12y45z.myshopify.com', true) + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + expect(setLastSeenUserId).not.toHaveBeenCalled() + }) + + test('does not persist a store session when preview store creation fails', async () => { + const setStoredStoreAppSession = vi.fn() + const recordStoreFqdnMetadata = vi.fn() + const setLastSeenUserId = vi.fn() + + await expect( + createPreviewStoreCommand( + {name: 'Lavender Candles'}, + { + createPreviewStore: vi.fn(async () => { + throw new Error('Preview store creation failed.') + }), + setStoredStoreAppSession, + recordStoreFqdnMetadata, + setLastSeenUserId, + now: () => new Date('2026-06-08T12:00:00.000Z'), + }, + ), + ).rejects.toThrow('Preview store creation failed.') + + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + expect(recordStoreFqdnMetadata).not.toHaveBeenCalled() + expect(setLastSeenUserId).not.toHaveBeenCalled() + }) +}) diff --git a/packages/store/src/cli/services/store/create/preview/index.ts b/packages/store/src/cli/services/store/create/preview/index.ts new file mode 100644 index 00000000000..20d1d46e4e2 --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/index.ts @@ -0,0 +1,106 @@ +import {PreviewStoreClientOptions, PreviewStoreCreateResponse, createPreviewStore} from './client.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../../auth/config.js' +import {setStoredStoreAppSession} from '../../auth/session-store.js' +import {recordStoreFqdnMetadata} from '../../attribution.js' +import {setLastSeenUserId} from '@shopify/cli-kit/node/session' + +export const PREVIEW_USER_ID_PREFIX = 'preview:' + +interface CreatePreviewStoreInput { + name?: string + country?: string + client?: PreviewStoreClientOptions +} + +interface CreatePreviewStoreDependencies { + createPreviewStore: typeof createPreviewStore + setStoredStoreAppSession: typeof setStoredStoreAppSession + recordStoreFqdnMetadata: typeof recordStoreFqdnMetadata + setLastSeenUserId: typeof setLastSeenUserId + now: () => Date +} + +export interface CreatePreviewStoreResult { + status: 'success' + message: string + store: { + id: string + name: string + subdomain: string + requestedCountry?: string + } + nextSteps: string[] +} + +const defaultDependencies: CreatePreviewStoreDependencies = { + createPreviewStore, + setStoredStoreAppSession, + recordStoreFqdnMetadata, + setLastSeenUserId, + now: () => new Date(), +} + +export async function createPreviewStoreCommand( + input: CreatePreviewStoreInput, + dependencies: Partial = {}, +): Promise { + const resolvedDependencies = {...defaultDependencies, ...dependencies} + const response = await resolvedDependencies.createPreviewStore( + { + name: input.name, + country: input.country, + }, + input.client, + ) + + return persistPreviewStoreSession(response, input.country, resolvedDependencies) +} + +function previewUserId(response: PreviewStoreCreateResponse): string { + return `${PREVIEW_USER_ID_PREFIX}${response.placeholderAccountUuid ?? response.shop.id}` +} + +async function persistPreviewStoreSession( + response: PreviewStoreCreateResponse, + country: string | undefined, + dependencies: CreatePreviewStoreDependencies, +): Promise { + const acquiredAt = dependencies.now().toISOString() + const userId = previewUserId(response) + + await dependencies.recordStoreFqdnMetadata(response.shop.domain, false) + await dependencies.recordStoreFqdnMetadata(response.shop.domain, true) + dependencies.setStoredStoreAppSession({ + store: response.shop.domain, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId, + accessToken: response.adminApiToken, + scopes: [], + acquiredAt, + kind: 'preview', + preview: { + shopId: response.shop.id, + name: response.shop.name, + createdAt: acquiredAt, + ...(response.placeholderAccountUuid ? {placeholderAccountUuid: response.placeholderAccountUuid} : {}), + ...(country ? {country} : {}), + }, + }) + dependencies.setLastSeenUserId(userId) + + return { + status: 'success', + message: + 'Your preview store is ready. This preview store is temporary. Create a free Shopify account to save it and start selling.', + store: { + id: response.shop.id, + name: response.shop.name, + subdomain: response.shop.domain, + ...(country ? {requestedCountry: country} : {}), + }, + nextSteps: [ + `Use shopify store execute --store ${response.shop.domain} to add products, collections, pages, and more.`, + 'Use shopify theme pull and shopify theme push to edit your store design.', + ], + } +} diff --git a/packages/store/src/cli/services/store/create/preview/result.test.ts b/packages/store/src/cli/services/store/create/preview/result.test.ts new file mode 100644 index 00000000000..03ab1323359 --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/result.test.ts @@ -0,0 +1,73 @@ +import {writeCreatePreviewStoreResult} from './result.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/output', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/output') + return {...actual, outputResult: vi.fn()} +}) +vi.mock('@shopify/cli-kit/node/ui', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/ui') + return {...actual, renderSuccess: vi.fn()} +}) + +const result = { + status: 'success' as const, + message: 'Your preview store is ready.', + store: {id: '123', name: 'Lavender Candles', subdomain: 'x12y45z.myshopify.com', requestedCountry: 'US'}, + nextSteps: [ + 'Use shopify store execute --store x12y45z.myshopify.com to add products, collections, pages, and more.', + 'Use shopify theme pull and shopify theme push to edit your store design.', + ], +} + +describe('preview store create result presenter', () => { + test('writes JSON output without links that are not returned by the backend yet', () => { + writeCreatePreviewStoreResult(result, 'json') + + expect(outputResult).toHaveBeenCalledWith( + JSON.stringify( + { + status: 'success', + message: 'Your preview store is ready.', + store: {id: '123', name: 'Lavender Candles', subdomain: 'x12y45z.myshopify.com', requestedCountry: 'US'}, + next_steps: result.nextSteps, + }, + null, + 2, + ), + ) + }) + + test('renders text output with store details and next steps', () => { + writeCreatePreviewStoreResult(result, 'text') + + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + headline: 'Preview store created.', + body: 'Your preview store is ready.', + customSections: [ + { + title: 'Store', + body: { + tabularData: [ + ['Name', 'Lavender Candles'], + ['Domain', 'x12y45z.myshopify.com'], + ['Requested country', 'US'], + ], + firstColumnSubdued: true, + }, + }, + ], + nextSteps: expect.arrayContaining([ + [ + 'Use ', + {command: 'shopify store execute --store x12y45z.myshopify.com'}, + ' to add products, collections, pages, and more.', + ], + ]), + }), + ) + }) +}) diff --git a/packages/store/src/cli/services/store/create/preview/result.ts b/packages/store/src/cli/services/store/create/preview/result.ts new file mode 100644 index 00000000000..21e6e67766b --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/result.ts @@ -0,0 +1,62 @@ +import {type CreatePreviewStoreResult} from './index.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' + +type CreatePreviewStoreOutputFormat = 'text' | 'json' + +export function writeCreatePreviewStoreResult( + result: CreatePreviewStoreResult, + format: CreatePreviewStoreOutputFormat, +): void { + if (format === 'json') { + outputResult(JSON.stringify(serializeAsJson(result), null, 2)) + return + } + + renderTextResult(result) +} + +function serializeAsJson(result: CreatePreviewStoreResult) { + return { + status: result.status, + message: result.message, + store: result.store, + next_steps: result.nextSteps, + } +} + +function renderTextResult(result: CreatePreviewStoreResult): void { + renderSuccess({ + headline: 'Preview store created.', + body: result.message, + customSections: [ + { + title: 'Store', + body: { + tabularData: [ + ['Name', result.store.name], + ['Domain', result.store.subdomain], + ...(result.store.requestedCountry + ? ([['Requested country', result.store.requestedCountry]] as string[][]) + : []), + ], + firstColumnSubdued: true, + }, + }, + ], + nextSteps: [ + [ + 'Use ', + {command: `shopify store execute --store ${result.store.subdomain}`}, + ' to add products, collections, pages, and more.', + ], + [ + 'Use ', + {command: 'shopify theme pull'}, + ' and ', + {command: 'shopify theme push'}, + ' to edit your store design.', + ], + ], + }) +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 73e67d78154..2d571415986 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,8 +1,10 @@ import StoreAuth from './cli/commands/store/auth.js' +import StoreCreatePreview from './cli/commands/store/create/preview.js' import StoreExecute from './cli/commands/store/execute.js' const COMMANDS = { 'store:auth': StoreAuth, + 'store:create:preview': StoreCreatePreview, 'store:execute': StoreExecute, }