Skip to content

Add production preview store create command#7764

Open
alfonso-noriega wants to merge 1 commit into
mainfrom
productionize-preview-store-create-main
Open

Add production preview store create command#7764
alfonso-noriega wants to merge 1 commit into
mainfrom
productionize-preview-store-create-main

Conversation

@alfonso-noriega

@alfonso-noriega alfonso-noriega commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

WHY are these changes introduced?

This productionizes shopify store create preview on top of the shipped preview-store backend endpoint so an agent can create a preview store and immediately use the returned Admin API token through existing store command auth plumbing.

The command targets the production endpoint contract from shop/world:

  • unauthenticated POST /services/preview-stores
  • request body with optional top-level name
  • optional country sent as variables.storeCreatePayload.country
  • CLI attribution headers for rollout/rate-limit/tracking
  • response body with shop details, placeholder_account_uuid, admin_api_token, and access_url

WHAT is this pull request doing?

  • Adds shopify store create preview with flags:
    • --name
    • --country with two-letter code validation
    • --json
    • existing global --no-color / --verbose
  • Calls https://<app-management-fqdn>/services/preview-stores without Basic auth or Identity auth.
  • Sends:
    • X-Shopify-CLI-Instance from a stable locally persisted install id
    • X-Shopify-CLI-Version
    • User-Agent
    • JSON accept/content headers
    • variables.storeCreatePayload.country when --country is provided
  • Parses the production response shape:
    • shop.id
    • shop.name
    • shop.domain
    • placeholder_account_uuid when present
    • admin_api_token
    • access_url
  • Persists the returned Admin API token into the existing local store-auth session cache, tagged as kind: 'preview', so the store is immediately usable by shopify store execute --store <domain> without shopify store auth.
  • Persists preview-store metadata, including the returned access URL, in the store-auth session cache.
  • Prints the backend-returned access URL in text output and includes it in JSON output.
  • Redacts both admin_api_token and tokenized access_url from malformed-response diagnostics.
  • Adds generated command README, OCLIF manifest, shopify.dev command interface docs, and a changeset.

Notes / open questions

  • Preview-store token expiry is intentionally not managed in this PR.
  • The CLI handles both the requested 422 preview_store_create_failed path and the currently observed backend 500 preview_store_create_failed path defensively.
  • Store-auth persistence is performed before best-effort store FQDN metadata recording so a metadata failure does not orphan a successfully created preview store without local credentials.

How to test your changes?

Ideally, the endpoint will be ready in prod to test this (protected behind a flag) and we can test as follows:

  • Enable flag for your CLI instance UUID
  • Run from this branch pnpm shopify store create preview --country US
  • A new preview store should be created
  • The returned access URL should open the preview store
  • pnpm shopify store execute --store <returned-domain> should use the cached preview-store Admin API token

Local validation run:

  • pnpm --filter @shopify/store exec vitest run src/cli/commands/store/create/preview.test.ts src/cli/services/store/create/preview/client.test.ts src/cli/services/store/create/preview/index.test.ts src/cli/services/store/create/preview/result.test.ts src/cli/services/store/auth/session-store.test.ts
  • pnpm nx run store:lint --skip-nx-cache --output-style=stream
  • pnpm --filter @shopify/store run type-check
  • /usr/bin/git diff --check

Post-release steps

None.

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows) — local persistence uses existing LocalStorage; command logic is platform-neutral.
  • I've considered possible documentation changes — command README, OCLIF manifest, generated docs interface, and generated docs data are updated.
  • I've considered analytics changes to measure impact — request sends CLI instance/version/user-agent headers read by the backend tracking path; store FQDN metadata is recorded after persistence.
  • The change is user-facing — minor changeset added for @shopify/cli and @shopify/store.

alfonso-noriega commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot added the Area: @shopify/cli @shopify/cli package issues label Jun 9, 2026
@alfonso-noriega alfonso-noriega force-pushed the productionize-preview-store-create-main branch 6 times, most recently from a95b7a4 to 897e9ea Compare June 9, 2026 11:52
@alfonso-noriega alfonso-noriega force-pushed the productionize-preview-store-create-main branch 5 times, most recently from e6b9771 to f0160e7 Compare June 10, 2026 13:52
@alfonso-noriega alfonso-noriega force-pushed the productionize-preview-store-create-main branch 2 times, most recently from 72f8f19 to b504c98 Compare June 11, 2026 12:24
@alfonso-noriega alfonso-noriega marked this pull request as ready for review June 11, 2026 13:29
@alfonso-noriega alfonso-noriega requested review from a team as code owners June 11, 2026 13:29
@alfonso-noriega alfonso-noriega force-pushed the productionize-preview-store-create-main branch from b504c98 to c3f7626 Compare June 11, 2026 14:35

@tizmagik tizmagik left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One minor thing inline, otherwise lgtm


Review assisted by pair-review

const acquiredAt = dependencies.now().toISOString()
const userId = previewUserId(response)

