Add production preview store create command#7764
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
a95b7a4 to
897e9ea
Compare
e6b9771 to
f0160e7
Compare
72f8f19 to
b504c98
Compare
b504c98 to
c3f7626
Compare
| const acquiredAt = dependencies.now().toISOString() | ||
| const userId = previewUserId(response) | ||
|
|
||
| await dependencies.recordStoreFqdnMetadata(response.shop.domain, false) |
There was a problem hiding this comment.
🐛 Bug: Two concerns at this call site, both pointing at the same fix:
-
Ordering risk: Both
recordStoreFqdnMetadatacalls precedesetStoredStoreAppSession. The testdoes not persist a store session when recording store metadata failslocks 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. -
Redundant pair of calls: In
auth/index.tsthevalidated:false→validated:truetransition straddles the OAuth handshake, so both states are meaningful. Here the two calls fire back-to-back with no intervening validation step, andrecordStoreFqdnMetadataObject.assigns into a shared bag — so thefalsewrite is immediately overwritten bytrueand adds no telemetry signal.
Suggestion: Persist the session first, then record metadata once with true:
| 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) |
c3f7626 to
07d1745
Compare
07d1745 to
f4ec45d
Compare
| let _clientStorage: LocalStorage<PreviewStoreClientStorageSchema> | undefined | ||
|
|
||
| function clientStorage() { | ||
| _clientStorage ??= new LocalStorage<PreviewStoreClientStorageSchema>({projectName: 'shopify-cli-store'}) |
There was a problem hiding this comment.
Nit: Should we extract this into a shared constant?
There was a problem hiding this comment.
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: [ |
There was a problem hiding this comment.
These next steps might drift from the non-JSON result. Any way to consolidate?
| 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)}`, |
There was a problem hiding this comment.
🔒 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) |
There was a problem hiding this comment.
🐛 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.
| 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 { |
There was a problem hiding this comment.
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.` |
There was a problem hiding this comment.
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
| 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.', |
There was a problem hiding this comment.
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'}) |
There was a problem hiding this comment.
I don't think so, this is just a memoization strategy we use often throughout the CLI.
| message?: string | ||
| } | ||
|
|
||
| export function getOrCreateCliInstanceId( |
There was a problem hiding this comment.
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') { |
There was a problem hiding this comment.
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' | |||
There was a problem hiding this comment.
I don't think we generally do barrel files like this
| @@ -0,0 +1,68 @@ | |||
| import {type CreatePreviewStoreResult} from './index.js' | |||
There was a problem hiding this comment.
I love everything about this file. So nice to see UI kit being used.

WHY are these changes introduced?
This productionizes
shopify store create previewon 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:
POST /services/preview-storesnamevariables.storeCreatePayload.countryshopdetails,placeholder_account_uuid,admin_api_token, andaccess_urlWHAT is this pull request doing?
shopify store create previewwith flags:--name--countrywith two-letter code validation--json--no-color/--verbosehttps://<app-management-fqdn>/services/preview-storeswithout Basic auth or Identity auth.X-Shopify-CLI-Instancefrom a stable locally persisted install idX-Shopify-CLI-VersionUser-Agentvariables.storeCreatePayload.countrywhen--countryis providedshop.idshop.nameshop.domainplaceholder_account_uuidwhen presentadmin_api_tokenaccess_urlkind: 'preview', so the store is immediately usable byshopify store execute --store <domain>withoutshopify store auth.admin_api_tokenand tokenizedaccess_urlfrom malformed-response diagnostics.Notes / open questions
422 preview_store_create_failedpath and the currently observed backend500 preview_store_create_failedpath defensively.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:
pnpm shopify store create preview --country USpnpm shopify store execute --store <returned-domain>should use the cached preview-store Admin API tokenLocal 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.tspnpm nx run store:lint --skip-nx-cache --output-style=streampnpm --filter @shopify/store run type-check/usr/bin/git diff --checkPost-release steps
None.
Checklist
LocalStorage; command logic is platform-neutral.@shopify/cliand@shopify/store.