Skip to content

Commit 57c73ad

Browse files
introduce --output-file flag
1 parent c8e62d6 commit 57c73ad

File tree

7 files changed

+145
-8
lines changed

7 files changed

+145
-8
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export default class Execute extends AppLinkedCommand {
4141
variables: flags.variables,
4242
variableFile: flags['variable-file'],
4343
watch: flags.watch,
44+
outputFile: flags['output-file'],
4445
})
4546

4647
return {app: appContextResult.app}

packages/app/src/cli/flags.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,8 @@ export const bulkOperationFlags = {
6868
env: 'SHOPIFY_FLAG_WATCH',
6969
default: false,
7070
}),
71+
'output-file': Flags.string({
72+
description: 'The file path where results should be written. If not specified, results will be written to STDOUT.',
73+
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
74+
}),
7175
}
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: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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'
@@ -14,8 +15,10 @@ import {describe, test, expect, vi, beforeEach} from 'vitest'
1415
vi.mock('./run-query.js')
1516
vi.mock('./run-mutation.js')
1617
vi.mock('./watch-bulk-operation.js')
18+
vi.mock('./download-bulk-operation-results.js')
1719
vi.mock('@shopify/cli-kit/node/ui')
1820
vi.mock('@shopify/cli-kit/node/session')
21+
vi.mock('@shopify/cli-kit/node/fs')
1922

2023
describe('executeBulkOperation', () => {
2124
const mockApp = {
@@ -334,6 +337,7 @@ describe('executeBulkOperation', () => {
334337

335338
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
336339
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
340+
vi.mocked(downloadBulkOperationResults).mockResolvedValue('{"id":"gid://shopify/Product/123"}')
337341

338342
await executeBulkOperation({
339343
app: mockApp,
@@ -346,11 +350,75 @@ describe('executeBulkOperation', () => {
346350
expect(renderSuccess).toHaveBeenCalledWith(
347351
expect.objectContaining({
348352
headline: expect.stringContaining('Bulk operation succeeded:'),
349-
body: expect.arrayContaining([expect.stringContaining('https://example.com/download')]),
350353
}),
351354
)
352355
})
353356

357+
test('writes results to file when --output-file flag is provided', async () => {
358+
const query = '{ products { edges { node { id } } } }'
359+
const outputFile = '/tmp/results.jsonl'
360+
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
361+
362+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
363+
bulkOperation: createdBulkOperation,
364+
userErrors: [],
365+
}
366+
const completedOperation = {
367+
...createdBulkOperation,
368+
status: 'COMPLETED' as const,
369+
url: 'https://example.com/download',
370+
objectCount: '2',
371+
}
372+
373+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
374+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
375+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsContent)
376+
377+
await executeBulkOperation({
378+
app: mockApp,
379+
storeFqdn,
380+
query,
381+
watch: true,
382+
outputFile,
383+
})
384+
385+
expect(writeFile).toHaveBeenCalledWith(outputFile, resultsContent)
386+
})
387+
388+
test('writes results to stdout when --output-file flag is not provided', async () => {
389+
const query = '{ products { edges { node { id } } } }'
390+
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
391+
392+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
393+
bulkOperation: createdBulkOperation,
394+
userErrors: [],
395+
}
396+
const completedOperation = {
397+
...createdBulkOperation,
398+
status: 'COMPLETED' as const,
399+
url: 'https://example.com/download',
400+
objectCount: '2',
401+
}
402+
403+
const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
404+
405+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
406+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
407+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsContent)
408+
409+
await executeBulkOperation({
410+
app: mockApp,
411+
storeFqdn,
412+
query,
413+
watch: true,
414+
})
415+
416+
expect(stdoutWriteSpy).toHaveBeenCalledWith(resultsContent)
417+
expect(writeFile).not.toHaveBeenCalled()
418+
419+
stdoutWriteSpy.mockRestore()
420+
})
421+
354422
test.each(['FAILED', 'CANCELED', 'EXPIRED'] as const)(
355423
'waits for operation to finish and renders error when watch is provided and operation finishes with %s status',
356424
async (status) => {

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ 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'
11-
import {readFile, fileExists} from '@shopify/cli-kit/node/fs'
12+
import {readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs'
1213

1314
interface ExecuteBulkOperationInput {
1415
app: AppLinkedInterface
@@ -17,6 +18,7 @@ interface ExecuteBulkOperationInput {
1718
variables?: string[]
1819
variableFile?: string
1920
watch?: boolean
21+
outputFile?: string
2022
}
2123

2224
async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise<string | undefined> {
@@ -37,7 +39,7 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string
3739
}
3840

3941
export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
40-
const {app, storeFqdn, query, variables, variableFile, watch = false} = input
42+
const {app, storeFqdn, query, variables, variableFile, outputFile, watch = false} = input
4143

4244
renderInfo({
4345
headline: 'Starting bulk operation.',
@@ -71,14 +73,14 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
7173
if (createdOperation) {
7274
if (watch) {
7375
const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id)
74-
renderBulkOperationResult(finishedOperation)
76+
await renderBulkOperationResult(finishedOperation, outputFile)
7577
} else {
76-
renderBulkOperationResult(createdOperation)
78+
await renderBulkOperationResult(createdOperation, outputFile)
7779
}
7880
}
7981
}
8082

81-
function renderBulkOperationResult(operation: BulkOperation): void {
83+
async function renderBulkOperationResult(operation: BulkOperation, outputFile?: string): Promise<void> {
8284
const headline = formatBulkOperationStatus(operation).value
8385
const items = [
8486
outputContent`ID: ${outputToken.cyan(operation.id)}`.value,
@@ -97,8 +99,15 @@ function renderBulkOperationResult(operation: BulkOperation): void {
9799
break
98100
case 'COMPLETED':
99101
if (operation.url) {
100-
const downloadMessage = outputContent`Download results ${outputToken.link('here', operation.url)}.`.value
101-
renderSuccess({headline, body: [downloadMessage], customSections})
102+
const results = await downloadBulkOperationResults(operation.url)
103+
104+
if (outputFile) {
105+
await writeFile(outputFile, results)
106+
renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections})
107+
} else {
108+
process.stdout.write(results)
109+
renderSuccess({headline, customSections})
110+
}
102111
} else {
103112
renderSuccess({headline, customSections})
104113
}

packages/cli/oclif.manifest.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,14 @@
846846
"name": "no-color",
847847
"type": "boolean"
848848
},
849+
"output-file": {
850+
"description": "The file path where results should be written. If not specified, results will be written to STDOUT.",
851+
"env": "SHOPIFY_FLAG_OUTPUT_FILE",
852+
"hasDynamicHelp": false,
853+
"multiple": false,
854+
"name": "output-file",
855+
"type": "option"
856+
},
849857
"path": {
850858
"description": "The path to your app directory.",
851859
"env": "SHOPIFY_FLAG_PATH",

0 commit comments

Comments
 (0)