From 9f6e6b288d471202771f2652cd3c0783f086d68e Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:36:08 -0500 Subject: [PATCH 1/3] add support for `--query-file` to both `app execute` and `app bulk execute` --- packages/app/src/cli/flags.ts | 14 ++++++++ .../utilities/execute-command-helpers.test.ts | 36 +++++++++++++++++++ .../cli/utilities/execute-command-helpers.ts | 26 +++++++++++--- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index 262f6e8223..46a5ebb8cd 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -41,6 +41,13 @@ export const bulkOperationFlags = { description: 'The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.', env: 'SHOPIFY_FLAG_QUERY', required: false, + exclusive: ['query-file'], + }), + 'query-file': Flags.string({ + description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + env: 'SHOPIFY_FLAG_QUERY_FILE', + parse: async (input) => resolvePath(input), + exclusive: ['query'], }), variables: Flags.string({ char: 'v', @@ -85,6 +92,13 @@ export const operationFlags = { description: 'The GraphQL query or mutation, as a string.', env: 'SHOPIFY_FLAG_QUERY', required: false, + exclusive: ['query-file'], + }), + 'query-file': Flags.string({ + description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + env: 'SHOPIFY_FLAG_QUERY_FILE', + parse: async (input) => resolvePath(input), + exclusive: ['query'], }), variables: Flags.string({ char: 'v', 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 5705c59dae..68aba964ad 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.test.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.test.ts @@ -2,11 +2,13 @@ import {prepareAppStoreContext, prepareExecuteContext} from './execute-command-h import {linkedAppContext} from '../services/app-context.js' import {storeContext} from '../services/store-context.js' import {readStdinString} from '@shopify/cli-kit/node/system' +import {readFile, fileExists} from '@shopify/cli-kit/node/fs' import {describe, test, expect, vi, beforeEach} from 'vitest' vi.mock('../services/app-context.js') vi.mock('../services/store-context.js') vi.mock('@shopify/cli-kit/node/system') +vi.mock('@shopify/cli-kit/node/fs') describe('prepareAppStoreContext', () => { const mockFlags = { @@ -170,4 +172,38 @@ describe('prepareExecuteContext', () => { expect(linkedAppContext).toHaveBeenCalled() expect(storeContext).toHaveBeenCalled() }) + + test('reads query from file when query-file flag is provided', async () => { + const queryFileContent = 'query { shop { name } }' + vi.mocked(fileExists).mockResolvedValue(true) + vi.mocked(readFile).mockResolvedValue(queryFileContent as any) + + const flagsWithQueryFile = {...mockFlags, query: undefined, 'query-file': '/path/to/query.graphql'} + const result = await prepareExecuteContext(flagsWithQueryFile) + + expect(fileExists).toHaveBeenCalledWith('/path/to/query.graphql') + expect(readFile).toHaveBeenCalledWith('/path/to/query.graphql', {encoding: 'utf8'}) + expect(result.query).toBe(queryFileContent) + expect(readStdinString).not.toHaveBeenCalled() + }) + + test('throws AbortError when query file does not exist', async () => { + vi.mocked(fileExists).mockResolvedValue(false) + + const flagsWithQueryFile = {...mockFlags, query: undefined, 'query-file': '/path/to/nonexistent.graphql'} + + await expect(prepareExecuteContext(flagsWithQueryFile)).rejects.toThrow('Query file not found') + expect(readFile).not.toHaveBeenCalled() + }) + + test('falls back to stdin when neither query nor query-file provided', async () => { + const stdinQuery = 'query { shop { name } }' + vi.mocked(readStdinString).mockResolvedValue(stdinQuery) + + const flagsWithoutQueryOrFile = {...mockFlags, query: undefined} + const result = await prepareExecuteContext(flagsWithoutQueryOrFile) + + expect(readStdinString).toHaveBeenCalled() + expect(result.query).toBe(stdinQuery) + }) }) diff --git a/packages/app/src/cli/utilities/execute-command-helpers.ts b/packages/app/src/cli/utilities/execute-command-helpers.ts index 4c40d542d1..039321b425 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.ts @@ -3,6 +3,8 @@ import {storeContext} from '../services/store-context.js' import {OrganizationStore} from '../models/organization.js' import {readStdinString} from '@shopify/cli-kit/node/system' import {AbortError} from '@shopify/cli-kit/node/error' +import {readFile, fileExists} from '@shopify/cli-kit/node/fs' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' interface AppStoreContextFlags { path: string @@ -19,6 +21,7 @@ interface AppStoreContext { interface ExecuteCommandFlags extends AppStoreContextFlags { query?: string + 'query-file'?: string } interface ExecuteContext extends AppStoreContext { @@ -51,7 +54,7 @@ export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promi /** * Prepares the execution context for GraphQL operations. - * Handles query input from flag or stdin, and sets up app and store contexts. + * Handles query input from flag, file, or stdin, and sets up app and store contexts. * * @param flags - Command flags containing configuration options. * @param commandName - Name of the command for error messages (e.g., 'execute', 'bulk execute'). @@ -61,11 +64,26 @@ export async function prepareExecuteContext( flags: ExecuteCommandFlags, commandName = 'execute', ): Promise { - const query = flags.query ?? (await readStdinString()) + let query: string | undefined + + if (flags.query) { + query = flags.query + } else if (flags['query-file']) { + const queryFile = flags['query-file'] + if (!(await fileExists(queryFile))) { + throw new AbortError( + outputContent`Query file not found at ${outputToken.path(queryFile)}. Please check the path and try again.`, + ) + } + query = await readFile(queryFile, {encoding: 'utf8'}) + } else { + query = await readStdinString() + } + if (!query) { throw new AbortError( - 'No query provided. Use the --query flag or pipe input via stdin.', - `Example: echo "query { ... }" | shopify app ${commandName}`, + 'No query provided. Use the --query flag, --query-file flag, or pipe input via stdin.', + `Example: shopify app ${commandName} --query-file query.graphql`, ) } From 8d398cddfa8d72463c671f4d6e5f5dbc55b1b106 Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Thu, 18 Dec 2025 09:06:16 -0500 Subject: [PATCH 2/3] refreshed manifests --- packages/cli/oclif.manifest.json | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 0831c83c20..25db767f42 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -148,12 +148,26 @@ "char": "q", "description": "The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.", "env": "SHOPIFY_FLAG_QUERY", + "exclusive": [ + "query-file" + ], "hasDynamicHelp": false, "multiple": false, "name": "query", "required": false, "type": "option" }, + "query-file": { + "description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + "env": "SHOPIFY_FLAG_QUERY_FILE", + "exclusive": [ + "query" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "query-file", + "type": "option" + }, "reset": { "allowNo": false, "description": "Reset all your settings.", @@ -1186,12 +1200,26 @@ "char": "q", "description": "The GraphQL query or mutation, as a string.", "env": "SHOPIFY_FLAG_QUERY", + "exclusive": [ + "query-file" + ], "hasDynamicHelp": false, "multiple": false, "name": "query", "required": false, "type": "option" }, + "query-file": { + "description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + "env": "SHOPIFY_FLAG_QUERY_FILE", + "exclusive": [ + "query" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "query-file", + "type": "option" + }, "reset": { "allowNo": false, "description": "Reset all your settings.", From 1ffab0df5999ab6adc05ec5d088aa40ad23c5c9a Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:07:08 -0500 Subject: [PATCH 3/3] centralize GraphQL validation --- .../bulk-operations/execute-bulk-operation.ts | 16 ++++++---------- .../app/src/cli/services/execute-operation.ts | 9 +-------- .../utilities/execute-command-helpers.test.ts | 14 ++++++++++++-- .../src/cli/utilities/execute-command-helpers.ts | 6 +++++- 4 files changed, 24 insertions(+), 21 deletions(-) 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 06c5d7ff4c..df27c892e3 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 @@ -5,12 +5,7 @@ import {formatBulkOperationStatus} from './format-bulk-operation-status.js' import {downloadBulkOperationResults} from './download-bulk-operation-results.js' import {extractBulkOperationId} from './bulk-operation-status.js' import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' -import { - createAdminSessionAsApp, - validateSingleOperation, - formatOperationInfo, - resolveApiVersion, -} from '../graphql/common.js' +import {createAdminSessionAsApp, formatOperationInfo, resolveApiVersion} from '../graphql/common.js' import {OrganizationApp, Organization} 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' @@ -71,7 +66,7 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr const variablesJsonl = await parseVariablesToJsonl(variables, variableFile) - validateGraphQLDocument(query, variablesJsonl) + validateBulkOperationVariables(query, variablesJsonl) renderInfo({ headline: 'Starting bulk operation.', @@ -208,9 +203,10 @@ function resultsContainUserErrors(results: string): boolean { }) } -function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): void { - validateSingleOperation(graphqlOperation) - +/** + * Validates bulk operation-specific constraints for variables. + */ +function validateBulkOperationVariables(graphqlOperation: string, variablesJsonl?: string): void { if (!isMutation(graphqlOperation) && variablesJsonl) { throw new AbortError( outputContent`The ${outputToken.yellow('--variables')} and ${outputToken.yellow( diff --git a/packages/app/src/cli/services/execute-operation.ts b/packages/app/src/cli/services/execute-operation.ts index 4b92789979..70ba3c7d63 100644 --- a/packages/app/src/cli/services/execute-operation.ts +++ b/packages/app/src/cli/services/execute-operation.ts @@ -1,9 +1,4 @@ -import { - createAdminSessionAsApp, - validateSingleOperation, - resolveApiVersion, - formatOperationInfo, -} from './graphql/common.js' +import {createAdminSessionAsApp, resolveApiVersion, formatOperationInfo} from './graphql/common.js' import {OrganizationApp, Organization} 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' @@ -89,8 +84,6 @@ export async function executeOperation(input: ExecuteOperationInput): Promise ({ + validateSingleOperation: vi.fn(), +})) describe('prepareAppStoreContext', () => { const mockFlags = { @@ -206,4 +210,10 @@ describe('prepareExecuteContext', () => { expect(readStdinString).toHaveBeenCalled() expect(result.query).toBe(stdinQuery) }) + + test('validates GraphQL query using validateSingleOperation', async () => { + await prepareExecuteContext(mockFlags) + + expect(validateSingleOperation).toHaveBeenCalledWith(mockFlags.query) + }) }) diff --git a/packages/app/src/cli/utilities/execute-command-helpers.ts b/packages/app/src/cli/utilities/execute-command-helpers.ts index 039321b425..26c43882f7 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.ts @@ -1,5 +1,6 @@ import {linkedAppContext, LoadedAppContextOutput} from '../services/app-context.js' import {storeContext} from '../services/store-context.js' +import {validateSingleOperation} from '../services/graphql/common.js' import {OrganizationStore} from '../models/organization.js' import {readStdinString} from '@shopify/cli-kit/node/system' import {AbortError} from '@shopify/cli-kit/node/error' @@ -54,7 +55,7 @@ export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promi /** * Prepares the execution context for GraphQL operations. - * Handles query input from flag, file, or stdin, and sets up app and store contexts. + * Handles query input from flag, file, or stdin, validates GraphQL syntax, and sets up app and store contexts. * * @param flags - Command flags containing configuration options. * @param commandName - Name of the command for error messages (e.g., 'execute', 'bulk execute'). @@ -87,6 +88,9 @@ export async function prepareExecuteContext( ) } + // Validate GraphQL syntax and ensure single operation + validateSingleOperation(query) + const {appContextResult, store} = await prepareAppStoreContext(flags) return {query, appContextResult, store}