Skip to content

Commit b2f1d4e

Browse files
nickwesselmanclaude
andcommitted
Add shopify store bulk commands (execute, status, cancel)
Mirrors `app bulk` commands using user auth instead of app credentials. These commands don't require an app to be linked or installed on the target store. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c4f19d8 commit b2f1d4e

13 files changed

Lines changed: 983 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import StoreBulkCancel from './cancel.js'
2+
import {storeCancelBulkOperation} from '../../../services/store-bulk-cancel-operation.js'
3+
import {describe, expect, test, vi} from 'vitest'
4+
5+
vi.mock('../../../services/store-bulk-cancel-operation.js')
6+
7+
describe('store bulk cancel command', () => {
8+
test('requires --store flag', async () => {
9+
await expect(
10+
StoreBulkCancel.run(['--id', '123'], import.meta.url),
11+
).rejects.toThrow()
12+
13+
expect(storeCancelBulkOperation).not.toHaveBeenCalled()
14+
})
15+
16+
test('requires --id flag', async () => {
17+
await expect(
18+
StoreBulkCancel.run(['--store', 'test-store.myshopify.com'], import.meta.url),
19+
).rejects.toThrow()
20+
21+
expect(storeCancelBulkOperation).not.toHaveBeenCalled()
22+
})
23+
24+
test('calls storeCancelBulkOperation with correct arguments', async () => {
25+
vi.mocked(storeCancelBulkOperation).mockResolvedValue()
26+
27+
await StoreBulkCancel.run(
28+
['--store', 'test-store.myshopify.com', '--id', '123'],
29+
import.meta.url,
30+
)
31+
32+
expect(storeCancelBulkOperation).toHaveBeenCalledWith({
33+
storeFqdn: 'test-store.myshopify.com',
34+
operationId: 'gid://shopify/BulkOperation/123',
35+
})
36+
})
37+
38+
test('accepts full GID format for --id', async () => {
39+
vi.mocked(storeCancelBulkOperation).mockResolvedValue()
40+
41+
await StoreBulkCancel.run(
42+
['--store', 'test-store.myshopify.com', '--id', 'gid://shopify/BulkOperation/456'],
43+
import.meta.url,
44+
)
45+
46+
expect(storeCancelBulkOperation).toHaveBeenCalledWith({
47+
storeFqdn: 'test-store.myshopify.com',
48+
operationId: 'gid://shopify/BulkOperation/456',
49+
})
50+
})
51+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {storeCancelBulkOperation} from '../../../services/store-bulk-cancel-operation.js'
2+
import {normalizeBulkOperationId} from '../../../services/bulk-operations/bulk-operation-status.js'
3+
import {Flags} from '@oclif/core'
4+
import {globalFlags} from '@shopify/cli-kit/node/cli'
5+
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
6+
import BaseCommand from '@shopify/cli-kit/node/base-command'
7+
8+
export default class StoreBulkCancel extends BaseCommand {
9+
static summary = 'Cancel a bulk operation on a store.'
10+
11+
static description = 'Cancels a running bulk operation by ID, authenticated as the current user.'
12+
13+
static flags = {
14+
...globalFlags,
15+
id: Flags.string({
16+
description: 'The bulk operation ID to cancel (numeric ID or full GID).',
17+
env: 'SHOPIFY_FLAG_ID',
18+
required: true,
19+
}),
20+
store: Flags.string({
21+
char: 's',
22+
description: 'The myshopify.com domain of the store.',
23+
env: 'SHOPIFY_FLAG_STORE',
24+
parse: async (input) => normalizeStoreFqdn(input),
25+
required: true,
26+
}),
27+
}
28+
29+
async run(): Promise<void> {
30+
const {flags} = await this.parse(StoreBulkCancel)
31+
32+
await storeCancelBulkOperation({
33+
storeFqdn: flags.store,
34+
operationId: normalizeBulkOperationId(flags.id),
35+
})
36+
}
37+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import StoreBulkExecute from './execute.js'
2+
import {storeExecuteBulkOperation} from '../../../services/store-bulk-execute-operation.js'
3+
import {loadQuery} from '../../../utilities/execute-command-helpers.js'
4+
import {describe, expect, test, vi} from 'vitest'
5+
6+
vi.mock('../../../services/store-bulk-execute-operation.js')
7+
vi.mock('../../../utilities/execute-command-helpers.js')
8+
9+
describe('store bulk execute command', () => {
10+
test('requires --store flag', async () => {
11+
vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }')
12+
vi.mocked(storeExecuteBulkOperation).mockResolvedValue()
13+
14+
await expect(
15+
StoreBulkExecute.run(['--query', 'query { shop { name } }'], import.meta.url),
16+
).rejects.toThrow()
17+
18+
expect(storeExecuteBulkOperation).not.toHaveBeenCalled()
19+
})
20+
21+
test('calls storeExecuteBulkOperation with correct arguments', async () => {
22+
vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }')
23+
vi.mocked(storeExecuteBulkOperation).mockResolvedValue()
24+
25+
await StoreBulkExecute.run(
26+
['--store', 'test-store.myshopify.com', '--query', 'query { shop { name } }'],
27+
import.meta.url,
28+
)
29+
30+
expect(loadQuery).toHaveBeenCalledWith(
31+
expect.objectContaining({query: 'query { shop { name } }'}),
32+
)
33+
expect(storeExecuteBulkOperation).toHaveBeenCalledWith(
34+
expect.objectContaining({
35+
storeFqdn: 'test-store.myshopify.com',
36+
query: 'query { shop { name } }',
37+
watch: false,
38+
}),
39+
)
40+
})
41+
42+
test('passes version flag when provided', async () => {
43+
vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }')
44+
vi.mocked(storeExecuteBulkOperation).mockResolvedValue()
45+
46+
await StoreBulkExecute.run(
47+
['--store', 'test-store.myshopify.com', '--query', 'query { shop { name } }', '--version', '2024-01'],
48+
import.meta.url,
49+
)
50+
51+
expect(storeExecuteBulkOperation).toHaveBeenCalledWith(
52+
expect.objectContaining({
53+
version: '2024-01',
54+
}),
55+
)
56+
})
57+
58+
test('passes watch and output-file flags when provided', async () => {
59+
vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }')
60+
vi.mocked(storeExecuteBulkOperation).mockResolvedValue()
61+
62+
await StoreBulkExecute.run(
63+
[
64+
'--store', 'test-store.myshopify.com',
65+
'--query', 'query { shop { name } }',
66+
'--watch',
67+
'--output-file', '/tmp/out.jsonl',
68+
],
69+
import.meta.url,
70+
)
71+
72+
expect(storeExecuteBulkOperation).toHaveBeenCalledWith(
73+
expect.objectContaining({
74+
watch: true,
75+
outputFile: '/tmp/out.jsonl',
76+
}),
77+
)
78+
})
79+
80+
test('passes variables flag when provided', async () => {
81+
vi.mocked(loadQuery).mockResolvedValue('mutation { productCreate { product { id } } }')
82+
vi.mocked(storeExecuteBulkOperation).mockResolvedValue()
83+
84+
await StoreBulkExecute.run(
85+
[
86+
'--store', 'test-store.myshopify.com',
87+
'--query', 'mutation { productCreate { product { id } } }',
88+
'--variables', '{"input": {"title": "test"}}',
89+
],
90+
import.meta.url,
91+
)
92+
93+
expect(storeExecuteBulkOperation).toHaveBeenCalledWith(
94+
expect.objectContaining({
95+
variables: ['{"input": {"title": "test"}}'],
96+
}),
97+
)
98+
})
99+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {storeBulkOperationFlags} from '../../../flags.js'
2+
import {storeExecuteBulkOperation} from '../../../services/store-bulk-execute-operation.js'
3+
import {loadQuery} from '../../../utilities/execute-command-helpers.js'
4+
import {globalFlags} from '@shopify/cli-kit/node/cli'
5+
import BaseCommand from '@shopify/cli-kit/node/base-command'
6+
7+
export default class StoreBulkExecute extends BaseCommand {
8+
static summary = 'Execute bulk operations against a store.'
9+
10+
static descriptionWithMarkdown = `Executes an Admin API GraphQL query or mutation on the specified store as a bulk operation, authenticated as the current user.
11+
12+
Unlike [\`app bulk execute\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute), this command does not require an app to be linked or installed on the target store.
13+
14+
Bulk operations allow you to process large amounts of data asynchronously. Learn more about [bulk query operations](https://shopify.dev/docs/api/usage/bulk-operations/queries) and [bulk mutation operations](https://shopify.dev/docs/api/usage/bulk-operations/imports).
15+
16+
Use [\`store bulk status\`](https://shopify.dev/docs/api/shopify-cli/store/store-bulk-status) to check the status of your bulk operations.`
17+
18+
static description = this.descriptionWithoutMarkdown()
19+
20+
static flags = {
21+
...globalFlags,
22+
...storeBulkOperationFlags,
23+
}
24+
25+
async run(): Promise<void> {
26+
const {flags} = await this.parse(StoreBulkExecute)
27+
const query = await loadQuery(flags)
28+
await storeExecuteBulkOperation({
29+
storeFqdn: flags.store,
30+
query,
31+
variables: flags.variables,
32+
variableFile: flags['variable-file'],
33+
watch: flags.watch ?? false,
34+
outputFile: flags['output-file'],
35+
...(flags.version && {version: flags.version}),
36+
})
37+
}
38+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import StoreBulkStatus from './status.js'
2+
import {storeGetBulkOperationStatus, storeListBulkOperations} from '../../../services/store-bulk-operation-status.js'
3+
import {describe, expect, test, vi} from 'vitest'
4+
5+
vi.mock('../../../services/store-bulk-operation-status.js')
6+
7+
describe('store bulk status command', () => {
8+
test('requires --store flag', async () => {
9+
await expect(StoreBulkStatus.run([], import.meta.url)).rejects.toThrow()
10+
11+
expect(storeGetBulkOperationStatus).not.toHaveBeenCalled()
12+
expect(storeListBulkOperations).not.toHaveBeenCalled()
13+
})
14+
15+
test('calls storeGetBulkOperationStatus when --id is provided', async () => {
16+
vi.mocked(storeGetBulkOperationStatus).mockResolvedValue()
17+
18+
await StoreBulkStatus.run(
19+
['--store', 'test-store.myshopify.com', '--id', '123'],
20+
import.meta.url,
21+
)
22+
23+
expect(storeGetBulkOperationStatus).toHaveBeenCalledWith({
24+
storeFqdn: 'test-store.myshopify.com',
25+
operationId: 'gid://shopify/BulkOperation/123',
26+
})
27+
})
28+
29+
test('calls storeListBulkOperations when --id is not provided', async () => {
30+
vi.mocked(storeListBulkOperations).mockResolvedValue()
31+
32+
await StoreBulkStatus.run(
33+
['--store', 'test-store.myshopify.com'],
34+
import.meta.url,
35+
)
36+
37+
expect(storeListBulkOperations).toHaveBeenCalledWith({
38+
storeFqdn: 'test-store.myshopify.com',
39+
})
40+
})
41+
42+
test('accepts full GID format for --id', async () => {
43+
vi.mocked(storeGetBulkOperationStatus).mockResolvedValue()
44+
45+
await StoreBulkStatus.run(
46+
['--store', 'test-store.myshopify.com', '--id', 'gid://shopify/BulkOperation/456'],
47+
import.meta.url,
48+
)
49+
50+
expect(storeGetBulkOperationStatus).toHaveBeenCalledWith({
51+
storeFqdn: 'test-store.myshopify.com',
52+
operationId: 'gid://shopify/BulkOperation/456',
53+
})
54+
})
55+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {
2+
storeGetBulkOperationStatus,
3+
storeListBulkOperations,
4+
} from '../../../services/store-bulk-operation-status.js'
5+
import {normalizeBulkOperationId} from '../../../services/bulk-operations/bulk-operation-status.js'
6+
import {Flags} from '@oclif/core'
7+
import {globalFlags} from '@shopify/cli-kit/node/cli'
8+
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
9+
import BaseCommand from '@shopify/cli-kit/node/base-command'
10+
11+
export default class StoreBulkStatus extends BaseCommand {
12+
static summary = 'Check the status of bulk operations on a store.'
13+
14+
static descriptionWithMarkdown = `Check the status of a specific bulk operation by ID, or list all bulk operations on this store in the last 7 days.
15+
16+
Unlike [\`app bulk status\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status), this command does not require an app to be linked or installed on the target store.
17+
18+
Use [\`store bulk execute\`](https://shopify.dev/docs/api/shopify-cli/store/store-bulk-execute) to start a new bulk operation.`
19+
20+
static description = this.descriptionWithoutMarkdown()
21+
22+
static flags = {
23+
...globalFlags,
24+
id: Flags.string({
25+
description:
26+
'The bulk operation ID (numeric ID or full GID). If not provided, lists all bulk operations on this store in the last 7 days.',
27+
env: 'SHOPIFY_FLAG_ID',
28+
}),
29+
store: Flags.string({
30+
char: 's',
31+
description: 'The myshopify.com domain of the store.',
32+
env: 'SHOPIFY_FLAG_STORE',
33+
parse: async (input) => normalizeStoreFqdn(input),
34+
required: true,
35+
}),
36+
}
37+
38+
async run(): Promise<void> {
39+
const {flags} = await this.parse(StoreBulkStatus)
40+
41+
if (flags.id) {
42+
await storeGetBulkOperationStatus({
43+
storeFqdn: flags.store,
44+
operationId: normalizeBulkOperationId(flags.id),
45+
})
46+
} else {
47+
await storeListBulkOperations({
48+
storeFqdn: flags.store,
49+
})
50+
}
51+
}
52+
}

packages/app/src/cli/flags.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,58 @@ export const storeOperationFlags = {
129129
}),
130130
}
131131

132+
export const storeBulkOperationFlags = {
133+
query: Flags.string({
134+
char: 'q',
135+
description: 'The GraphQL query or mutation to run as a bulk operation.',
136+
env: 'SHOPIFY_FLAG_QUERY',
137+
required: false,
138+
exactlyOne: ['query', 'query-file'],
139+
}),
140+
'query-file': Flags.string({
141+
description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
142+
env: 'SHOPIFY_FLAG_QUERY_FILE',
143+
parse: async (input) => resolvePath(input),
144+
exactlyOne: ['query', 'query-file'],
145+
}),
146+
variables: Flags.string({
147+
char: 'v',
148+
description:
149+
'The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.',
150+
env: 'SHOPIFY_FLAG_VARIABLES',
151+
multiple: true,
152+
exclusive: ['variable-file'],
153+
}),
154+
'variable-file': Flags.string({
155+
description:
156+
"Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.",
157+
env: 'SHOPIFY_FLAG_VARIABLE_FILE',
158+
parse: async (input) => resolvePath(input),
159+
exclusive: ['variables'],
160+
}),
161+
store: Flags.string({
162+
char: 's',
163+
description: 'The myshopify.com domain of the store to execute against.',
164+
env: 'SHOPIFY_FLAG_STORE',
165+
parse: async (input) => normalizeStoreFqdn(input),
166+
required: true,
167+
}),
168+
watch: Flags.boolean({
169+
description: 'Wait for bulk operation results before exiting. Defaults to false.',
170+
env: 'SHOPIFY_FLAG_WATCH',
171+
}),
172+
'output-file': Flags.string({
173+
description:
174+
'The file path where results should be written if --watch is specified. If not specified, results will be written to STDOUT.',
175+
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
176+
dependsOn: ['watch'],
177+
}),
178+
version: Flags.string({
179+
description: 'The API version to use for the bulk operation. If not specified, uses the latest stable version.',
180+
env: 'SHOPIFY_FLAG_VERSION',
181+
}),
182+
}
183+
132184
export const operationFlags = {
133185
query: Flags.string({
134186
char: 'q',

0 commit comments

Comments
 (0)