diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index 2b352d3dca..72a2b1bd6c 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -41,6 +41,7 @@ export default class Execute extends AppLinkedCommand { variables: flags.variables, variableFile: flags['variable-file'], watch: flags.watch, + outputFile: flags['output-file'], }) return {app: appContextResult.app} diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index 3eaade3219..70da9973ba 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -68,4 +68,8 @@ export const bulkOperationFlags = { env: 'SHOPIFY_FLAG_WATCH', default: false, }), + 'output-file': Flags.string({ + description: 'The file path where results should be written. If not specified, results will be written to STDOUT.', + env: 'SHOPIFY_FLAG_OUTPUT_FILE', + }), } diff --git a/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.test.ts b/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.test.ts new file mode 100644 index 0000000000..802a7aef6b --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.test.ts @@ -0,0 +1,35 @@ +import {downloadBulkOperationResults} from './download-bulk-operation-results.js' +import {fetch} from '@shopify/cli-kit/node/http' +import {describe, test, expect, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/http') + +describe('downloadBulkOperationResults', () => { + test('returns text content when fetch is successful', async () => { + const mockUrl = 'https://example.com/results.jsonl' + const mockContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}' + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: async () => mockContent, + } as Awaited>) + + const result = await downloadBulkOperationResults(mockUrl) + + expect(fetch).toHaveBeenCalledWith(mockUrl) + expect(result).toBe(mockContent) + }) + + test('throws error when fetch fails', async () => { + const mockUrl = 'https://example.com/results.jsonl' + + vi.mocked(fetch).mockResolvedValue({ + ok: false, + statusText: 'Not Found', + } as Awaited>) + + await expect(downloadBulkOperationResults(mockUrl)).rejects.toThrow( + 'Failed to download bulk operation results: Not Found', + ) + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.ts b/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.ts new file mode 100644 index 0000000000..0c779cc342 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.ts @@ -0,0 +1,12 @@ +import {fetch} from '@shopify/cli-kit/node/http' +import {AbortError} from '@shopify/cli-kit/node/error' + +export async function downloadBulkOperationResults(url: string): Promise { + const response = await fetch(url) + + if (!response.ok) { + throw new AbortError(`Failed to download bulk operation results: ${response.statusText}`) + } + + return response.text() +} 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 66266963dc..6830c639fa 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 @@ -2,6 +2,7 @@ import {executeBulkOperation} from './execute-bulk-operation.js' import {runBulkOperationQuery} from './run-query.js' import {runBulkOperationMutation} from './run-mutation.js' import {watchBulkOperation} from './watch-bulk-operation.js' +import {downloadBulkOperationResults} from './download-bulk-operation-results.js' import {AppLinkedInterface} from '../../models/app/app.js' import {BulkOperationRunQueryMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js' import {BulkOperationRunMutationMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js' @@ -9,13 +10,16 @@ import {renderSuccess, renderWarning, renderError} from '@shopify/cli-kit/node/u 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' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' vi.mock('./run-query.js') vi.mock('./run-mutation.js') vi.mock('./watch-bulk-operation.js') +vi.mock('./download-bulk-operation-results.js') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/fs') describe('executeBulkOperation', () => { const mockApp = { @@ -46,6 +50,10 @@ describe('executeBulkOperation', () => { vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockAdminSession) }) + afterEach(() => { + mockAndCaptureOutput().clear() + }) + test('runs query operation when GraphQL document starts with query', async () => { const query = 'query { products { edges { node { id } } } }' const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { @@ -334,6 +342,7 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation) + vi.mocked(downloadBulkOperationResults).mockResolvedValue('{"id":"gid://shopify/Product/123"}') await executeBulkOperation({ app: mockApp, @@ -346,11 +355,73 @@ describe('executeBulkOperation', () => { expect(renderSuccess).toHaveBeenCalledWith( expect.objectContaining({ headline: expect.stringContaining('Bulk operation succeeded:'), - body: expect.arrayContaining([expect.stringContaining('https://example.com/download')]), }), ) }) + test('writes results to file when --output-file flag is provided', async () => { + const query = '{ products { edges { node { id } } } }' + const outputFile = '/tmp/results.jsonl' + const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}' + + const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + const completedOperation = { + ...createdBulkOperation, + status: 'COMPLETED' as const, + url: 'https://example.com/download', + objectCount: '2', + } + + vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) + vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation) + vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsContent) + + await executeBulkOperation({ + app: mockApp, + storeFqdn, + query, + watch: true, + outputFile, + }) + + expect(writeFile).toHaveBeenCalledWith(outputFile, resultsContent) + }) + + test('writes results to stdout when --output-file flag is not provided', async () => { + const query = '{ products { edges { node { id } } } }' + const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}' + + const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + const completedOperation = { + ...createdBulkOperation, + status: 'COMPLETED' as const, + url: 'https://example.com/download', + objectCount: '2', + } + + const mockOutput = mockAndCaptureOutput() + + vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) + vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation) + vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsContent) + + await executeBulkOperation({ + app: mockApp, + storeFqdn, + query, + watch: true, + }) + + expect(mockOutput.info()).toContain(resultsContent) + expect(writeFile).not.toHaveBeenCalled() + }) + test.each(['FAILED', 'CANCELED', 'EXPIRED'] as const)( 'waits for operation to finish and renders error when watch is provided and operation finishes with %s status', async (status) => { 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 521456ac20..6ffe33a161 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 @@ -2,13 +2,14 @@ import {runBulkOperationQuery} from './run-query.js' import {runBulkOperationMutation} from './run-mutation.js' import {watchBulkOperation, type BulkOperation} from './watch-bulk-operation.js' import {formatBulkOperationStatus} from './format-bulk-operation-status.js' +import {downloadBulkOperationResults} from './download-bulk-operation-results.js' import {AppLinkedInterface} from '../../models/app/app.js' import {renderSuccess, renderInfo, renderError, renderWarning} from '@shopify/cli-kit/node/ui' -import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {outputContent, outputToken, outputResult} 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' +import {readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs' interface ExecuteBulkOperationInput { app: AppLinkedInterface @@ -17,6 +18,7 @@ interface ExecuteBulkOperationInput { variables?: string[] variableFile?: string watch?: boolean + outputFile?: string } async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise { @@ -37,7 +39,7 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string } export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise { - const {app, storeFqdn, query, variables, variableFile, watch = false} = input + const {app, storeFqdn, query, variables, variableFile, outputFile, watch = false} = input renderInfo({ headline: 'Starting bulk operation.', @@ -71,14 +73,14 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr if (createdOperation) { if (watch) { const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id) - renderBulkOperationResult(finishedOperation) + await renderBulkOperationResult(finishedOperation, outputFile) } else { - renderBulkOperationResult(createdOperation) + await renderBulkOperationResult(createdOperation, outputFile) } } } -function renderBulkOperationResult(operation: BulkOperation): void { +async function renderBulkOperationResult(operation: BulkOperation, outputFile?: string): Promise { const headline = formatBulkOperationStatus(operation).value const items = [ outputContent`ID: ${outputToken.cyan(operation.id)}`.value, @@ -97,8 +99,15 @@ function renderBulkOperationResult(operation: BulkOperation): void { break case 'COMPLETED': if (operation.url) { - const downloadMessage = outputContent`Download results ${outputToken.link('here', operation.url)}.`.value - renderSuccess({headline, body: [downloadMessage], customSections}) + const results = await downloadBulkOperationResults(operation.url) + + if (outputFile) { + await writeFile(outputFile, results) + renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections}) + } else { + outputResult(results) + renderSuccess({headline, customSections}) + } } else { renderSuccess({headline, customSections}) } diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 6952fe1159..8876864fe9 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -846,6 +846,14 @@ "name": "no-color", "type": "boolean" }, + "output-file": { + "description": "The file path where results should be written. If not specified, results will be written to STDOUT.", + "env": "SHOPIFY_FLAG_OUTPUT_FILE", + "hasDynamicHelp": false, + "multiple": false, + "name": "output-file", + "type": "option" + }, "path": { "description": "The path to your app directory.", "env": "SHOPIFY_FLAG_PATH",