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/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 = { @@ -170,4 +176,44 @@ 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) + }) + + 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 4c40d542d1..26c43882f7 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.ts @@ -1,8 +1,11 @@ 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' +import {readFile, fileExists} from '@shopify/cli-kit/node/fs' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' interface AppStoreContextFlags { path: string @@ -19,6 +22,7 @@ interface AppStoreContext { interface ExecuteCommandFlags extends AppStoreContextFlags { query?: string + 'query-file'?: string } interface ExecuteContext extends AppStoreContext { @@ -51,7 +55,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, 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'). @@ -61,14 +65,32 @@ 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`, ) } + // Validate GraphQL syntax and ensure single operation + validateSingleOperation(query) + const {appContextResult, store} = await prepareAppStoreContext(flags) return {query, appContextResult, store} 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.",