From 5bbb2d47318f28f7e435b81ba36d5d92053c9197 Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Fri, 5 Dec 2025 09:37:26 -0500 Subject: [PATCH 1/4] initial implementation of allowing graphql queries on non-dev stores --- .../api/graphql/app-dev/generated/types.d.ts | 16 +-- .../bulk-operations/generated/types.d.ts | 2 + .../generated/fetch_store_by_domain.ts | 119 ++++++++++++++++++ .../queries/fetch_store_by_domain.graphql | 22 ++++ .../app/src/cli/commands/app/bulk/execute.ts | 2 +- packages/app/src/cli/commands/app/execute.ts | 2 +- packages/app/src/cli/models/organization.ts | 1 + .../execute-bulk-operation.test.ts | 42 ++++--- .../bulk-operations/execute-bulk-operation.ts | 20 ++- packages/app/src/cli/services/dev/fetch.ts | 12 +- .../cli/services/execute-operation.test.ts | 30 +++-- .../app/src/cli/services/execute-operation.ts | 12 +- .../src/cli/services/graphql/common.test.ts | 85 ++++++++++++- .../app/src/cli/services/graphql/common.ts | 28 ++++- .../app/src/cli/services/store-context.ts | 9 +- .../utilities/developer-platform-client.ts | 2 +- .../app-management-client.ts | 20 +-- .../partners-client.ts | 7 +- .../cli/utilities/execute-command-helpers.ts | 1 + 19 files changed, 355 insertions(+), 77 deletions(-) create mode 100644 packages/app/src/cli/api/graphql/business-platform-organizations/generated/fetch_store_by_domain.ts create mode 100644 packages/app/src/cli/api/graphql/business-platform-organizations/queries/fetch_store_by_domain.graphql diff --git a/packages/app/src/cli/api/graphql/app-dev/generated/types.d.ts b/packages/app/src/cli/api/graphql/app-dev/generated/types.d.ts index 024de4d7d71..4fd45eb8318 100644 --- a/packages/app/src/cli/api/graphql/app-dev/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/app-dev/generated/types.d.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, tsdoc/syntax */ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, tsdoc/syntax */ import {JsonMapType} from '@shopify/cli-kit/node/toml' export type Maybe = T | null @@ -15,12 +15,6 @@ export type Scalars = { Boolean: {input: boolean; output: boolean} Int: {input: number; output: number} Float: {input: number; output: number} - /** - * Represents an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)-encoded date and time string. - * For example, 3:50 pm on September 7, 2019 in the time zone of UTC (Coordinated Universal Time) is - * represented as `"2019-09-07T15:50:00Z`". - */ - DateTime: {input: any; output: any} /** * A [JSON](https://www.json.org/json-en.html) object. * @@ -37,12 +31,4 @@ export type Scalars = { * }` */ JSON: {input: JsonMapType | string; output: JsonMapType} - /** - * Represents an [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) and - * [RFC 3987](https://datatracker.ietf.org/doc/html/rfc3987)-compliant URI string. - * - * For example, `"https://example.myshopify.com"` is a valid URL. It includes a scheme (`https`) and a host - * (`example.myshopify.com`). - */ - URL: {input: string; output: string} } diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts b/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts index f52c0a5a9a9..1b18af2517a 100644 --- a/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts @@ -210,6 +210,8 @@ export type BulkOperationsSortKeys = | 'COMPLETED_AT' /** Sort by the `created_at` value. */ | 'CREATED_AT' + /** Sort by the `status` value. */ + | 'STATUS' /** * The possible HTTP methods that can be used when sending a request to upload a file using information from a diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/fetch_store_by_domain.ts b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/fetch_store_by_domain.ts new file mode 100644 index 00000000000..09a5a6f5ea4 --- /dev/null +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/fetch_store_by_domain.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-duplicate-type-constituents */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type FetchStoreByDomainQueryVariables = Types.Exact<{ + domain?: Types.InputMaybe +}> + +export type FetchStoreByDomainQuery = { + organization?: { + id: string + name: string + accessibleShops?: { + edges: { + node: { + id: string + externalId?: string | null + name: string + storeType?: Types.Store | null + primaryDomain?: string | null + shortName?: string | null + url?: string | null + } + }[] + } | null + currentUser?: {organizationPermissions: string[]} | {organizationPermissions: string[]} | null + } | null +} + +export const FetchStoreByDomain = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'FetchStoreByDomain'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'domain'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'organization'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'accessibleShops'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'search'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'domain'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'edges'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'node'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'externalId'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'storeType'}}, + {kind: 'Field', name: {kind: 'Name', value: 'primaryDomain'}}, + {kind: 'Field', name: {kind: 'Name', value: 'shortName'}}, + {kind: 'Field', name: {kind: 'Name', value: 'url'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'currentUser'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'organizationPermissions'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/queries/fetch_store_by_domain.graphql b/packages/app/src/cli/api/graphql/business-platform-organizations/queries/fetch_store_by_domain.graphql new file mode 100644 index 00000000000..f986355e7f7 --- /dev/null +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/queries/fetch_store_by_domain.graphql @@ -0,0 +1,22 @@ + query FetchStoreByDomain($domain: String) { + organization { + id + name + accessibleShops(search: $domain) { + edges { + node { + id + externalId + name + storeType + primaryDomain + shortName + url + } + } + } + currentUser { + organizationPermissions + } + } + } diff --git a/packages/app/src/cli/commands/app/bulk/execute.ts b/packages/app/src/cli/commands/app/bulk/execute.ts index 92857c6916a..57889b157e8 100644 --- a/packages/app/src/cli/commands/app/bulk/execute.ts +++ b/packages/app/src/cli/commands/app/bulk/execute.ts @@ -26,7 +26,7 @@ export default class BulkExecute extends AppLinkedCommand { await executeBulkOperation({ organization: appContextResult.organization, remoteApp: appContextResult.remoteApp, - storeFqdn: store.shopDomain, + store, query, variables: flags.variables, variableFile: flags['variable-file'], diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index 9dfa85c3426..c56b5bf3760 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -23,7 +23,7 @@ export default class Execute extends AppLinkedCommand { await executeOperation({ organization: appContextResult.organization, remoteApp: appContextResult.remoteApp, - storeFqdn: store.shopDomain, + store, query, variables: flags.variables, outputFile: flags['output-file'], diff --git a/packages/app/src/cli/models/organization.ts b/packages/app/src/cli/models/organization.ts index 48b1b950fe7..c723b62bc82 100644 --- a/packages/app/src/cli/models/organization.ts +++ b/packages/app/src/cli/models/organization.ts @@ -60,4 +60,5 @@ export interface OrganizationStore { transferDisabled: boolean convertableToPartnerTest: boolean provisionable: boolean + storeType?: string } diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts index 8d450a17d22..0a37043cebf 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts @@ -50,6 +50,16 @@ describe('executeBulkOperation', () => { } as OrganizationApp const storeFqdn = 'test-store.myshopify.com' + const mockStore = { + shopId: '123', + link: 'link', + shopDomain: storeFqdn, + shopName: 'Test Store', + transferDisabled: true, + convertableToPartnerTest: false, + provisionable: true, + storeType: 'APP_DEVELOPMENT', + } const mockAdminSession = {token: 'test-token', storeFqdn} const createdBulkOperation: NonNullable< @@ -87,7 +97,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) @@ -110,7 +120,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) @@ -133,7 +143,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query: mutation, }) @@ -158,7 +168,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query: mutation, variables, }) @@ -181,7 +191,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) @@ -206,7 +216,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) @@ -242,7 +252,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query: mutation, variableFile: variableFilePath, }) @@ -265,7 +275,7 @@ describe('executeBulkOperation', () => { executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query: mutation, variableFile: nonExistentPath, }), @@ -284,7 +294,7 @@ describe('executeBulkOperation', () => { executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, variables, }), @@ -305,7 +315,7 @@ describe('executeBulkOperation', () => { executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, variableFile: variableFilePath, }), @@ -338,7 +348,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, watch: true, }) @@ -378,7 +388,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, watch: true, }) @@ -467,7 +477,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, watch: true, outputFile, @@ -501,7 +511,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, watch: true, }) @@ -530,7 +540,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, watch: true, }) @@ -555,7 +565,7 @@ describe('executeBulkOperation', () => { executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }), ).rejects.toThrow('Bulk operation response returned null with no error message.') diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts index 06c5d7ff4ce..a2300814714 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts @@ -10,19 +10,20 @@ import { validateSingleOperation, formatOperationInfo, resolveApiVersion, + validateMutationStore, + isMutation, } from '../graphql/common.js' -import {OrganizationApp, Organization} from '../../models/organization.js' +import {OrganizationApp, Organization, OrganizationStore} from '../../models/organization.js' import {renderSuccess, renderInfo, renderError, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui' import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {AbortController} from '@shopify/cli-kit/node/abort' -import {parse} from 'graphql' import {readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs' interface ExecuteBulkOperationInput { organization: Organization remoteApp: OrganizationApp - storeFqdn: string + store: OrganizationStore query: string variables?: string[] variableFile?: string @@ -52,7 +53,7 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr const { organization, remoteApp, - storeFqdn, + store, query, variables, variableFile, @@ -61,7 +62,7 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr version: userSpecifiedVersion, } = input - const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) + const adminSession = await createAdminSessionAsApp(remoteApp, store.shopDomain) const version = await resolveApiVersion({ adminSession, @@ -72,13 +73,14 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr const variablesJsonl = await parseVariablesToJsonl(variables, variableFile) validateGraphQLDocument(query, variablesJsonl) + validateMutationStore(query, store) renderInfo({ headline: 'Starting bulk operation.', body: [ { list: { - items: formatOperationInfo({organization, remoteApp, storeFqdn, version}), + items: formatOperationInfo({organization, remoteApp, storeFqdn: store.shopDomain, version}), }, }, ], @@ -226,9 +228,3 @@ function statusCommandHelpMessage(operationId: string): TokenItem { {command: `shopify app bulk status --id=${extractBulkOperationId(operationId)}`}, ] } - -function isMutation(graphqlOperation: string): boolean { - const document = parse(graphqlOperation) - const operation = document.definitions.find((def) => def.kind === 'OperationDefinition') - return operation?.kind === 'OperationDefinition' && operation.operation === 'mutation' -} diff --git a/packages/app/src/cli/services/dev/fetch.ts b/packages/app/src/cli/services/dev/fetch.ts index ea07d3b40f0..5014c048ccb 100644 --- a/packages/app/src/cli/services/dev/fetch.ts +++ b/packages/app/src/cli/services/dev/fetch.ts @@ -117,19 +117,25 @@ export async function fetchOrgFromId( * @param org - Organization * @param storeFqdn - store domain fqdn * @param developerPlatformClient - The client to access the platform API + * @param includeAllStores - Whether to include all store types or only Dev Stores */ export async function fetchStore( org: Organization, storeFqdn: string, developerPlatformClient: DeveloperPlatformClient, + includeAllStores = false, ): Promise { - const store = await developerPlatformClient.storeByDomain(org.id, storeFqdn) + const store = await developerPlatformClient.storeByDomain(org.id, storeFqdn, includeAllStores) - if (!store) + if (!store) { + const storeTypeMessage = includeAllStores + ? 'Ensure you have provided the correct store domain and that you have access to the store.' + : 'Ensure you have provided the correct store domain, that the store is a dev store, and that you have access to the store.' throw new AbortError( `Could not find store for domain ${storeFqdn} in organization ${org.businessName}.`, - `Ensure you've provided the correct store domain, that the store is a dev store, and that you have access to the store.`, + storeTypeMessage, ) + } return store } diff --git a/packages/app/src/cli/services/execute-operation.test.ts b/packages/app/src/cli/services/execute-operation.test.ts index 8f420b7331f..6a474e35778 100644 --- a/packages/app/src/cli/services/execute-operation.test.ts +++ b/packages/app/src/cli/services/execute-operation.test.ts @@ -28,6 +28,16 @@ describe('executeOperation', () => { } as OrganizationApp const storeFqdn = 'test-store.myshopify.com' + const mockStore = { + shopId: '123', + link: 'link', + shopDomain: storeFqdn, + shopName: 'Test Store', + transferDisabled: true, + convertableToPartnerTest: false, + provisionable: true, + storeType: 'APP_DEVELOPMENT', + } const mockAdminSession = {token: 'test-token', storeFqdn} beforeEach(() => { @@ -50,7 +60,7 @@ describe('executeOperation', () => { await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) @@ -75,7 +85,7 @@ describe('executeOperation', () => { await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, variables, }) @@ -95,7 +105,7 @@ describe('executeOperation', () => { executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, variables: invalidVariables, }), @@ -114,7 +124,7 @@ describe('executeOperation', () => { await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, version, }) @@ -137,7 +147,7 @@ describe('executeOperation', () => { await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) @@ -156,7 +166,7 @@ describe('executeOperation', () => { await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, outputFile, }) @@ -179,7 +189,7 @@ describe('executeOperation', () => { await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) @@ -199,7 +209,7 @@ describe('executeOperation', () => { executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }), ).rejects.toThrow('API request failed') @@ -216,7 +226,7 @@ describe('executeOperation', () => { await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) @@ -240,7 +250,7 @@ describe('executeOperation', () => { await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) diff --git a/packages/app/src/cli/services/execute-operation.ts b/packages/app/src/cli/services/execute-operation.ts index aa3d46149b2..8a150992649 100644 --- a/packages/app/src/cli/services/execute-operation.ts +++ b/packages/app/src/cli/services/execute-operation.ts @@ -3,8 +3,9 @@ import { validateSingleOperation, resolveApiVersion, formatOperationInfo, + validateMutationStore, } from './graphql/common.js' -import {OrganizationApp, Organization} from '../models/organization.js' +import {OrganizationApp, Organization, OrganizationStore} from '../models/organization.js' import {renderSuccess, renderError, renderInfo, renderSingleTask} from '@shopify/cli-kit/node/ui' import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' @@ -16,7 +17,7 @@ import {writeFile} from '@shopify/cli-kit/node/fs' interface ExecuteOperationInput { organization: Organization remoteApp: OrganizationApp - storeFqdn: string + store: OrganizationStore query: string variables?: string outputFile?: string @@ -38,9 +39,9 @@ async function parseVariables(variables?: string): Promise<{[key: string]: unkno } export async function executeOperation(input: ExecuteOperationInput): Promise { - const {organization, remoteApp, storeFqdn, query, variables, version: userSpecifiedVersion, outputFile} = input + const {organization, remoteApp, store, query, variables, version: userSpecifiedVersion, outputFile} = input - const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) + const adminSession = await createAdminSessionAsApp(remoteApp, store.shopDomain) const version = await resolveApiVersion({adminSession, userSpecifiedVersion}) @@ -49,7 +50,7 @@ export async function executeOperation(input: ExecuteOperationInput): Promise { expect(result).not.toContain(expect.stringContaining('API version')) }) }) + +describe('isMutation', () => { + test('returns true for mutation operation', () => { + const mutation = 'mutation { productUpdate(input: {}) { product { id } } }' + + expect(isMutation(mutation)).toBe(true) + }) + + test('returns false for query operation', () => { + const query = 'query { shop { name } }' + + expect(isMutation(query)).toBe(false) + }) + + test('returns false for shorthand query syntax', () => { + const query = '{ shop { name } }' + + expect(isMutation(query)).toBe(false) + }) +}) + +describe('validateMutationStore', () => { + const devStore: OrganizationStore = { + shopId: '123', + link: 'link', + shopDomain: 'dev-store.myshopify.com', + shopName: 'Dev Store', + transferDisabled: true, + convertableToPartnerTest: false, + provisionable: true, + storeType: 'APP_DEVELOPMENT', + } + + const nonDevStore: OrganizationStore = { + shopId: '456', + link: 'link', + shopDomain: 'prod-store.myshopify.com', + shopName: 'Production Store', + transferDisabled: false, + convertableToPartnerTest: false, + provisionable: false, + storeType: 'PRODUCTION', + } + + test('allows queries on Dev Stores', () => { + const query = 'query { shop { name } }' + + expect(() => validateMutationStore(query, devStore)).not.toThrow() + }) + + test('allows queries on non-dev stores', () => { + const query = 'query { shop { name } }' + + expect(() => validateMutationStore(query, nonDevStore)).not.toThrow() + }) + + test('allows mutations on Dev Stores', () => { + const mutation = 'mutation { productUpdate(input: {}) { product { id } } }' + + expect(() => validateMutationStore(mutation, devStore)).not.toThrow() + }) + + test('throws when attempting mutation on non-dev store', () => { + const mutation = 'mutation { productUpdate(input: {}) { product { id } } }' + + expect(() => validateMutationStore(mutation, nonDevStore)).toThrow('Mutations can only be executed on Dev Stores') + }) + + test('includes store domain in error message for non-dev store mutations', () => { + const mutation = 'mutation { productUpdate(input: {}) { product { id } } }' + + expect(() => validateMutationStore(mutation, nonDevStore)).toThrow('Dev Stores') + }) +}) diff --git a/packages/app/src/cli/services/graphql/common.ts b/packages/app/src/cli/services/graphql/common.ts index c13a0ab42f9..4fc6b9da3ba 100644 --- a/packages/app/src/cli/services/graphql/common.ts +++ b/packages/app/src/cli/services/graphql/common.ts @@ -1,4 +1,4 @@ -import {OrganizationApp} from '../../models/organization.js' +import {OrganizationApp, OrganizationStore} from '../../models/organization.js' import {ensureAuthenticatedAdminAsApp, AdminSession} from '@shopify/cli-kit/node/session' import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {outputContent} from '@shopify/cli-kit/node/output' @@ -115,3 +115,29 @@ export function formatOperationInfo(options: { return items } + +/** + * Checks if a GraphQL operation is a mutation. + * + * @param graphqlOperation - The GraphQL query or mutation string to check. + * @returns True if the operation is a mutation, false otherwise. + */ +export function isMutation(graphqlOperation: string): boolean { + const document = parse(graphqlOperation) + const operationDefinition = document.definitions.find((def) => def.kind === 'OperationDefinition') + + return operationDefinition?.operation === 'mutation' +} + +/** + * Validates that mutations can only be executed on Dev Stores. + * + * @param graphqlOperation - The GraphQL operation to validate. + * @param store - The store where the operation will be executed. + * @throws AbortError if attempting to run a mutation on a non-dev store. + */ +export function validateMutationStore(graphqlOperation: string, store: OrganizationStore): void { + if (isMutation(graphqlOperation) && store.storeType !== 'APP_DEVELOPMENT') { + throw new AbortError(`Mutations can only be executed on Dev Stores.`) + } +} diff --git a/packages/app/src/cli/services/store-context.ts b/packages/app/src/cli/services/store-context.ts index 42b9d1a5dfe..98c800f49b8 100644 --- a/packages/app/src/cli/services/store-context.ts +++ b/packages/app/src/cli/services/store-context.ts @@ -12,11 +12,13 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' * @param appContextResult - The result of the app context function. * @param forceReselectStore - Whether to force reselecting the store. * @param storeFqdn - a store FQDN, optional, when explicitly provided it has preference over anything else. + * @param includeAllStores - Whether to include all store types or only Dev Stores. */ interface StoreContextOptions { appContextResult: LoadedAppContextOutput forceReselectStore: boolean storeFqdn?: string + includeAllStores?: boolean } /** @@ -30,6 +32,7 @@ export async function storeContext({ appContextResult, storeFqdn, forceReselectStore, + includeAllStores = false, }: StoreContextOptions): Promise { const {app, organization, developerPlatformClient} = appContextResult let selectedStore: OrganizationStore @@ -46,9 +49,11 @@ export async function storeContext({ const storeFqdnToUse = storeFqdn ?? cachedStoreInToml if (storeFqdnToUse) { - selectedStore = await fetchStore(organization, storeFqdnToUse, developerPlatformClient) + selectedStore = await fetchStore(organization, storeFqdnToUse, developerPlatformClient, includeAllStores) // never automatically convert a store provided via the command line - await convertToTransferDisabledStoreIfNeeded(selectedStore, organization.id, developerPlatformClient, 'never') + if (!includeAllStores) { + await convertToTransferDisabledStoreIfNeeded(selectedStore, organization.id, developerPlatformClient, 'never') + } } else { // If no storeFqdn is provided, fetch all stores for the organization and let the user select one. const allStores = await developerPlatformClient.devStoresForOrg(organization.id) diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index d3b16261ea2..8deeee4924a 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -250,7 +250,7 @@ export interface DeveloperPlatformClient { templateSpecifications: (app: MinimalAppIdentifiers) => Promise createApp: (org: Organization, options: CreateAppOptions) => Promise devStoresForOrg: (orgId: string, searchTerm?: string) => Promise> - storeByDomain: (orgId: string, shopDomain: string) => Promise + storeByDomain: (orgId: string, shopDomain: string, includeAllStores?: boolean) => Promise ensureUserAccessToStore: (orgId: string, store: OrganizationStore) => Promise appExtensionRegistrations: ( app: MinimalAppIdentifiers, diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index f74ceebd240..b1c38ab8518 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -86,10 +86,11 @@ import { DevSessionUpdateMutationVariables, } from '../../api/graphql/app-dev/generated/dev-session-update.js' import {DevSessionDelete, DevSessionDeleteMutation} from '../../api/graphql/app-dev/generated/dev-session-delete.js' +import {FetchDevStoreByDomain} from '../../api/graphql/business-platform-organizations/generated/fetch_dev_store_by_domain.js' import { - FetchDevStoreByDomain, - FetchDevStoreByDomainQueryVariables, -} from '../../api/graphql/business-platform-organizations/generated/fetch_dev_store_by_domain.js' + FetchStoreByDomain, + FetchStoreByDomainQueryVariables, +} from '../../api/graphql/business-platform-organizations/generated/fetch_store_by_domain.js' import { ListAppDevStores, ListAppDevStoresQuery, @@ -834,10 +835,14 @@ export class AppManagementClient implements DeveloperPlatformClient { } } - async storeByDomain(orgId: string, shopDomain: string): Promise { - const queryVariables: FetchDevStoreByDomainQueryVariables = {domain: shopDomain} + async storeByDomain( + orgId: string, + shopDomain: string, + includeAllStores = false, + ): Promise { + const queryVariables: FetchStoreByDomainQueryVariables = {domain: shopDomain} const storesResult = await this.businessPlatformOrganizationsRequest({ - query: FetchDevStoreByDomain, + query: includeAllStores ? FetchStoreByDomain : FetchDevStoreByDomain, organizationId: String(numberFromGid(orgId)), variables: queryVariables, }) @@ -1303,7 +1308,7 @@ function mapBusinessPlatformStoresToOrganizationStores( provisionable: boolean, ): OrganizationStore[] { return storesArray.map((store: ShopNode) => { - const {externalId, primaryDomain, name, url} = store + const {externalId, primaryDomain, name, url, storeType} = store const shopDomain = url ?? primaryDomain if (!shopDomain) throw new BugError('The selected store does not have a valid URL') return { @@ -1314,6 +1319,7 @@ function mapBusinessPlatformStoresToOrganizationStores( transferDisabled: true, convertableToPartnerTest: true, provisionable, + storeType, } as OrganizationStore }) } diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts index 59a680465da..2804414b372 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts @@ -513,7 +513,12 @@ export class PartnersClient implements DeveloperPlatformClient { return this.request(ConvertDevToTransferDisabledStoreQuery, input) } - async storeByDomain(orgId: string, shopDomain: string): Promise { + async storeByDomain( + orgId: string, + shopDomain: string, + _includeAllStores = false, + ): Promise { + // Note: includeAllStores parameter not implemented for PartnersClient const variables: FindStoreByDomainQueryVariables = {orgId, shopDomain} const result: FindStoreByDomainSchema = await this.request(FindStoreByDomainQuery, variables) diff --git a/packages/app/src/cli/utilities/execute-command-helpers.ts b/packages/app/src/cli/utilities/execute-command-helpers.ts index 4c40d542d1f..d938afb47d4 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.ts @@ -44,6 +44,7 @@ export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promi appContextResult, storeFqdn: flags.store, forceReselectStore: flags.reset, + includeAllStores: true, }) return {appContextResult, store} From 011fabc5145993f6f079f82f8a5f6f7a2432c32e Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:20:56 -0500 Subject: [PATCH 2/4] fixed tests --- .../services/bulk-operations/execute-bulk-operation.test.ts | 4 ++-- packages/app/src/cli/services/dev/fetch.test.ts | 4 ++-- packages/app/src/cli/services/store-context.test.ts | 2 ++ .../app/src/cli/utilities/execute-command-helpers.test.ts | 1 + 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts index 0a37043cebf..d0fe323d369 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts @@ -698,7 +698,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) @@ -721,7 +721,7 @@ describe('executeBulkOperation', () => { await executeBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, - storeFqdn, + store: mockStore, query, }) diff --git a/packages/app/src/cli/services/dev/fetch.test.ts b/packages/app/src/cli/services/dev/fetch.test.ts index 6d1401f744e..35fc3990114 100644 --- a/packages/app/src/cli/services/dev/fetch.test.ts +++ b/packages/app/src/cli/services/dev/fetch.test.ts @@ -113,7 +113,7 @@ describe('fetchStore', () => { // Then expect(got).toEqual(STORE1) - expect(developerPlatformClient.storeByDomain).toHaveBeenCalledWith(ORG1.id, 'domain1') + expect(developerPlatformClient.storeByDomain).toHaveBeenCalledWith(ORG1.id, 'domain1', false) }) test('throws error if store not found', async () => { @@ -129,7 +129,7 @@ describe('fetchStore', () => { await expect(got).rejects.toThrow( new AbortError( `Could not find store for domain domain1 in organization org1.`, - `Ensure you've provided the correct store domain, that the store is a dev store, and that you have access to the store.`, + `Ensure you have provided the correct store domain, that the store is a dev store, and that you have access to the store.`, ), ) }) diff --git a/packages/app/src/cli/services/store-context.test.ts b/packages/app/src/cli/services/store-context.test.ts index 5d8cd3f45fa..95558267ad2 100644 --- a/packages/app/src/cli/services/store-context.test.ts +++ b/packages/app/src/cli/services/store-context.test.ts @@ -60,6 +60,7 @@ describe('storeContext', () => { mockOrganization, 'explicit-store.myshopify.com', mockDeveloperPlatformClient, + false, ) expect(convertToTransferDisabledStoreIfNeeded).toHaveBeenCalledWith( mockStore, @@ -85,6 +86,7 @@ describe('storeContext', () => { mockOrganization, 'cached-store.myshopify.com', mockDeveloperPlatformClient, + false, ) expect(result).toEqual(mockStore) }) diff --git a/packages/app/src/cli/utilities/execute-command-helpers.test.ts b/packages/app/src/cli/utilities/execute-command-helpers.test.ts index 5705c59daee..97987831f57 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.test.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.test.ts @@ -54,6 +54,7 @@ describe('prepareAppStoreContext', () => { appContextResult: mockAppContextResult, storeFqdn: mockFlags.store, forceReselectStore: mockFlags.reset, + includeAllStores: true, }) }) From 46bd168280d596880c1ee4373d999a02d92f57cf Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:27:51 -0500 Subject: [PATCH 3/4] dev store casing --- packages/app/src/cli/services/dev/fetch.ts | 2 +- packages/app/src/cli/services/graphql/common.test.ts | 8 ++++---- packages/app/src/cli/services/graphql/common.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/app/src/cli/services/dev/fetch.ts b/packages/app/src/cli/services/dev/fetch.ts index 5014c048ccb..3d654708963 100644 --- a/packages/app/src/cli/services/dev/fetch.ts +++ b/packages/app/src/cli/services/dev/fetch.ts @@ -117,7 +117,7 @@ export async function fetchOrgFromId( * @param org - Organization * @param storeFqdn - store domain fqdn * @param developerPlatformClient - The client to access the platform API - * @param includeAllStores - Whether to include all store types or only Dev Stores + * @param includeAllStores - Whether to include all store types or only dev stores */ export async function fetchStore( org: Organization, diff --git a/packages/app/src/cli/services/graphql/common.test.ts b/packages/app/src/cli/services/graphql/common.test.ts index 658fae21732..02beb590fcc 100644 --- a/packages/app/src/cli/services/graphql/common.test.ts +++ b/packages/app/src/cli/services/graphql/common.test.ts @@ -288,7 +288,7 @@ describe('validateMutationStore', () => { storeType: 'PRODUCTION', } - test('allows queries on Dev Stores', () => { + test('allows queries on dev stores', () => { const query = 'query { shop { name } }' expect(() => validateMutationStore(query, devStore)).not.toThrow() @@ -300,7 +300,7 @@ describe('validateMutationStore', () => { expect(() => validateMutationStore(query, nonDevStore)).not.toThrow() }) - test('allows mutations on Dev Stores', () => { + test('allows mutations on dev stores', () => { const mutation = 'mutation { productUpdate(input: {}) { product { id } } }' expect(() => validateMutationStore(mutation, devStore)).not.toThrow() @@ -309,12 +309,12 @@ describe('validateMutationStore', () => { test('throws when attempting mutation on non-dev store', () => { const mutation = 'mutation { productUpdate(input: {}) { product { id } } }' - expect(() => validateMutationStore(mutation, nonDevStore)).toThrow('Mutations can only be executed on Dev Stores') + expect(() => validateMutationStore(mutation, nonDevStore)).toThrow('Mutations can only be executed on dev stores') }) test('includes store domain in error message for non-dev store mutations', () => { const mutation = 'mutation { productUpdate(input: {}) { product { id } } }' - expect(() => validateMutationStore(mutation, nonDevStore)).toThrow('Dev Stores') + expect(() => validateMutationStore(mutation, nonDevStore)).toThrow('dev stores') }) }) diff --git a/packages/app/src/cli/services/graphql/common.ts b/packages/app/src/cli/services/graphql/common.ts index 4fc6b9da3ba..48127580370 100644 --- a/packages/app/src/cli/services/graphql/common.ts +++ b/packages/app/src/cli/services/graphql/common.ts @@ -130,7 +130,7 @@ export function isMutation(graphqlOperation: string): boolean { } /** - * Validates that mutations can only be executed on Dev Stores. + * Validates that mutations can only be executed on dev stores. * * @param graphqlOperation - The GraphQL operation to validate. * @param store - The store where the operation will be executed. @@ -138,6 +138,6 @@ export function isMutation(graphqlOperation: string): boolean { */ export function validateMutationStore(graphqlOperation: string, store: OrganizationStore): void { if (isMutation(graphqlOperation) && store.storeType !== 'APP_DEVELOPMENT') { - throw new AbortError(`Mutations can only be executed on Dev Stores.`) + throw new AbortError(`Mutations can only be executed on dev stores.`) } } From 99d6e3d9a02788e5cd1226cd8cc7ec4c609a03f4 Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:32:20 -0500 Subject: [PATCH 4/4] lint fix --- packages/app/src/cli/utilities/developer-platform-client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index 8deeee4924a..0b92c12354b 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -250,7 +250,11 @@ export interface DeveloperPlatformClient { templateSpecifications: (app: MinimalAppIdentifiers) => Promise createApp: (org: Organization, options: CreateAppOptions) => Promise devStoresForOrg: (orgId: string, searchTerm?: string) => Promise> - storeByDomain: (orgId: string, shopDomain: string, includeAllStores?: boolean) => Promise + storeByDomain: ( + orgId: string, + shopDomain: string, + includeAllStores?: boolean, + ) => Promise ensureUserAccessToStore: (orgId: string, store: OrganizationStore) => Promise appExtensionRegistrations: ( app: MinimalAppIdentifiers,