From bbc99b459ce09a4658f8e8b389f126005c204b3f Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Mon, 15 Jun 2026 12:32:29 +0300 Subject: [PATCH] Support numeric store IDs and Shop GIDs for the shared --store flag The shared --store flag (defined in the store package) previously accepted only myshopify.com domains. It now also accepts a numeric store ID and a Shop GID (gid://shopify/Shop/), each resolved to the store's domain via the Business Platform. Resolution is centralized in StoreCommand.parse (a post-parse hook), so every store command gains the capability with no per-command changes. IDs and GIDs resolve through a single org-agnostic Business Platform "destinations" lookup (currentUserAccount.destination(id:)); the domain path is unchanged and stays pure string normalization with zero network calls. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/store-flag-id-gid.md | 5 + .../interfaces/store-auth.interface.ts | 2 +- .../interfaces/store-execute.interface.ts | 2 +- .../interfaces/store-info.interface.ts | 2 +- .../generated/generated_docs_data_v2.json | 12 +- packages/cli/README.md | 9 +- packages/cli/oclif.manifest.json | 6 +- .../generated/resolve-store-by-id.ts | 64 +++++ .../queries/resolve-store-by-id.graphql | 8 + packages/store/src/cli/flags.ts | 5 +- .../store/src/cli/utilities/store-command.ts | 24 +- .../cli/utilities/store-resolution.test.ts | 220 ++++++++++++++++++ .../src/cli/utilities/store-resolution.ts | 125 ++++++++++ 13 files changed, 465 insertions(+), 19 deletions(-) create mode 100644 .changeset/store-flag-id-gid.md create mode 100644 packages/store/src/cli/api/graphql/business-platform-destinations/generated/resolve-store-by-id.ts create mode 100644 packages/store/src/cli/api/graphql/business-platform-destinations/queries/resolve-store-by-id.graphql create mode 100644 packages/store/src/cli/utilities/store-resolution.test.ts create mode 100644 packages/store/src/cli/utilities/store-resolution.ts diff --git a/.changeset/store-flag-id-gid.md b/.changeset/store-flag-id-gid.md new file mode 100644 index 00000000000..75952aa7ed0 --- /dev/null +++ b/.changeset/store-flag-id-gid.md @@ -0,0 +1,5 @@ +--- +'@shopify/store': minor +--- + +`shopify store` commands now accept a numeric store ID or a Shop GID (`gid://shopify/Shop/`) for `--store`, in addition to the myshopify.com domain. IDs and GIDs are resolved to the store's domain via the Business Platform. The `--store` value now also trims surrounding whitespace. diff --git a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts index 16f479cc306..c2847c9b0f2 100644 --- a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts @@ -23,7 +23,7 @@ export interface storeauth { '--scopes ': string /** - * The myshopify.com domain of the store. + * The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/). * @environment SHOPIFY_FLAG_STORE */ '-s, --store ': string diff --git a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts index 5897fffbb58..9c313e8b3b5 100644 --- a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts @@ -41,7 +41,7 @@ export interface storeexecute { '--query-file '?: string /** - * The myshopify.com domain of the store. + * The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/). * @environment SHOPIFY_FLAG_STORE */ '-s, --store ': string diff --git a/docs-shopify.dev/commands/interfaces/store-info.interface.ts b/docs-shopify.dev/commands/interfaces/store-info.interface.ts index 01c264dca63..d47b7237952 100644 --- a/docs-shopify.dev/commands/interfaces/store-info.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-info.interface.ts @@ -17,7 +17,7 @@ export interface storeinfo { '--no-color'?: '' /** - * The myshopify.com domain of the store. + * The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/). * @environment SHOPIFY_FLAG_STORE */ '-s, --store ': string diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 3c9205db032..4fc7e30a7e9 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4177,11 +4177,11 @@ "syntaxKind": "PropertySignature", "name": "-s, --store ", "value": "string", - "description": "The myshopify.com domain of the store.", + "description": "The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/).", "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "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.\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}" + "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 store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/).\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}" } }, "storeexecute": { @@ -4277,7 +4277,7 @@ "syntaxKind": "PropertySignature", "name": "-s, --store ", "value": "string", - "description": "The myshopify.com domain of the store.", + "description": "The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/).", "environmentValue": "SHOPIFY_FLAG_STORE" }, { @@ -4290,7 +4290,7 @@ "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\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 * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\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 * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } }, "storeinfo": { @@ -4332,11 +4332,11 @@ "syntaxKind": "PropertySignature", "name": "-s, --store ", "value": "string", - "description": "The myshopify.com domain of the store.", + "description": "The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/).", "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface storeinfo {\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 * The myshopify.com domain of the store.\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}" + "value": "export interface storeinfo {\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 * The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/).\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}" } }, "themecheck": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 02da54b2d35..c0cc9f79f8e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2112,7 +2112,8 @@ USAGE FLAGS -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. - -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store. + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The store to operate on: its myshopify.com domain, numeric + store ID, or Shop GID (gid://shopify/Shop/). --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. --scopes= (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app. --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. @@ -2143,7 +2144,8 @@ USAGE FLAGS -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string. - -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store. + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The store to operate on: its myshopify.com domain, + numeric store ID, or Shop GID (gid://shopify/Shop/). -v, --variables= [env: SHOPIFY_FLAG_VARIABLES] The values for any GraphQL variables in your query or mutation, in JSON format. --allow-mutations [env: SHOPIFY_FLAG_ALLOW_MUTATIONS] Allow GraphQL mutations to run against the target @@ -2188,7 +2190,8 @@ USAGE FLAGS -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. - -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store. + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The store to operate on: its myshopify.com domain, numeric + store ID, or Shop GID (gid://shopify/Shop/). --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index a43dda537c7..5a16fd968d3 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5710,7 +5710,7 @@ }, "store": { "char": "s", - "description": "The myshopify.com domain of the store.", + "description": "The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/).", "env": "SHOPIFY_FLAG_STORE", "hasDynamicHelp": false, "multiple": false, @@ -5804,7 +5804,7 @@ }, "store": { "char": "s", - "description": "The myshopify.com domain of the store.", + "description": "The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/).", "env": "SHOPIFY_FLAG_STORE", "hasDynamicHelp": false, "multiple": false, @@ -5894,7 +5894,7 @@ }, "store": { "char": "s", - "description": "The myshopify.com domain of the store.", + "description": "The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/).", "env": "SHOPIFY_FLAG_STORE", "hasDynamicHelp": false, "multiple": false, diff --git a/packages/store/src/cli/api/graphql/business-platform-destinations/generated/resolve-store-by-id.ts b/packages/store/src/cli/api/graphql/business-platform-destinations/generated/resolve-store-by-id.ts new file mode 100644 index 00000000000..fa11c1b6c8f --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-destinations/generated/resolve-store-by-id.ts @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type ResolveStoreByIdQueryVariables = Types.Exact<{ + id: Types.Scalars['DestinationPublicID']['input'] +}> + +export type ResolveStoreByIdQuery = { + currentUserAccount?: {destination?: {webUrl: string; primaryDomain?: string | null} | null} | null +} + +export const ResolveStoreById = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'ResolveStoreById'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'id'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'DestinationPublicID'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'currentUserAccount'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'destination'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'id'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'id'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'webUrl'}}, + {kind: 'Field', name: {kind: 'Name', value: 'primaryDomain'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/store/src/cli/api/graphql/business-platform-destinations/queries/resolve-store-by-id.graphql b/packages/store/src/cli/api/graphql/business-platform-destinations/queries/resolve-store-by-id.graphql new file mode 100644 index 00000000000..887fd44871b --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-destinations/queries/resolve-store-by-id.graphql @@ -0,0 +1,8 @@ +query ResolveStoreById($id: DestinationPublicID!) { + currentUserAccount { + destination(id: $id) { + webUrl + primaryDomain + } + } +} diff --git a/packages/store/src/cli/flags.ts b/packages/store/src/cli/flags.ts index 541d7ecda73..c134233fa2f 100644 --- a/packages/store/src/cli/flags.ts +++ b/packages/store/src/cli/flags.ts @@ -1,12 +1,11 @@ -import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' export const storeFlags = { store: Flags.string({ char: 's', - description: 'The myshopify.com domain of the store.', + description: + 'The store to operate on: its myshopify.com domain, numeric store ID, or Shop GID (gid://shopify/Shop/).', env: 'SHOPIFY_FLAG_STORE', - parse: async (input) => normalizeStoreFqdn(input), required: true, }), } diff --git a/packages/store/src/cli/utilities/store-command.ts b/packages/store/src/cli/utilities/store-command.ts index d9d969dc0d8..39481b3a263 100644 --- a/packages/store/src/cli/utilities/store-command.ts +++ b/packages/store/src/cli/utilities/store-command.ts @@ -1,8 +1,30 @@ -import Command from '@shopify/cli-kit/node/base-command' +import {resolveStore} from './store-resolution.js' +import Command, {ArgOutput, FlagOutput} from '@shopify/cli-kit/node/base-command' +import {Input, ParserOutput} from '@oclif/core/parser' /** * Base class that includes shared behavior for all store commands. */ export default abstract class StoreCommand extends Command { public abstract run(): Promise + + // Resolve `--store` AFTER oclif has parsed and validated every other flag. Doing the + // auth+network resolution here (rather than in the flag's `parse`) means unknown/required/ + // exclusive flag errors are reported before we ever reach out to the Business Platform, so + // users don't get surprising auth prompts ahead of a plain flag-validation error. + protected async parse< + TFlags extends FlagOutput & {path?: string; verbose?: boolean}, + TGlobalFlags extends FlagOutput, + TArgs extends ArgOutput, + >( + options?: Input, + argv?: string[], + ): Promise & {argv: string[]}> { + const result = await super.parse(options, argv) + const flags = result?.flags as {store?: unknown} | undefined + if (flags && typeof flags.store === 'string') { + flags.store = await resolveStore(flags.store) + } + return result + } } diff --git a/packages/store/src/cli/utilities/store-resolution.test.ts b/packages/store/src/cli/utilities/store-resolution.test.ts new file mode 100644 index 00000000000..4531701bc73 --- /dev/null +++ b/packages/store/src/cli/utilities/store-resolution.test.ts @@ -0,0 +1,220 @@ +import {resolveStore} from './store-resolution.js' +import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' +import {encodeGid} from '@shopify/cli-kit/common/gid' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/business-platform') +vi.mock('@shopify/cli-kit/node/session') + +const SHOP = 'shop.myshopify.com' + +// BP's DestinationPublicID input scalar expects a base64-encoded `gid://organization/ShopifyShop/`. +// Compute the expected value dynamically rather than hardcoding the base64 string. +const ENCODED_123 = encodeGid('gid://organization/ShopifyShop/123') + +function destinationResponse(fields: {webUrl?: string | null; primaryDomain?: string | null}) { + return {currentUserAccount: {destination: {webUrl: fields.webUrl, primaryDomain: fields.primaryDomain}}} +} + +describe('resolveStore', () => { + beforeEach(() => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('bp-token') + }) + + describe('domain inputs (no network)', () => { + test.each([ + ['shop.myshopify.com', 'shop.myshopify.com'], + ['shop', 'shop.myshopify.com'], + ['https://shop.myshopify.com/admin', 'shop.myshopify.com'], + // normalizeStoreFqdn does not lowercase the subdomain; resolveStore must preserve + // that exact behavior for domain inputs. + ['SHOP', 'SHOP.myshopify.com'], + ])('%s resolves to %s without any BP calls', async (input, expected) => { + const result = await resolveStore(input) + + expect(result).toBe(expected) + expect(businessPlatformRequestDoc).not.toHaveBeenCalled() + }) + }) + + describe('numeric store ID', () => { + test('resolves via a single destinations lookup keyed by the encoded shop id', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce( + destinationResponse({webUrl: `https://${SHOP}`, primaryDomain: `https://${SHOP}`}) as never, + ) + + const result = await resolveStore('123') + + expect(result).toBe(SHOP) + expect(businessPlatformRequestDoc).toHaveBeenCalledTimes(1) + expect(vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0]).toMatchObject({ + variables: {id: ENCODED_123}, + }) + }) + + test('prefers webUrl over a custom primaryDomain', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce( + destinationResponse({webUrl: `https://${SHOP}`, primaryDomain: 'https://www.custom-domain.com'}) as never, + ) + + const result = await resolveStore('123') + + expect(result).toBe(SHOP) + }) + + test('falls back to primaryDomain when webUrl yields no host', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce( + destinationResponse({webUrl: null, primaryDomain: `https://${SHOP}`}) as never, + ) + + const result = await resolveStore('123') + + expect(result).toBe(SHOP) + }) + + test('strips scheme and trailing slash from the returned domain', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce( + destinationResponse({webUrl: `https://${SHOP}/`, primaryDomain: null}) as never, + ) + + const result = await resolveStore('123') + + expect(result).toBe(SHOP) + }) + + test('returns a bare-host webUrl (no scheme) as-is', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce( + destinationResponse({webUrl: SHOP, primaryDomain: null}) as never, + ) + + const result = await resolveStore('123') + + expect(result).toBe(SHOP) + }) + + test('throws AbortError mentioning the id when the destination is null', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce({currentUserAccount: {destination: null}} as never) + + const err = await resolveStore('999').catch((error: unknown) => error) + + expect(err).toBeInstanceOf(AbortError) + expect((err as AbortError).message).toContain('999') + expect((err as AbortError).message).toContain("Couldn't find") + }) + + test('throws AbortError mentioning the id when currentUserAccount is null', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce({currentUserAccount: null} as never) + + const err = await resolveStore('999').catch((error: unknown) => error) + + expect(err).toBeInstanceOf(AbortError) + expect((err as AbortError).message).toContain('999') + expect((err as AbortError).message).toContain("Couldn't find") + }) + + test('throws a distinct AbortError when the store is found but has no domain', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce( + destinationResponse({webUrl: null, primaryDomain: null}) as never, + ) + + const err = await resolveStore('123').catch((error: unknown) => error) + + expect(err).toBeInstanceOf(AbortError) + expect((err as AbortError).message).toContain("couldn't determine its domain") + }) + + test('surfaces an auth failure as an AbortError mentioning the id', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockRejectedValueOnce(new Error('boom')) + + const err = await resolveStore('123').catch((error: unknown) => error) + + expect(err).toBeInstanceOf(AbortError) + expect((err as AbortError).message).toContain('123') + expect((err as AbortError).message).toContain("Couldn't reach the Business Platform") + }) + + test('surfaces a request failure as an AbortError mentioning the id', async () => { + vi.mocked(businessPlatformRequestDoc).mockRejectedValueOnce(new Error('network error')) + + const err = await resolveStore('123').catch((error: unknown) => error) + + expect(err).toBeInstanceOf(AbortError) + expect((err as AbortError).message).toContain('123') + expect((err as AbortError).message).toContain("Couldn't reach the Business Platform") + }) + + test('trims surrounding whitespace before resolving a numeric id', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce( + destinationResponse({webUrl: `https://${SHOP}`, primaryDomain: null}) as never, + ) + + const result = await resolveStore(' 123 ') + + expect(result).toBe(SHOP) + expect(vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0]).toMatchObject({ + variables: {id: ENCODED_123}, + }) + }) + }) + + describe('GID inputs', () => { + test('gid://shopify/Shop/ resolves the same as the numeric id', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce( + destinationResponse({webUrl: `https://${SHOP}`, primaryDomain: null}) as never, + ) + + const result = await resolveStore('gid://shopify/Shop/123') + + expect(result).toBe(SHOP) + expect(vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0]).toMatchObject({ + variables: {id: ENCODED_123}, + }) + }) + + test('lowercase gid://shopify/shop/ is accepted', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce( + destinationResponse({webUrl: `https://${SHOP}`, primaryDomain: null}) as never, + ) + + const result = await resolveStore('gid://shopify/shop/123') + + expect(result).toBe(SHOP) + }) + + test('non-Shop GID throws AbortError without any BP calls', async () => { + const err = await resolveStore('gid://shopify/Product/1').catch((error: unknown) => error) + + expect(err).toBeInstanceOf(AbortError) + expect(businessPlatformRequestDoc).not.toHaveBeenCalled() + }) + + test('malformed GID throws AbortError without any BP calls', async () => { + const err = await resolveStore('gid://shopify/Shop/').catch((error: unknown) => error) + + expect(err).toBeInstanceOf(AbortError) + expect(businessPlatformRequestDoc).not.toHaveBeenCalled() + }) + + test('GID with extra path segments throws AbortError without any BP calls', async () => { + const err = await resolveStore('gid://shopify/Shop/123/456').catch((error: unknown) => error) + + expect(err).toBeInstanceOf(AbortError) + expect(businessPlatformRequestDoc).not.toHaveBeenCalled() + }) + + test('trims surrounding whitespace before resolving a GID', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce( + destinationResponse({webUrl: `https://${SHOP}`, primaryDomain: null}) as never, + ) + + const result = await resolveStore(' gid://shopify/Shop/123 ') + + expect(result).toBe(SHOP) + expect(vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0]).toMatchObject({ + variables: {id: ENCODED_123}, + }) + }) + }) +}) diff --git a/packages/store/src/cli/utilities/store-resolution.ts b/packages/store/src/cli/utilities/store-resolution.ts new file mode 100644 index 00000000000..6e3d5926fb2 --- /dev/null +++ b/packages/store/src/cli/utilities/store-resolution.ts @@ -0,0 +1,125 @@ +import {ResolveStoreById} from '../api/graphql/business-platform-destinations/generated/resolve-store-by-id.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {encodeGid} from '@shopify/cli-kit/common/gid' +import {extractHost} from '@shopify/cli-kit/common/url' +import type { + ResolveStoreByIdQuery, + ResolveStoreByIdQueryVariables, +} from '../api/graphql/business-platform-destinations/generated/resolve-store-by-id.js' + +// Matches a plain GraphQL global id of the shape `gid://shopify//`. +// The `gid://` prefix is matched case-insensitively for friendliness; the captured +// type segment is compared explicitly below. We do NOT reuse `numericIdFromGid` for +// validation because it only extracts the trailing number and would happily accept a +// non-Shop GID (for example `gid://shopify/Product/1`). +const SHOP_GID_REGEX = /^gid:\/\/shopify\/([^/]+)\/(\d+)$/i + +/** + * Resolves a user-supplied `--store` value to a normalized myshopify.com FQDN. + * + * Accepted inputs: + * - A Shop GID (`gid://shopify/Shop/`) — resolved to the store's domain via the + * Business Platform. + * - A bare numeric store ID (``) — resolved the same way. A purely numeric value is + * always treated as a store ID, never as an all-numeric subdomain; users with an + * all-numeric subdomain must pass the full `.myshopify.com`. + * - Anything else — treated as a domain and normalized locally with no network calls, + * preserving the previous behavior of the flag exactly. + * + * @param input - The raw value passed to `--store`. + * @returns A normalized myshopify.com FQDN. + */ +export async function resolveStore(input: string): Promise { + const trimmed = input.trim() + + if (/^gid:\/\//i.test(trimmed)) { + const match = SHOP_GID_REGEX.exec(trimmed) + if (!match || match[1]?.toLowerCase() !== 'shop') { + throw new AbortError( + `${trimmed} isn't a valid store identifier.`, + 'Pass a Shop GID (gid://shopify/Shop/), a numeric store ID, or a myshopify.com domain.', + ) + } + return resolveShopIdToFqdn(match[2]!) + } + + if (/^\d+$/.test(trimmed)) { + return resolveShopIdToFqdn(trimmed) + } + + // Existing domain path: pure string normalization, no network. + return normalizeStoreFqdn(trimmed) +} + +/** + * Resolves a numeric shop id to its myshopify.com FQDN via the Business Platform. + * + * The org-agnostic destinations API (`currentUserAccount`) exposes a single direct + * `destination(id:)` lookup, so resolution is one request — no org enumeration. A shop + * destination's `publicId` is the shop's numeric id, encoded as a `DestinationPublicID`. + * + * @param shopId - The bare numeric shop id. + * @returns The store's normalized myshopify.com host. + */ +async function resolveShopIdToFqdn(shopId: string): Promise { + // The token_refresh handler transparently swaps in a fresh token if one expires + // mid-resolution. + const unauthorizedHandler = { + type: 'token_refresh' as const, + handler: async () => { + const newToken = await ensureAuthenticatedBusinessPlatform() + return {token: newToken} + }, + } + + // BP's DestinationPublicID *input* scalar expects a base64-encoded + // `gid://organization/ShopifyShop/`, not the bare numeric id. Mirrors the encoding + // used by app-management-client.ts `encodedGidFromShopId`. + const id = encodeGid(`gid://organization/ShopifyShop/${shopId}`) + + // Authenticate once and make the single direct lookup. If we can't reach the Business + // Platform at all (auth failure, network error), present a friendly AbortError rather than + // a raw stack trace. Existing AbortErrors pass through unchanged so we don't double-wrap. + let response: ResolveStoreByIdQuery + try { + const token = await ensureAuthenticatedBusinessPlatform() + response = await businessPlatformRequestDoc({ + query: ResolveStoreById, + token, + variables: {id}, + unauthorizedHandler, + }) + } catch (error) { + if (error instanceof AbortError) throw error + throw new AbortError( + `Couldn't reach the Business Platform to resolve store ID ${shopId}.`, + 'Pass the myshopify.com domain instead.', + ) + } + + const dest = response.currentUserAccount?.destination + if (!dest) { + throw new AbortError( + `Couldn't find a store with ID ${shopId} in your organizations.`, + "Verify the ID, pass the myshopify.com domain instead, or make sure you're signed in to an account with access to the store.", + ) + } + + // Read the host from `webUrl` FIRST: it is the canonical `*.myshopify.com` URL. Only fall + // back to `primaryDomain`, which MAY be a merchant custom domain — preferring it would + // resolve a custom-domain store to e.g. `www.example.com` and break downstream handling. + // Note: `extractHost` lowercases the host, whereas the domain-input path uses + // `normalizeStoreFqdn`, which preserves case. This asymmetry is intentional and harmless + // for canonical BP-resolved myshopify.com domains, which are already lowercase. + const domain = extractHost(dest.webUrl) ?? (dest.primaryDomain ? extractHost(dest.primaryDomain) : undefined) + if (!domain) { + throw new AbortError( + `Found the store with ID ${shopId} but couldn't determine its domain.`, + 'Pass the myshopify.com domain directly.', + ) + } + return domain +}