diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index e0aedecefa..5a72509861 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -28,6 +28,7 @@ export default class Execute extends AppLinkedCommand { storeFqdn: store.shopDomain, query, variables: flags.variables, + variableFile: flags['variable-file'], outputFile: flags['output-file'], ...(flags.version && {version: flags.version}), }) diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index e9561dada8..262f6e8223 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -90,6 +90,13 @@ export const operationFlags = { char: 'v', description: 'The values for any GraphQL variables in your query or mutation, in JSON format.', env: 'SHOPIFY_FLAG_VARIABLES', + exclusive: ['variable-file'], + }), + 'variable-file': Flags.string({ + description: "Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.", + env: 'SHOPIFY_FLAG_VARIABLE_FILE', + parse: async (input) => resolvePath(input), + exclusive: ['variables'], }), store: Flags.string({ char: 's', diff --git a/packages/app/src/cli/services/execute-operation.test.ts b/packages/app/src/cli/services/execute-operation.test.ts index 8f420b7331..889919fe30 100644 --- a/packages/app/src/cli/services/execute-operation.test.ts +++ b/packages/app/src/cli/services/execute-operation.test.ts @@ -104,6 +104,72 @@ describe('executeOperation', () => { expect(adminRequestDoc).not.toHaveBeenCalled() }) + test('reads and parses variables from a JSON file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const variableFile = joinPath(tmpDir, 'variables.json') + const variables = {input: {id: 'gid://shopify/Product/123', title: 'Updated'}} + await writeFile(variableFile, JSON.stringify(variables)) + + const query = 'mutation UpdateProduct($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' + const mockResult = {data: {productUpdate: {product: {id: 'gid://shopify/Product/123'}}}} + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + + await executeOperation({ + organization: mockOrganization, + remoteApp: mockRemoteApp, + storeFqdn, + query, + variableFile, + }) + + expect(adminRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + variables, + }), + ) + }) + }) + + test('throws AbortError when variable file does not exist', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const nonExistentFile = joinPath(tmpDir, 'nonexistent.json') + const query = 'query { shop { name } }' + + await expect( + executeOperation({ + organization: mockOrganization, + remoteApp: mockRemoteApp, + storeFqdn, + query, + variableFile: nonExistentFile, + }), + ).rejects.toThrow('Variable file not found') + + expect(adminRequestDoc).not.toHaveBeenCalled() + }) + }) + + test('throws AbortError when variable file contains invalid JSON', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const variableFile = joinPath(tmpDir, 'invalid.json') + await writeFile(variableFile, '{invalid json}') + + const query = 'query { shop { name } }' + + await expect( + executeOperation({ + organization: mockOrganization, + remoteApp: mockRemoteApp, + storeFqdn, + query, + variableFile, + }), + ).rejects.toThrow('Invalid JSON') + + expect(adminRequestDoc).not.toHaveBeenCalled() + }) + }) + test('uses specified API version when provided', async () => { const query = 'query { shop { name } }' const version = '2024-01' diff --git a/packages/app/src/cli/services/execute-operation.ts b/packages/app/src/cli/services/execute-operation.ts index aa3d46149b..4b92789979 100644 --- a/packages/app/src/cli/services/execute-operation.ts +++ b/packages/app/src/cli/services/execute-operation.ts @@ -11,7 +11,7 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' import {ClientError} from 'graphql-request' import {parse} from 'graphql' -import {writeFile} from '@shopify/cli-kit/node/fs' +import {writeFile, readFile, fileExists} from '@shopify/cli-kit/node/fs' interface ExecuteOperationInput { organization: Organization @@ -19,26 +19,58 @@ interface ExecuteOperationInput { storeFqdn: string query: string variables?: string + variableFile?: string outputFile?: string version?: string } -async function parseVariables(variables?: string): Promise<{[key: string]: unknown} | undefined> { - if (!variables) return undefined - - try { - return JSON.parse(variables) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - throw new AbortError( - outputContent`Invalid JSON in ${outputToken.yellow('--variables')} flag: ${errorMessage}`, - 'Please provide valid JSON format.', - ) +async function parseVariables( + variables?: string, + variableFile?: string, +): Promise<{[key: string]: unknown} | undefined> { + if (variables) { + try { + return JSON.parse(variables) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + throw new AbortError( + outputContent`Invalid JSON in ${outputToken.yellow('--variables')} flag: ${errorMessage}`, + 'Please provide valid JSON format.', + ) + } + } else if (variableFile) { + if (!(await fileExists(variableFile))) { + throw new AbortError( + outputContent`Variable file not found at ${outputToken.path( + variableFile, + )}. Please check the path and try again.`, + ) + } + const fileContent = await readFile(variableFile, {encoding: 'utf8'}) + try { + return JSON.parse(fileContent) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + throw new AbortError( + outputContent`Invalid JSON in variable file ${outputToken.path(variableFile)}: ${errorMessage}`, + 'Please provide valid JSON format.', + ) + } } + return undefined } export async function executeOperation(input: ExecuteOperationInput): Promise { - const {organization, remoteApp, storeFqdn, query, variables, version: userSpecifiedVersion, outputFile} = input + const { + organization, + remoteApp, + storeFqdn, + query, + variables, + variableFile, + version: userSpecifiedVersion, + outputFile, + } = input const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) @@ -55,7 +87,7 @@ export async function executeOperation(input: ExecuteOperationInput): Promise