diff --git a/.changeset/store-create-preview-command.md b/.changeset/store-create-preview-command.md new file mode 100644 index 0000000000..12b8a35fbc --- /dev/null +++ b/.changeset/store-create-preview-command.md @@ -0,0 +1,8 @@ +--- +'@shopify/store': minor +'@shopify/cli': minor +--- + +Add `shopify store create preview`, which mints a Preview Store via Core's preview-stores orchestrator (M1 of the [Preview Store for AI Agent Surfaces](https://vault.shopify.io/gsd/proposals/60T12R) initiative) and persists the returned admin API token as a `kind: 'preview'` stored session. After running it, `shopify store execute --store ` works against the new store immediately, with no PKCE flow and no browser interaction. + +The Core endpoint defaults to the local development rig (`https://app.shop.dev`) and the prototype basic-auth credentials. Override with `--core-url`, `--cli-username`, `--cli-secret` or the equivalent `SHOPIFY_FLAG_PREVIEW_STORE_*` environment variables when targeting other environments. diff --git a/packages/cli/README.md b/packages/cli/README.md index d96a305ea8..72159c4712 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) @@ -2130,6 +2131,51 @@ EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products --json ``` +## `shopify store create preview` + +Create a Preview Store backed by a placeholder identity. + +``` +USAGE + $ shopify store create preview [--cli-secret ] [--cli-username ] [--core-url ] [--country ] + [--email ] [-j] [--no-color] [-n ] [--verbose] + +FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. + -n, --shop-name= [env: SHOPIFY_FLAG_PREVIEW_STORE_SHOP_NAME] Subdomain prefix for the new preview store. + Auto-generated if omitted. + --cli-secret= [env: SHOPIFY_FLAG_PREVIEW_STORE_CLI_SECRET] Basic-auth secret for the Core endpoint. + Defaults to the development rig value. + --cli-username= [env: SHOPIFY_FLAG_PREVIEW_STORE_CLI_USERNAME] Basic-auth username for the Core endpoint. + Defaults to the development rig value. + --core-url= [env: SHOPIFY_FLAG_PREVIEW_STORE_CORE_URL] Base URL of the Core preview-stores + orchestrator. Defaults to the local development rig. + --country= [env: SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY] ISO country code for the new store. Defaults to + "US". + --email= [env: SHOPIFY_FLAG_PREVIEW_STORE_EMAIL] Email to associate with the placeholder identity. + Defaults to a generated `@previewstore.invalid` address chosen by Core. + --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 Store backed by a placeholder identity. + + Creates a Preview Store via the Core preview-stores orchestrator. The returned admin API token is persisted locally as + a stored store-auth session, so the new store can be used immediately as a target for `shopify store execute --store + ` without any further login. + + The orchestrator endpoint, basic-auth username, and basic-auth secret default to the local development rig values used + by the M1 prototype. Override them with `--core-url`, `--cli-username`, and `--cli-secret` (or the corresponding + `SHOPIFY_FLAG_*` environment variables) when targeting a non-default environment. + +EXAMPLES + $ shopify store create preview --shop-name my-preview + + $ shopify store create preview --shop-name my-preview --email demo@previewstore.invalid + + $ shopify store create preview --shop-name my-preview --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 57aa12022d..273ca3f368 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5737,6 +5737,111 @@ "strict": true, "summary": "Authenticate an app against a store for store commands." }, + "store:create:preview": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/store", + "description": "Creates a Preview Store via the Core preview-stores orchestrator. The returned admin API token is persisted locally as a stored store-auth session, so the new store can be used immediately as a target for `shopify store execute --store ` without any further login.\n\nThe orchestrator endpoint, basic-auth username, and basic-auth secret default to the local development rig values used by the M1 prototype. Override them with `--core-url`, `--cli-username`, and `--cli-secret` (or the corresponding `SHOPIFY_FLAG_*` environment variables) when targeting a non-default environment.", + "descriptionWithMarkdown": "Creates a Preview Store via the Core preview-stores orchestrator. The returned admin API token is persisted locally as a stored store-auth session, so the new store can be used immediately as a target for `shopify store execute --store ` without any further login.\n\nThe orchestrator endpoint, basic-auth username, and basic-auth secret default to the local development rig values used by the M1 prototype. Override them with `--core-url`, `--cli-username`, and `--cli-secret` (or the corresponding `SHOPIFY_FLAG_*` environment variables) when targeting a non-default environment.", + "examples": [ + "<%= config.bin %> <%= command.id %> --shop-name my-preview", + "<%= config.bin %> <%= command.id %> --shop-name my-preview --email demo@previewstore.invalid", + "<%= config.bin %> <%= command.id %> --shop-name my-preview --json" + ], + "flags": { + "cli-secret": { + "description": "Basic-auth secret for the Core endpoint. Defaults to the development rig value.", + "env": "SHOPIFY_FLAG_PREVIEW_STORE_CLI_SECRET", + "hasDynamicHelp": false, + "multiple": false, + "name": "cli-secret", + "required": false, + "type": "option" + }, + "cli-username": { + "description": "Basic-auth username for the Core endpoint. Defaults to the development rig value.", + "env": "SHOPIFY_FLAG_PREVIEW_STORE_CLI_USERNAME", + "hasDynamicHelp": false, + "multiple": false, + "name": "cli-username", + "required": false, + "type": "option" + }, + "core-url": { + "description": "Base URL of the Core preview-stores orchestrator. Defaults to the local development rig.", + "env": "SHOPIFY_FLAG_PREVIEW_STORE_CORE_URL", + "hasDynamicHelp": false, + "multiple": false, + "name": "core-url", + "required": false, + "type": "option" + }, + "country": { + "description": "ISO country code for the new store. Defaults to \"US\".", + "env": "SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY", + "hasDynamicHelp": false, + "multiple": false, + "name": "country", + "required": false, + "type": "option" + }, + "email": { + "description": "Email to associate with the placeholder identity. Defaults to a generated `@previewstore.invalid` address chosen by Core.", + "env": "SHOPIFY_FLAG_PREVIEW_STORE_EMAIL", + "hasDynamicHelp": false, + "multiple": false, + "name": "email", + "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" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "shop-name": { + "char": "n", + "description": "Subdomain prefix for the new preview store. Auto-generated if omitted.", + "env": "SHOPIFY_FLAG_PREVIEW_STORE_SHOP_NAME", + "hasDynamicHelp": false, + "multiple": false, + "name": "shop-name", + "required": false, + "type": "option" + }, + "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 Store backed by a placeholder identity." + }, "store:execute": { "aliases": [ ], 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 0000000000..21c94a5b0a --- /dev/null +++ b/packages/store/src/cli/commands/store/create/preview.ts @@ -0,0 +1,81 @@ +import {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 {Flags} from '@oclif/core' + +export default class StoreCreatePreview extends StoreCommand { + static summary = 'Create a Preview Store backed by a placeholder identity.' + + static descriptionWithMarkdown = `Creates a Preview Store via the Core preview-stores orchestrator. The returned admin API token is persisted locally as a stored store-auth session, so the new store can be used immediately as a target for \`shopify store execute --store \` without any further login. + +The orchestrator endpoint, basic-auth username, and basic-auth secret default to the local development rig values used by the M1 prototype. Override them with \`--core-url\`, \`--cli-username\`, and \`--cli-secret\` (or the corresponding \`SHOPIFY_FLAG_*\` environment variables) when targeting a non-default environment.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --shop-name my-preview', + '<%= config.bin %> <%= command.id %> --shop-name my-preview --email demo@previewstore.invalid', + '<%= config.bin %> <%= command.id %> --shop-name my-preview --json', + ] + + static flags = { + ...globalFlags, + ...jsonFlag, + 'shop-name': Flags.string({ + char: 'n', + description: 'Subdomain prefix for the new preview store. Auto-generated if omitted.', + env: 'SHOPIFY_FLAG_PREVIEW_STORE_SHOP_NAME', + required: false, + }), + email: Flags.string({ + description: + 'Email to associate with the placeholder identity. Defaults to a generated `@previewstore.invalid` address chosen by Core.', + env: 'SHOPIFY_FLAG_PREVIEW_STORE_EMAIL', + required: false, + }), + country: Flags.string({ + description: 'ISO country code for the new store. Defaults to "US".', + env: 'SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY', + required: false, + }), + 'core-url': Flags.string({ + description: 'Base URL of the Core preview-stores orchestrator. Defaults to the local development rig.', + env: 'SHOPIFY_FLAG_PREVIEW_STORE_CORE_URL', + required: false, + }), + 'cli-username': Flags.string({ + description: 'Basic-auth username for the Core endpoint. Defaults to the development rig value.', + env: 'SHOPIFY_FLAG_PREVIEW_STORE_CLI_USERNAME', + required: false, + }), + 'cli-secret': Flags.string({ + description: 'Basic-auth secret for the Core endpoint. Defaults to the development rig value.', + env: 'SHOPIFY_FLAG_PREVIEW_STORE_CLI_SECRET', + required: false, + }), + } + + public async run(): Promise { + const {flags} = await this.parse(StoreCreatePreview) + + const shopName = flags['shop-name'] ?? generateDefaultShopName() + + const result = await createPreviewStoreCommand({ + shopName, + email: flags.email, + country: flags.country, + client: { + coreUrl: flags['core-url'], + cliUsername: flags['cli-username'], + cliSecret: flags['cli-secret'], + }, + }) + + writeCreatePreviewStoreResult(result, flags.json ? 'json' : 'text') + } +} + +function generateDefaultShopName(): string { + return `preview-${Math.floor(Date.now() / 1000)}` +} 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 01c4e6d284..f3cddcba86 100644 --- a/packages/store/src/cli/services/store/auth/session-store.ts +++ b/packages/store/src/cli/services/store/auth/session-store.ts @@ -12,7 +12,6 @@ import {LocalStorage} from '@shopify/cli-kit/node/local-storage' * Stored sessions written before this discriminator existed have no `kind` field and are * read back as 'standard'. */ -// Kept internal for now; re-exported when the `shopify store create preview` command lands. type StoredStoreSessionKind = 'standard' | 'preview' /** @@ -58,9 +57,6 @@ export interface StoredStoreAppSession { /** * A stored session that has been narrowed to a preview-store session. The `preview` * metadata is guaranteed to be present. - * - * Kept internal for now; re-exported when the `shopify store create preview` command - * lands and external callers need to construct or pass them around. */ type StoredPreviewStoreSession = StoredStoreAppSession & { kind: '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 0000000000..64e9bf5fb4 --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/client.test.ts @@ -0,0 +1,148 @@ +import { + DEFAULT_PREVIEW_CLI_SECRET, + DEFAULT_PREVIEW_CLI_USERNAME, + DEFAULT_PREVIEW_CORE_URL, + createPreviewStore, + defaultPreviewStoreClientOptions, +} from './client.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {shopifyFetch} from '@shopify/cli-kit/node/http' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/http') + +function fakeOkResponse(body: unknown): Response { + return { + ok: true, + status: 201, + text: vi.fn().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response +} + +function fakeNotOkResponse(status: number, body: string): Response { + return { + ok: false, + status, + text: vi.fn().mockResolvedValue(body), + } as unknown as Response +} + +const validBody = { + shop_id: 21, + shop_permanent_domain: 'preview-1.myshopify.io', + placeholder_account_uuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + admin_api_token: 'shpat_preview_token', + magic_link_url: 'https://app.shop.dev/auth/preview-store?token=abc', +} + +describe('defaultPreviewStoreClientOptions', () => { + test('returns the development-rig defaults when no overrides are passed', () => { + expect(defaultPreviewStoreClientOptions()).toEqual({ + coreUrl: DEFAULT_PREVIEW_CORE_URL, + cliUsername: DEFAULT_PREVIEW_CLI_USERNAME, + cliSecret: DEFAULT_PREVIEW_CLI_SECRET, + }) + }) + + test('layers overrides on top of the defaults', () => { + expect(defaultPreviewStoreClientOptions({coreUrl: 'https://core.example/'})).toEqual({ + coreUrl: 'https://core.example/', + cliUsername: DEFAULT_PREVIEW_CLI_USERNAME, + cliSecret: DEFAULT_PREVIEW_CLI_SECRET, + }) + }) +}) + +describe('createPreviewStore', () => { + beforeEach(() => { + vi.mocked(shopifyFetch).mockReset() + }) + + test('POSTs to /services/preview-stores with snake_case body and basic auth', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(fakeOkResponse(validBody)) + + await createPreviewStore({shopName: 'preview-demo', email: 'demo@previewstore.invalid', country: 'CA'}) + + expect(shopifyFetch).toHaveBeenCalledTimes(1) + const [url, init] = vi.mocked(shopifyFetch).mock.calls[0]! + expect(url).toBe(`${DEFAULT_PREVIEW_CORE_URL}/services/preview-stores`) + expect(init?.method).toBe('POST') + expect(JSON.parse(init?.body as string)).toEqual({ + shop_name: 'preview-demo', + email: 'demo@previewstore.invalid', + country: 'CA', + }) + const headers = init?.headers as Record + expect(headers['Content-Type']).toBe('application/json') + expect(headers.Accept).toBe('application/json') + expect(headers.Authorization).toMatch(/^Basic /) + const decoded = Buffer.from(headers.Authorization!.replace('Basic ', ''), 'base64').toString() + expect(decoded).toBe(`${DEFAULT_PREVIEW_CLI_USERNAME}:${DEFAULT_PREVIEW_CLI_SECRET}`) + }) + + test('omits optional fields from the request body when they are not provided', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(fakeOkResponse(validBody)) + + await createPreviewStore({shopName: 'preview-demo'}) + + const init = vi.mocked(shopifyFetch).mock.calls[0]![1]! + expect(JSON.parse(init.body as string)).toEqual({shop_name: 'preview-demo'}) + }) + + test('translates the snake_case JSON contract into the camelCase response type', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(fakeOkResponse(validBody)) + + const response = await createPreviewStore({shopName: 'preview-demo'}) + + expect(response).toEqual({ + shopId: 21, + shopPermanentDomain: 'preview-1.myshopify.io', + placeholderAccountUuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + adminApiToken: 'shpat_preview_token', + magicLinkUrl: 'https://app.shop.dev/auth/preview-store?token=abc', + }) + }) + + test('strips a trailing slash from the configured Core URL when building the request URL', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(fakeOkResponse(validBody)) + + await createPreviewStore( + {shopName: 'preview-demo'}, + {coreUrl: 'https://core.example/', cliUsername: 'u', cliSecret: 's'}, + ) + + expect(vi.mocked(shopifyFetch).mock.calls[0]![0]).toBe('https://core.example/services/preview-stores') + }) + + test('surfaces non-2xx responses with status, URL, and a truncated body', async () => { + vi.mocked(shopifyFetch).mockResolvedValue( + fakeNotOkResponse(502, '{"error":"identity_api_error","detail":"Identity request failed"}'), + ) + + await expect(createPreviewStore({shopName: 'preview-demo'})).rejects.toMatchObject({ + message: expect.stringContaining('returned HTTP 502'), + }) + }) + + test('surfaces a non-empty body even when the response is OK but not JSON', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(fakeOkResponse('oops')) + + await expect(createPreviewStore({shopName: 'preview-demo'})).rejects.toBeInstanceOf(AbortError) + }) + + test('rejects responses missing required identifier fields', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(fakeOkResponse({...validBody, admin_api_token: undefined})) + + await expect(createPreviewStore({shopName: 'preview-demo'})).rejects.toMatchObject({ + message: expect.stringContaining('missing required fields'), + }) + }) + + test('rejects responses where required fields have the wrong type', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(fakeOkResponse({...validBody, shop_id: '21'})) + + await expect(createPreviewStore({shopName: 'preview-demo'})).rejects.toMatchObject({ + message: expect.stringContaining('missing required fields'), + }) + }) +}) 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 0000000000..1e7de30094 --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/client.ts @@ -0,0 +1,137 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {shopifyFetch} from '@shopify/cli-kit/node/http' + +/** + * Default Core orchestrator URL for preview-store creation. Today this is the local + * `app.shop.dev` rig used by the M1 prototype; the production endpoint will replace + * this once Growth deploys the `services/preview-stores` controller. + */ +export const DEFAULT_PREVIEW_CORE_URL = 'https://app.shop.dev' + +/** + * Default basic-auth credentials for the prototype Core endpoint. These match the + * dev-only secret hardcoded in `Services::PreviewStoresController` and MUST be + * replaced with a proper service-auth pattern before this command ships to a + * non-developer release channel. + */ +export const DEFAULT_PREVIEW_CLI_USERNAME = 'preview-store-cli' +export const DEFAULT_PREVIEW_CLI_SECRET = 'preview-store-cli-dev' + +export interface PreviewStoreClientOptions { + coreUrl: string + cliUsername: string + cliSecret: string +} + +interface PreviewStoreCreateRequest { + shopName: string + email?: string + country?: string +} + +/** + * Response from `POST /services/preview-stores`. Field names mirror the snake_case + * JSON contract emitted by `Services::PreviewStoresController#create`. + */ +export interface PreviewStoreCreateResponse { + shopId: number + shopPermanentDomain: string + placeholderAccountUuid: string + adminApiToken: string + magicLinkUrl: string +} + +interface RawPreviewStoreCreateResponse { + shop_id?: unknown + shop_permanent_domain?: unknown + placeholder_account_uuid?: unknown + admin_api_token?: unknown + magic_link_url?: unknown +} + +export function defaultPreviewStoreClientOptions( + overrides: Partial = {}, +): PreviewStoreClientOptions { + return { + coreUrl: overrides.coreUrl ?? DEFAULT_PREVIEW_CORE_URL, + cliUsername: overrides.cliUsername ?? DEFAULT_PREVIEW_CLI_USERNAME, + cliSecret: overrides.cliSecret ?? DEFAULT_PREVIEW_CLI_SECRET, + } +} + +function basicAuthHeader(username: string, secret: string): string { + return `Basic ${Buffer.from(`${username}:${secret}`).toString('base64')}` +} + +/** + * POSTs the create-preview-store request to Core's orchestrator. Translates the + * snake_case JSON contract into our camelCase response type and rejects responses + * that don't carry the required identifiers. + */ +export async function createPreviewStore( + request: PreviewStoreCreateRequest, + options: PreviewStoreClientOptions = defaultPreviewStoreClientOptions(), +): Promise { + const url = `${options.coreUrl.replace(/\/$/, '')}/services/preview-stores` + const body = JSON.stringify({ + shop_name: request.shopName, + ...(request.email ? {email: request.email} : {}), + ...(request.country ? {country: request.country} : {}), + }) + + const response = await shopifyFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: basicAuthHeader(options.cliUsername, options.cliSecret), + }, + body, + }) + + const rawText = await response.text() + if (!response.ok) { + throw new AbortError( + `Preview store creation failed: ${url} returned HTTP ${response.status}.`, + rawText.length > 0 ? rawText.slice(0, 1000) : 'No response body returned.', + ) + } + + 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 from ${url}.`, + `Parse error: ${message}. Body (truncated): ${rawText.slice(0, 500)}`, + ) + } + + return narrowResponse(parsed, url) +} + +function narrowResponse(parsed: RawPreviewStoreCreateResponse, url: string): PreviewStoreCreateResponse { + const shopId = typeof parsed.shop_id === 'number' ? parsed.shop_id : undefined + const shopPermanentDomain = + typeof parsed.shop_permanent_domain === 'string' ? parsed.shop_permanent_domain : undefined + const placeholderAccountUuid = + typeof parsed.placeholder_account_uuid === 'string' ? parsed.placeholder_account_uuid : undefined + const adminApiToken = typeof parsed.admin_api_token === 'string' ? parsed.admin_api_token : undefined + const magicLinkUrl = typeof parsed.magic_link_url === 'string' ? parsed.magic_link_url : undefined + + if (!shopId || !shopPermanentDomain || !placeholderAccountUuid || !adminApiToken || !magicLinkUrl) { + throw new AbortError( + `Preview store creation response from ${url} is missing required fields.`, + `Got: ${JSON.stringify(parsed).slice(0, 500)}`, + ) + } + + return { + shopId, + shopPermanentDomain, + placeholderAccountUuid, + adminApiToken, + magicLinkUrl, + } +} 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 0000000000..f0c6b0fde6 --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/index.test.ts @@ -0,0 +1,128 @@ +import {createPreviewStore} from './client.js' +import {createPreviewStoreCommand, placeholderUserId, PLACEHOLDER_USER_ID_PREFIX} from './index.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' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./client.js', async () => { + // `defaultPreviewStoreClientOptions` is a pure factory the orchestrator calls before + // delegating to the network helper. Keep it real so the persisted session reflects + // the resolved options; only stub `createPreviewStore` itself. + const actual = await vi.importActual('./client.js') + return { + ...actual, + createPreviewStore: vi.fn(), + } +}) +vi.mock('../../auth/session-store.js') +vi.mock('../../attribution.js') +vi.mock('@shopify/cli-kit/node/session') + +const fakeResponse = { + shopId: 21, + shopPermanentDomain: 'preview-1.myshopify.io', + placeholderAccountUuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + adminApiToken: 'shpat_preview_token', + magicLinkUrl: 'https://app.shop.dev/auth/preview-store?token=abc', +} + +const fixedNow = new Date('2026-03-27T00:00:00.000Z') + +describe('placeholderUserId', () => { + test('namespaces the placeholder UUID with a stable, non-numeric prefix', () => { + expect(placeholderUserId('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')).toBe( + `${PLACEHOLDER_USER_ID_PREFIX}aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa`, + ) + }) +}) + +describe('createPreviewStoreCommand', () => { + beforeEach(() => { + vi.mocked(createPreviewStore).mockReset() + vi.mocked(setStoredStoreAppSession).mockReset() + vi.mocked(recordStoreFqdnMetadata).mockReset() + vi.mocked(setLastSeenUserId).mockReset() + vi.mocked(recordStoreFqdnMetadata).mockResolvedValue(undefined) + }) + + test('forwards the request to the client, including override client options', async () => { + vi.mocked(createPreviewStore).mockResolvedValue(fakeResponse) + + await createPreviewStoreCommand( + {shopName: 'preview-demo', email: 'demo@previewstore.invalid', country: 'CA', client: {coreUrl: 'https://core.example'}}, + () => fixedNow, + ) + + expect(createPreviewStore).toHaveBeenCalledWith( + {shopName: 'preview-demo', email: 'demo@previewstore.invalid', country: 'CA'}, + expect.objectContaining({coreUrl: 'https://core.example'}), + ) + }) + + test('persists the response as a kind=preview stored session under a placeholder userId', async () => { + vi.mocked(createPreviewStore).mockResolvedValue(fakeResponse) + + await createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow) + + expect(setStoredStoreAppSession).toHaveBeenCalledTimes(1) + expect(setStoredStoreAppSession).toHaveBeenCalledWith({ + store: fakeResponse.shopPermanentDomain, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: `${PLACEHOLDER_USER_ID_PREFIX}${fakeResponse.placeholderAccountUuid}`, + accessToken: fakeResponse.adminApiToken, + // The granted scope set is not surfaced by Core; we record an empty array as a sentinel. + scopes: [], + acquiredAt: fixedNow.toISOString(), + kind: 'preview', + preview: { + placeholderAccountUuid: fakeResponse.placeholderAccountUuid, + coreUrl: 'https://app.shop.dev', + magicLinkUrl: fakeResponse.magicLinkUrl, + magicLinkExpiresAt: '2026-03-27T00:30:00.000Z', + }, + }) + }) + + test('records fqdn metadata twice (unvalidated then validated) so analytics see the same shape as PKCE auth', async () => { + vi.mocked(createPreviewStore).mockResolvedValue(fakeResponse) + + await createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow) + + expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(fakeResponse.shopPermanentDomain, false) + expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(fakeResponse.shopPermanentDomain, true) + }) + + test('updates the last-seen user id so the session lookup later picks up the new identity', async () => { + vi.mocked(createPreviewStore).mockResolvedValue(fakeResponse) + + await createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow) + + expect(setLastSeenUserId).toHaveBeenCalledWith(`${PLACEHOLDER_USER_ID_PREFIX}${fakeResponse.placeholderAccountUuid}`) + }) + + test('returns a result struct that surfaces the persisted user id and the magic-link expiry derived locally', async () => { + vi.mocked(createPreviewStore).mockResolvedValue(fakeResponse) + + const result = await createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow) + + expect(result).toEqual({ + shopId: fakeResponse.shopId, + shopPermanentDomain: fakeResponse.shopPermanentDomain, + placeholderAccountUuid: fakeResponse.placeholderAccountUuid, + adminApiToken: fakeResponse.adminApiToken, + magicLinkUrl: fakeResponse.magicLinkUrl, + magicLinkExpiresAt: '2026-03-27T00:30:00.000Z', + userId: `${PLACEHOLDER_USER_ID_PREFIX}${fakeResponse.placeholderAccountUuid}`, + }) + }) + + test('does not invoke setStoredStoreAppSession if the underlying client call rejects', async () => { + vi.mocked(createPreviewStore).mockRejectedValue(new Error('boom')) + + await expect(createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow)).rejects.toThrow('boom') + expect(setStoredStoreAppSession).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 0000000000..19b5bcf75f --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/index.ts @@ -0,0 +1,126 @@ +import { + PreviewStoreClientOptions, + PreviewStoreCreateResponse, + createPreviewStore, + defaultPreviewStoreClientOptions, +} 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' + +/** + * Preview-store sessions are issued by Core's preview-stores orchestrator with the + * `shopify-cli-connector-app` pre-installed against ~80 default Admin API scopes + * (see `PreviewStores::Create::DEFAULT_DEV_INSTALL_APP_KEY` in shop/world). Core + * does not surface the granted scope list to the CLI, so we record an empty array + * as a sentinel: downstream consumers (`store execute`) don't validate scopes + * against the stored list, and the recovery surface for preview sessions doesn't + * suggest re-auth with a scopes flag. + */ +const PREVIEW_STORE_SCOPES: string[] = [] + +/** + * The session store keys per-store buckets by `userId`. Preview-store sessions are + * not tied to a real Shopify user, but they are tied to a placeholder identity, so + * we derive a stable `userId` from the placeholder UUID and namespace it with a + * known prefix to make it (a) non-numeric (so analytics filters can isolate it) + * and (b) collision-free with PKCE-issued sessions, which use numeric Shopify user + * ids. + */ +export const PLACEHOLDER_USER_ID_PREFIX = 'placeholder:' + +/** Magic-link TTL fixed by Core (`PreviewStores::Create::MAGIC_LINK_TTL`). */ +const MAGIC_LINK_TTL_MS = 30 * 60 * 1000 + +interface CreatePreviewStoreInput { + shopName: string + email?: string + country?: string + client?: Partial +} + +export interface CreatePreviewStoreResult { + shopId: number + shopPermanentDomain: string + placeholderAccountUuid: string + adminApiToken: string + magicLinkUrl: string + magicLinkExpiresAt: string + /** The synthetic user id under which the session was persisted in the local store. */ + userId: string +} + +export function placeholderUserId(placeholderAccountUuid: string): string { + return `${PLACEHOLDER_USER_ID_PREFIX}${placeholderAccountUuid}` +} + +/** + * Mints a preview-store via Core's preview-stores orchestrator and persists the + * returned admin token as a `kind: 'preview'` session in the same LocalStorage + * bucket that `shopify store execute` reads from. After this call, the preview + * store can be used as a target for `shopify store execute --store ...` + * with no further setup. + */ +export async function createPreviewStoreCommand( + input: CreatePreviewStoreInput, + now: () => Date = () => new Date(), +): Promise { + const clientOptions = defaultPreviewStoreClientOptions(input.client) + + const response = await createPreviewStore( + { + shopName: input.shopName, + email: input.email, + country: input.country, + }, + clientOptions, + ) + + return persistPreviewStoreSession(response, clientOptions, now) +} + +function persistPreviewStoreSession( + response: PreviewStoreCreateResponse, + clientOptions: PreviewStoreClientOptions, + now: () => Date, +): CreatePreviewStoreResult { + const acquiredAt = now() + const acquiredAtIso = acquiredAt.toISOString() + const magicLinkExpiresAt = new Date(acquiredAt.getTime() + MAGIC_LINK_TTL_MS).toISOString() + const userId = placeholderUserId(response.placeholderAccountUuid) + + // Record fqdn metadata before and after so analytics see the same shape we emit + // for PKCE-authed stores: an unvalidated record at request time, validated record + // once we have a usable token. + recordStoreFqdnMetadata(response.shopPermanentDomain, false).catch(() => undefined) + + setStoredStoreAppSession({ + store: response.shopPermanentDomain, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId, + accessToken: response.adminApiToken, + scopes: PREVIEW_STORE_SCOPES, + acquiredAt: acquiredAtIso, + kind: 'preview', + preview: { + placeholderAccountUuid: response.placeholderAccountUuid, + coreUrl: clientOptions.coreUrl, + magicLinkUrl: response.magicLinkUrl, + magicLinkExpiresAt, + }, + }) + + recordStoreFqdnMetadata(response.shopPermanentDomain, true).catch(() => undefined) + setLastSeenUserId(userId) + + return { + shopId: response.shopId, + shopPermanentDomain: response.shopPermanentDomain, + placeholderAccountUuid: response.placeholderAccountUuid, + adminApiToken: response.adminApiToken, + magicLinkUrl: response.magicLinkUrl, + magicLinkExpiresAt, + userId, + } +} 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 0000000000..4302b35193 --- /dev/null +++ b/packages/store/src/cli/services/store/create/preview/result.ts @@ -0,0 +1,74 @@ +import {type CreatePreviewStoreResult} from './index.js' +import {outputResult, outputContent, outputToken} 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(serializeAsJson(result)) + return + } + renderTextResult(result) +} + +function serializeAsJson(result: CreatePreviewStoreResult): string { + return JSON.stringify( + { + shopId: result.shopId, + shopPermanentDomain: result.shopPermanentDomain, + placeholderAccountUuid: result.placeholderAccountUuid, + adminApiToken: result.adminApiToken, + magicLinkUrl: result.magicLinkUrl, + magicLinkExpiresAt: result.magicLinkExpiresAt, + userId: result.userId, + }, + null, + 2, + ) +} + +function renderTextResult(result: CreatePreviewStoreResult): void { + renderSuccess({ + headline: `Preview store created: ${result.shopPermanentDomain}`, + customSections: [ + { + title: 'Store', + body: { + list: { + items: [ + `Shop ID: ${result.shopId}`, + `Permanent domain: ${result.shopPermanentDomain}`, + `Placeholder account: ${result.placeholderAccountUuid}`, + ], + }, + }, + }, + { + title: 'Magic link (one-time-use, expires in ~30 minutes)', + body: result.magicLinkUrl, + }, + ], + nextSteps: [ + [ + 'Run an Admin GraphQL query against the new store:', + { + command: `shopify store execute --store ${result.shopPermanentDomain} --query '{ shop { name } }'`, + }, + ], + ['Open the magic link above in a browser to land in admin without an Identity login.'], + ], + }) + + // The admin token is not displayed in the rendered text output to avoid accidental + // copy-paste leakage. JSON output (used by AI agents) still surfaces it, since the + // agent needs it to reason about the session shape. + outputResult( + outputContent`Preview store admin token stored locally and accessible via ${outputToken.genericShellCommand( + `shopify store execute --store ${result.shopPermanentDomain}`, + )}.`.value, + ) +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 73e67d7815..2d57141598 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, }