Skip to content

Commit cb7b47a

Browse files
introduce --output-file flag
1 parent c9b42e0 commit cb7b47a

File tree

6 files changed

+144
-7
lines changed

6 files changed

+144
-7
lines changed

packages/app/src/cli/commands/app/execute.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export default class Execute extends AppLinkedCommand {
4040
query: flags.query,
4141
variables: flags.variables,
4242
watch: flags.watch,
43+
outputFile: flags['output-file'],
4344
})
4445

4546
return {app: appContextResult.app}

packages/app/src/cli/flags.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,8 @@ export const bulkOperationFlags = {
6060
env: 'SHOPIFY_FLAG_WATCH',
6161
default: false,
6262
}),
63+
'output-file': Flags.string({
64+
description: 'The file path where results should be written. If not specified, results will be written to STDOUT.',
65+
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
66+
}),
6367
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
2+
import {fetch} from '@shopify/cli-kit/node/http'
3+
import {describe, test, expect, vi} from 'vitest'
4+
5+
vi.mock('@shopify/cli-kit/node/http')
6+
7+
describe('downloadBulkOperationResults', () => {
8+
test('returns text content when fetch is successful', async () => {
9+
const mockUrl = 'https://example.com/results.jsonl'
10+
const mockContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
11+
12+
vi.mocked(fetch).mockResolvedValue({
13+
ok: true,
14+
text: async () => mockContent,
15+
} as Awaited<ReturnType<typeof fetch>>)
16+
17+
const result = await downloadBulkOperationResults(mockUrl)
18+
19+
expect(fetch).toHaveBeenCalledWith(mockUrl)
20+
expect(result).toBe(mockContent)
21+
})
22+
23+
test('throws error when fetch fails', async () => {
24+
const mockUrl = 'https://example.com/results.jsonl'
25+
26+
vi.mocked(fetch).mockResolvedValue({
27+
ok: false,
28+
statusText: 'Not Found',
29+
} as Awaited<ReturnType<typeof fetch>>)
30+
31+
await expect(downloadBulkOperationResults(mockUrl)).rejects.toThrow(
32+
'Failed to download bulk operation results: Not Found',
33+
)
34+
})
35+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {fetch} from '@shopify/cli-kit/node/http'
2+
import {AbortError} from '@shopify/cli-kit/node/error'
3+
4+
export async function downloadBulkOperationResults(url: string): Promise<string> {
5+
const response = await fetch(url)
6+
7+
if (!response.ok) {
8+
throw new AbortError(`Failed to download bulk operation results: ${response.statusText}`)
9+
}
10+
11+
return response.text()
12+
}

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@ import {executeBulkOperation} from './execute-bulk-operation.js'
22
import {runBulkOperationQuery} from './run-query.js'
33
import {runBulkOperationMutation} from './run-mutation.js'
44
import {watchBulkOperation} from './watch-bulk-operation.js'
5+
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
56
import {AppLinkedInterface} from '../../models/app/app.js'
67
import {BulkOperationRunQueryMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js'
78
import {BulkOperationRunMutationMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js'
89
import {renderSuccess, renderWarning, renderError} from '@shopify/cli-kit/node/ui'
910
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
11+
import {writeFile} from '@shopify/cli-kit/node/fs'
1012
import {describe, test, expect, vi, beforeEach} from 'vitest'
1113

1214
vi.mock('./run-query.js')
1315
vi.mock('./run-mutation.js')
1416
vi.mock('./watch-bulk-operation.js')
17+
vi.mock('./download-bulk-operation-results.js')
1518
vi.mock('@shopify/cli-kit/node/ui')
1619
vi.mock('@shopify/cli-kit/node/session')
20+
vi.mock('@shopify/cli-kit/node/fs')
1721

1822
describe('executeBulkOperation', () => {
1923
const mockApp = {
@@ -193,6 +197,7 @@ describe('executeBulkOperation', () => {
193197

194198
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
195199
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
200+
vi.mocked(downloadBulkOperationResults).mockResolvedValue('{"id":"gid://shopify/Product/123"}')
196201

197202
await executeBulkOperation({
198203
app: mockApp,
@@ -205,11 +210,81 @@ describe('executeBulkOperation', () => {
205210
expect(renderSuccess).toHaveBeenCalledWith(
206211
expect.objectContaining({
207212
headline: expect.stringContaining('Bulk operation succeeded.'),
208-
body: expect.arrayContaining([expect.stringContaining('https://example.com/download')]),
209213
}),
210214
)
211215
})
212216

217+
test('writes results to file when --output-file flag is provided', async () => {
218+
const query = '{ products { edges { node { id } } } }'
219+
const outputFile = '/tmp/results.jsonl'
220+
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
221+
222+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
223+
bulkOperation: createdBulkOperation,
224+
userErrors: [],
225+
}
226+
const completedOperation = {
227+
...createdBulkOperation,
228+
status: 'COMPLETED' as const,
229+
url: 'https://example.com/download',
230+
objectCount: '2',
231+
}
232+
233+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
234+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
235+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsContent)
236+
237+
await executeBulkOperation({
238+
app: mockApp,
239+
storeFqdn,
240+
query,
241+
watch: true,
242+
outputFile,
243+
})
244+
245+
expect(writeFile).toHaveBeenCalledWith(outputFile, resultsContent)
246+
expect(renderSuccess).toHaveBeenCalledWith(
247+
expect.objectContaining({
248+
body: expect.arrayContaining([expect.stringContaining(outputFile)]),
249+
}),
250+
)
251+
})
252+
253+
test('writes results to stdout when --output-file flag is not provided', async () => {
254+
const query = '{ products { edges { node { id } } } }'
255+
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
256+
257+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
258+
bulkOperation: createdBulkOperation,
259+
userErrors: [],
260+
}
261+
const completedOperation = {
262+
...createdBulkOperation,
263+
status: 'COMPLETED' as const,
264+
url: 'https://example.com/download',
265+
objectCount: '2',
266+
}
267+
268+
const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
269+
270+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
271+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
272+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsContent)
273+
274+
await executeBulkOperation({
275+
app: mockApp,
276+
storeFqdn,
277+
query,
278+
watch: true,
279+
})
280+
281+
expect(stdoutWriteSpy).toHaveBeenCalledWith(resultsContent)
282+
expect(writeFile).not.toHaveBeenCalled()
283+
expect(renderSuccess).toHaveBeenCalled()
284+
285+
stdoutWriteSpy.mockRestore()
286+
})
287+
213288
test.each(['FAILED', 'CANCELED', 'EXPIRED'] as const)(
214289
'waits for operation to finish and renders error when watch is provided and operation finishes with %s status',
215290
async (status) => {

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,26 @@ import {runBulkOperationQuery} from './run-query.js'
22
import {runBulkOperationMutation} from './run-mutation.js'
33
import {watchBulkOperation, type BulkOperation} from './watch-bulk-operation.js'
44
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
5+
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
56
import {AppLinkedInterface} from '../../models/app/app.js'
67
import {renderSuccess, renderInfo, renderError, renderWarning} from '@shopify/cli-kit/node/ui'
78
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
89
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
910
import {AbortError} from '@shopify/cli-kit/node/error'
1011
import {parse} from 'graphql'
12+
import {writeFile} from '@shopify/cli-kit/node/fs'
1113

1214
interface ExecuteBulkOperationInput {
1315
app: AppLinkedInterface
1416
storeFqdn: string
1517
query: string
1618
variables?: string[]
1719
watch?: boolean
20+
outputFile?: string
1821
}
1922

2023
export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
21-
const {app, storeFqdn, query, variables, watch = false} = input
24+
const {app, storeFqdn, query, variables, watch = false, outputFile} = input
2225

2326
renderInfo({
2427
headline: 'Starting bulk operation.',
@@ -56,14 +59,14 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
5659
if (createdOperation) {
5760
if (watch) {
5861
const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id)
59-
renderBulkOperationResult(finishedOperation)
62+
await renderBulkOperationResult(finishedOperation, outputFile)
6063
} else {
61-
renderBulkOperationResult(createdOperation)
64+
await renderBulkOperationResult(createdOperation, outputFile)
6265
}
6366
}
6467
}
6568

66-
function renderBulkOperationResult(operation: BulkOperation): void {
69+
async function renderBulkOperationResult(operation: BulkOperation, outputFile?: string): Promise<void> {
6770
const headline = formatBulkOperationStatus(operation).value
6871
const items = [
6972
outputContent`ID: ${outputToken.cyan(operation.id)}`.value,
@@ -82,8 +85,15 @@ function renderBulkOperationResult(operation: BulkOperation): void {
8285
break
8386
case 'COMPLETED':
8487
if (operation.url) {
85-
const downloadMessage = outputContent`Download results ${outputToken.link('here.', operation.url)}`.value
86-
renderSuccess({headline, body: [downloadMessage], customSections})
88+
const results = await downloadBulkOperationResults(operation.url)
89+
90+
if (outputFile) {
91+
await writeFile(outputFile, results)
92+
renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections})
93+
} else {
94+
process.stdout.write(results)
95+
renderSuccess({headline, customSections})
96+
}
8797
} else {
8898
renderSuccess({headline, customSections})
8999
}

0 commit comments

Comments
 (0)