await dependencies.recordStoreFqdnMetadata(response.shop.domain, false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: Two concerns at this call site, both pointing at the same fix:

  1. Ordering risk: Both recordStoreFqdnMetadata calls precede setStoredStoreAppSession. The test does not persist a store session when recording store metadata fails locks this in, but at that point the backend has already created the preview store and issued an admin token — if metadata throws (e.g., 'bubble' contexts), the token is dropped and the merchant is left with an orphaned store and no local credential. Metadata is best-effort observability; it should not gate token persistence.

  2. Redundant pair of calls: In auth/index.ts the validated:falsevalidated:true transition straddles the OAuth handshake, so both states are meaningful. Here the two calls fire back-to-back with no intervening validation step, and recordStoreFqdnMetadata Object.assigns into a shared bag — so the false write is immediately overwritten by true and adds no telemetry signal.

Suggestion: Persist the session first, then record metadata once with true:

Suggested change
await dependencies.recordStoreFqdnMetadata(response.shop.domain, false)
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)
await dependencies.recordStoreFqdnMetadata(response.shop.domain, true)

@alfonso-noriega alfonso-noriega force-pushed the productionize-preview-store-create-main branch 2 times, most recently from 07d1745 to f4ec45d Compare June 12, 2026 12:42
let _clientStorage: LocalStorage<PreviewStoreClientStorageSchema> | undefined

function clientStorage() {
_clientStorage ??= new LocalStorage<PreviewStoreClientStorageSchema>({projectName: 'shopify-cli-store'})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Should we extract this into a shared constant?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, this is just a memoization strategy we use often throughout the CLI.

accessUrl: 'https://app.shopify.com/auth/preview-store?token=access-token',
requestedCountry: 'US',
},
nextSteps: [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These next steps might drift from the non-JSON result. Any way to consolidate?

@dmerand dmerand left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent pal comments

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)}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security: Consider reviewing the JSON parse failure path because it includes the raw response body in the user-visible AbortError diagnostic. The preview-store endpoint can return an Admin API token and tokenized access URL, and the current redaction helper only runs after JSON parsing succeeds. A malformed or truncated credential-bearing response could therefore expose secrets in CLI output or copied bug reports.

Suggestion: Redact raw response text before adding it to any AbortError try message, or avoid including the body for this endpoint. The redaction should cover at least admin_api_token, access_url, and token-shaped preview-store access URLs. It would also be worth applying the same protection to any other fallback diagnostics that include raw response text for this endpoint.

},
})
dependencies.setLastSeenUserId(userId)
await dependencies.recordStoreFqdnMetadata(response.shop.domain, true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: Worth reviewing this await because it can turn a successful remote store creation into a failed command after credentials have already been persisted locally. If recordStoreFqdnMetadata rejects, the user may see a failure and never receive the access URL, even though the preview store exists and the Admin API token is cached. That conflicts with the intended best-effort nature of this metadata write and can encourage duplicate retries.

Suggestion: Treat recordStoreFqdnMetadata as non-blocking for command success. Catch failures after persisting the session and last-seen user, optionally debug-log them, and still return the success result so the user receives the access URL.

Suggested change
await dependencies.recordStoreFqdnMetadata(response.shop.domain, true)
try {
await dependencies.recordStoreFqdnMetadata(response.shop.domain, true)
} catch {
// Store metadata is best-effort; credentials and access URL are already persisted.
}

import {renderSingleTask} from '@shopify/cli-kit/node/ui'
import {Flags} from '@oclif/core'

export default class StoreCreatePreview extends StoreCommand {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we ship this as hidden for now? It won't be usable until we flip on the flag.

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.`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something seems off about this text - the merchant is described as a 3rd party, but the CLI descriptions are usually more about what the command will do for you, the CLI user. I'd expect something like

Suggested change
static descriptionWithMarkdown = `Creates a new preview Shopify store for a merchant who wants to try Shopify without needing to immediately create an account.`
static descriptionWithMarkdown = `Creates a new Shopify store, with no need for an existing account.`

required: false,
}),
country: Flags.string({
description: 'Two-letter ISO 3166-1 alpha-2 country code for the store, such as US, CA, or GB.',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do people generally know what ISO 3166-1 alpha-2 is? I only heard of it now... Maybe we can leave out that part? I don't think there are other popular 2-char country code systems.

let _clientStorage: LocalStorage<PreviewStoreClientStorageSchema> | undefined

function clientStorage() {
_clientStorage ??= new LocalStorage<PreviewStoreClientStorageSchema>({projectName: 'shopify-cli-store'})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, this is just a memoization strategy we use often throughout the CLI.

message?: string
}

export function getOrCreateCliInstanceId(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like something that belongs in CLI-kit - it should have utility beyond the current need. Especially for analytics.

const errorCode = parsed.error_code
const message = parsed.message

if (errorCode === 'service_unavailable' || errorCode === 'not_in_rollout') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checking, are these error codes part of a confirmed contract with the API?

Also, service_unavailable sounds more like the service should be available but is currently down - like a 500.

@@ -0,0 +1,109 @@
import {PreviewStoreClientOptions, PreviewStoreCreateResponse, createPreviewStore} from './client.js'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we generally do barrel files like this

@@ -0,0 +1,68 @@
import {type CreatePreviewStoreResult} from './index.js'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love everything about this file. So nice to see UI kit being used.

@alfonso-noriega alfonso-noriega force-pushed the productionize-preview-store-create-main branch from f4ec45d to 1fc7440 Compare June 15, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: @shopify/cli @shopify/cli package issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants