diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index 6ea5195374..2ee7fee7e4 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -39,6 +39,7 @@ export default class Execute extends AppLinkedCommand { storeFqdn: store.shopDomain, query: flags.query, variables: flags.variables, + variableFile: flags['variable-file'], }) return {app: appContextResult.app} diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index 176648efb2..ee68dbb5b0 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -48,6 +48,14 @@ export const bulkOperationFlags = { 'The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.', env: 'SHOPIFY_FLAG_VARIABLES', multiple: true, + exclusive: ['variable-file'], + }), + 'variable-file': Flags.string({ + description: + "Path to a file containing GraphQL variables in JSONL format (one JSON object per line). 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/bulk-operations/execute-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts index 5e6ac79048..298bccb40d 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 @@ -4,6 +4,8 @@ import {runBulkOperationMutation} from './run-mutation.js' import {AppLinkedInterface} from '../../models/app/app.js' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' import {describe, test, expect, vi, beforeEach} from 'vitest' vi.mock('./run-query.js') @@ -50,7 +52,6 @@ describe('executeBulkOperation', () => { expect(runBulkOperationQuery).toHaveBeenCalledWith({ adminSession: mockAdminSession, query, - variables: undefined, }) expect(runBulkOperationMutation).not.toHaveBeenCalled() }) @@ -72,7 +73,6 @@ describe('executeBulkOperation', () => { expect(runBulkOperationQuery).toHaveBeenCalledWith({ adminSession: mockAdminSession, query, - variables: undefined, }) expect(runBulkOperationMutation).not.toHaveBeenCalled() }) @@ -94,7 +94,7 @@ describe('executeBulkOperation', () => { expect(runBulkOperationMutation).toHaveBeenCalledWith({ adminSession: mockAdminSession, query: mutation, - variables: undefined, + variablesJsonl: undefined, }) expect(runBulkOperationQuery).not.toHaveBeenCalled() }) @@ -118,7 +118,7 @@ describe('executeBulkOperation', () => { expect(runBulkOperationMutation).toHaveBeenCalledWith({ adminSession: mockAdminSession, query: mutation, - variables, + variablesJsonl: '{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}', }) }) @@ -216,4 +216,118 @@ describe('executeBulkOperation', () => { expect(runBulkOperationQuery).not.toHaveBeenCalled() expect(runBulkOperationMutation).not.toHaveBeenCalled() }) + + test('reads variables from file when variableFile is provided', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const variableFilePath = joinPath(tmpDir, 'variables.jsonl') + const variables = [ + '{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}', + '{"input":{"id":"gid://shopify/Product/456","tags":["test2"]}}', + ] + await writeFile(variableFilePath, variables.join('\n')) + + const mutation = + 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' + const mockResponse = { + bulkOperation: successfulBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any) + + await executeBulkOperation({ + app: mockApp, + storeFqdn, + query: mutation, + variableFile: variableFilePath, + }) + + expect(runBulkOperationMutation).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query: mutation, + variablesJsonl: variables.join('\n'), + }) + }) + }) + + test('throws error when both variables and variableFile are provided', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const variableFilePath = joinPath(tmpDir, 'variables.jsonl') + await writeFile(variableFilePath, '{"input":{}}') + + const mutation = + 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' + const variables = ['{"input":{"id":"gid://shopify/Product/123"}}'] + + await expect( + executeBulkOperation({ + app: mockApp, + storeFqdn, + query: mutation, + variables, + variableFile: variableFilePath, + }), + ).rejects.toThrow(/Can't use both.*--variables.*and.*--variable-file/) + + expect(runBulkOperationQuery).not.toHaveBeenCalled() + expect(runBulkOperationMutation).not.toHaveBeenCalled() + }) + }) + + test('throws error when variableFile does not exist', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const nonExistentPath = joinPath(tmpDir, 'nonexistent.jsonl') + const mutation = + 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' + + await expect( + executeBulkOperation({ + app: mockApp, + storeFqdn, + query: mutation, + variableFile: nonExistentPath, + }), + ).rejects.toThrow('Variable file not found') + + expect(runBulkOperationQuery).not.toHaveBeenCalled() + expect(runBulkOperationMutation).not.toHaveBeenCalled() + }) + }) + + test('throws error when variables are provided with a query (not mutation)', async () => { + const query = 'query { products { edges { node { id } } } }' + const variables = ['{"input":{"id":"gid://shopify/Product/123"}}'] + + await expect( + executeBulkOperation({ + app: mockApp, + storeFqdn, + query, + variables, + }), + ).rejects.toThrow('can only be used with mutations, not queries') + + expect(runBulkOperationQuery).not.toHaveBeenCalled() + expect(runBulkOperationMutation).not.toHaveBeenCalled() + }) + + test('throws error when variableFile is provided with a query (not mutation)', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const variableFilePath = joinPath(tmpDir, 'variables.jsonl') + await writeFile(variableFilePath, '{"input":{}}') + + const query = 'query { products { edges { node { id } } } }' + + await expect( + executeBulkOperation({ + app: mockApp, + storeFqdn, + query, + variableFile: variableFilePath, + }), + ).rejects.toThrow('can only be used with mutations, not queries') + + expect(runBulkOperationQuery).not.toHaveBeenCalled() + expect(runBulkOperationMutation).not.toHaveBeenCalled() + }) + }) }) 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 b84e88a0b4..360772a124 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 @@ -6,28 +6,55 @@ import {outputContent, outputToken} from '@shopify/cli-kit/node/output' import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' import {AbortError} from '@shopify/cli-kit/node/error' import {parse} from 'graphql' +import {readFile, fileExists} from '@shopify/cli-kit/node/fs' interface ExecuteBulkOperationInput { app: AppLinkedInterface storeFqdn: string query: string variables?: string[] + variableFile?: string +} + +async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise { + if (variables && variableFile) { + throw new AbortError( + outputContent`Can't use both ${outputToken.yellow('--variables')} and ${outputToken.yellow( + '--variable-file', + )} flags. Please use only one.`, + ) + } + + if (variables) { + return variables.join('\n') + } 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.`, + ) + } + return readFile(variableFile, {encoding: 'utf8'}) + } else { + return undefined + } } export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise { - const {app, storeFqdn, query, variables} = input + const {app, storeFqdn, query, variables, variableFile} = input renderInfo({ headline: 'Starting bulk operation.', body: `App: ${app.name}\nStore: ${storeFqdn}`, }) - const adminSession = await ensureAuthenticatedAdmin(storeFqdn) - const operationIsMutation = validateGraphQLDocument(query, variables) + const variablesJsonl = await parseVariablesToJsonl(variables, variableFile) + const operationIsMutation = validateGraphQLDocument(query, variablesJsonl) const bulkOperationResponse = operationIsMutation - ? await runBulkOperationMutation({adminSession, query, variables}) + ? await runBulkOperationMutation({adminSession, query, variablesJsonl}) : await runBulkOperationQuery({adminSession, query}) if (bulkOperationResponse?.userErrors?.length) { @@ -72,7 +99,7 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr } } -function validateGraphQLDocument(graphqlOperation: string, variables?: string[]): boolean { +function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): boolean { const document = parse(graphqlOperation) const operationDefinitions = document.definitions.filter((def) => def.kind === 'OperationDefinition') @@ -85,9 +112,11 @@ function validateGraphQLDocument(graphqlOperation: string, variables?: string[]) const operation = operationDefinitions[0] const operationIsMutation = operation?.kind === 'OperationDefinition' && operation.operation === 'mutation' - if (!operationIsMutation && variables) { + if (!operationIsMutation && variablesJsonl) { throw new AbortError( - outputContent`The ${outputToken.yellow('--variables')} flag can only be used with mutations, not queries.`, + outputContent`The ${outputToken.yellow('--variables')} and ${outputToken.yellow( + '--variable-file', + )} flags can only be used with mutations, not queries.`, ) } diff --git a/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts b/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts index dae9c33b18..17fde6aa91 100644 --- a/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts +++ b/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts @@ -34,7 +34,7 @@ describe('runBulkOperationMutation', () => { const bulkOperationResult = await runBulkOperationMutation({ adminSession: mockSession, query: 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }', - variables: ['{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}'], + variablesJsonl: '{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}', }) expect(bulkOperationResult?.bulkOperation).toEqual(successfulBulkOperation) diff --git a/packages/app/src/cli/services/bulk-operations/run-mutation.ts b/packages/app/src/cli/services/bulk-operations/run-mutation.ts index cb86680df9..fff5727ae2 100644 --- a/packages/app/src/cli/services/bulk-operations/run-mutation.ts +++ b/packages/app/src/cli/services/bulk-operations/run-mutation.ts @@ -10,17 +10,17 @@ import {AdminSession} from '@shopify/cli-kit/node/session' interface BulkOperationRunMutationOptions { adminSession: AdminSession query: string - variables?: string[] + variablesJsonl?: string } export async function runBulkOperationMutation( options: BulkOperationRunMutationOptions, ): Promise { - const {adminSession, query: mutation, variables} = options + const {adminSession, query: mutation, variablesJsonl} = options const stagedUploadPath = await stageFile({ adminSession, - jsonVariables: variables, + variablesJsonl, }) const response = await adminRequestDoc({ diff --git a/packages/app/src/cli/services/bulk-operations/stage-file.test.ts b/packages/app/src/cli/services/bulk-operations/stage-file.test.ts index 3927c83b94..e5ee5476d1 100644 --- a/packages/app/src/cli/services/bulk-operations/stage-file.test.ts +++ b/packages/app/src/cli/services/bulk-operations/stage-file.test.ts @@ -44,55 +44,53 @@ describe('stageFile', () => { } as any) }) - test('returns staged upload key when file is successfully staged with empty variables', async () => { + test('returns staged upload key when file is successfully staged with no variables', async () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) const result = await stageFile({ adminSession: mockSession, - jsonVariables: [], + variablesJsonl: undefined, }) expect(result).toBe('test-key') }) - test('converts JSON strings array to JSONL format when uploading file', async () => { + test('converts JSONL string to buffer when uploading file', async () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) const mockAppend = vi.fn() vi.mocked(formData).mockReturnValue({append: mockAppend} as any) - const jsonVariables = ['{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}'] + const variablesJsonl = '{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}' await stageFile({ adminSession: mockSession, - jsonVariables, + variablesJsonl, }) - // Find the form.append('file', buffer, options) call among all append calls const fileAppendCall = mockAppend.mock.calls.find((call) => { const fieldName = call[0] return fieldName === 'file' }) - // Extract the buffer (second argument) that was uploaded const uploadedBuffer = fileAppendCall?.[1] const uploadedContent = uploadedBuffer?.toString('utf-8') expect(uploadedContent).toBe('{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}\n') }) - test('handles multiple JSON variable strings correctly', async () => { + test('handles JSONL with multiple lines correctly', async () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) const mockAppend = vi.fn() vi.mocked(formData).mockReturnValue({append: mockAppend} as any) - const jsonVariables = [ + const variablesJsonl = [ '{"input":{"id":"gid://shopify/Product/1","title":"New Shirt"}}', '{"input":{"id":"gid://shopify/Product/2","title":"Cool Pants"}}', '{"input":{"id":"gid://shopify/Product/3","title":"Nice Hat"}}', - ] + ].join('\n') await stageFile({ adminSession: mockSession, - jsonVariables, + variablesJsonl, }) const fileAppendCall = mockAppend.mock.calls.find((call) => { diff --git a/packages/app/src/cli/services/bulk-operations/stage-file.ts b/packages/app/src/cli/services/bulk-operations/stage-file.ts index a7d65f65f4..ec725b99a7 100644 --- a/packages/app/src/cli/services/bulk-operations/stage-file.ts +++ b/packages/app/src/cli/services/bulk-operations/stage-file.ts @@ -10,13 +10,13 @@ import {AbortError} from '@shopify/cli-kit/node/error' interface StageFileOptions { adminSession: AdminSession - jsonVariables?: string[] + variablesJsonl?: string } export async function stageFile(options: StageFileOptions): Promise { - const {adminSession, jsonVariables = []} = options + const {adminSession, variablesJsonl} = options - const buffer = convertJsonToJsonlBuffer(jsonVariables) + const buffer = Buffer.from(variablesJsonl ? `${variablesJsonl}\n` : '', 'utf-8') const filename = 'bulk-variables.jsonl' const size = buffer.length @@ -28,11 +28,6 @@ export async function stageFile(options: StageFileOptions): Promise { return target.stagedUploadKey } -function convertJsonToJsonlBuffer(jsonVariables: string[]): Buffer { - const jsonlContent = `${jsonVariables.join('\n')}\n` - return Buffer.from(jsonlContent, 'utf-8') -} - async function requestStagedUpload( adminSession: AdminSession, filename: string, diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d8d00d63b1..4ae041e032 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -885,10 +885,24 @@ "name": "store", "type": "option" }, + "variable-file": { + "description": "Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.", + "env": "SHOPIFY_FLAG_VARIABLE_FILE", + "exclusive": [ + "variables" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "variable-file", + "type": "option" + }, "variables": { "char": "v", "description": "The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.", "env": "SHOPIFY_FLAG_VARIABLES", + "exclusive": [ + "variable-file" + ], "hasDynamicHelp": false, "multiple": true, "name": "variables",