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) |
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.
f4ec45d to
1fc7440
Compare

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.