Skip to content

Commit e01ab55

Browse files
committed
minimal foundations of the BulkOps CLI that future PRs will build upon
1 parent c356b4e commit e01ab55

File tree

6 files changed

+233
-15
lines changed

6 files changed

+233
-15
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {gql} from 'graphql-request'
2+
3+
// eslint-disable-next-line @shopify/cli/no-inline-graphql
4+
export const BulkOperationRunQuery = gql`
5+
mutation BulkOperationRunQuery($query: String!) {
6+
bulkOperationRunQuery(query: $query) {
7+
bulkOperation {
8+
id
9+
status
10+
errorCode
11+
createdAt
12+
objectCount
13+
fileSize
14+
url
15+
}
16+
userErrors {
17+
field
18+
message
19+
}
20+
}
21+
}
22+
`
23+
24+
export interface BulkOperation {
25+
id: string
26+
status: string
27+
errorCode: string | null
28+
createdAt: string
29+
objectCount: string
30+
fileSize: string
31+
url: string | null
32+
}
33+
34+
export interface BulkOperationError {
35+
field: string[] | null
36+
message: string
37+
}
38+
39+
export interface BulkOperationRunQuerySchema {
40+
bulkOperationRunQuery: {
41+
bulkOperation: BulkOperation | null
42+
userErrors: BulkOperationError[]
43+
}
44+
}
Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,86 @@
1-
import {appFlags} from '../../flags.js'
2-
import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../utilities/app-unlinked-command.js'
3-
import {AppInterface} from '../../models/app/app.js'
1+
import {appFlags, bulkOperationFlags} from '../../flags.js'
2+
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js'
3+
import {linkedAppContext} from '../../services/app-context.js'
4+
import {storeContext} from '../../services/store-context.js'
5+
import {runBulkOperationQuery} from '../../services/bulk-operation-run-query.js'
46
import {globalFlags} from '@shopify/cli-kit/node/cli'
5-
import {renderSuccess} from '@shopify/cli-kit/node/ui'
7+
import {renderSuccess, renderInfo, renderWarning} from '@shopify/cli-kit/node/ui'
8+
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
69

7-
export default class Execute extends AppUnlinkedCommand {
8-
static summary = 'Execute app operations.'
10+
export default class Execute extends AppLinkedCommand {
11+
static summary = 'Execute bulk operations.'
912

10-
static description = 'Execute app operations.'
13+
static description = 'Execute bulk operations against the Shopify Admin API.'
1114

1215
static hidden = true
1316

1417
static flags = {
1518
...globalFlags,
1619
...appFlags,
20+
...bulkOperationFlags,
1721
}
1822

19-
async run(): Promise<AppUnlinkedCommandOutput> {
20-
await this.parse(Execute)
23+
async run(): Promise<AppLinkedCommandOutput> {
24+
const {flags} = await this.parse(Execute)
2125

22-
renderSuccess({
23-
headline: 'Execute command ran successfully!',
24-
body: 'Placeholder command. Add execution logic here.',
26+
const appContextResult = await linkedAppContext({
27+
directory: flags.path,
28+
clientId: flags['client-id'],
29+
forceRelink: flags.reset,
30+
userProvidedConfigName: flags.config,
2531
})
2632

27-
return {app: undefined as unknown as AppInterface}
33+
const store = await storeContext({
34+
appContextResult,
35+
storeFqdn: flags.store,
36+
forceReselectStore: flags.reset,
37+
})
38+
39+
renderInfo({
40+
headline: 'Starting bulk operation.',
41+
body: `App: ${appContextResult.app.name}\nStore: ${store.shopDomain}`,
42+
})
43+
44+
const {result, errors} = await runBulkOperationQuery({
45+
storeFqdn: store.shopDomain,
46+
query: flags.query,
47+
})
48+
49+
if (errors?.length) {
50+
const errorMessages = errors.map((error) => `${error.field?.join('.') ?? 'unknown'}: ${error.message}`).join('\n')
51+
renderWarning({
52+
headline: 'Bulk operation errors.',
53+
body: errorMessages,
54+
})
55+
return {app: appContextResult.app}
56+
}
57+
58+
if (result) {
59+
const infoSections = [
60+
{
61+
title: 'Bulk Operation Created',
62+
body: [
63+
{
64+
list: {
65+
items: [
66+
outputContent`ID: ${outputToken.cyan(result.id)}`.value,
67+
outputContent`Status: ${outputToken.yellow(result.status)}`.value,
68+
outputContent`Created: ${outputToken.gray(result.createdAt)}`.value,
69+
],
70+
},
71+
},
72+
],
73+
},
74+
]
75+
76+
renderInfo({customSections: infoSections})
77+
78+
renderSuccess({
79+
headline: 'Bulk operation started successfully!',
80+
body: 'Congrats!',
81+
})
82+
}
83+
84+
return {app: appContextResult.app}
2885
}
2986
}

packages/app/src/cli/flags.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {Flags} from '@oclif/core'
22
import {resolvePath, cwd} from '@shopify/cli-kit/node/path'
3+
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
34

45
/**
56
* An object that contains the flags that
@@ -33,3 +34,18 @@ export const appFlags = {
3334
exclusive: ['config'],
3435
}),
3536
}
37+
38+
export const bulkOperationFlags = {
39+
query: Flags.string({
40+
char: 'q',
41+
description: 'The GraphQL query, as a string.',
42+
env: 'SHOPIFY_FLAG_QUERY',
43+
required: true,
44+
}),
45+
store: Flags.string({
46+
char: 's',
47+
description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.',
48+
env: 'SHOPIFY_FLAG_STORE',
49+
parse: async (input) => normalizeStoreFqdn(input),
50+
}),
51+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {
2+
BulkOperationRunQuery,
3+
BulkOperation,
4+
BulkOperationError,
5+
BulkOperationRunQuerySchema,
6+
} from '../api/graphql/admin-bulk-operations.js'
7+
import {adminRequest} from '@shopify/cli-kit/node/api/admin'
8+
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
9+
10+
interface BulkOperationRunQueryOptions {
11+
storeFqdn: string
12+
query: string
13+
}
14+
15+
/**
16+
* Executes a bulk operation query against the Shopify Admin API.
17+
* The operation runs asynchronously in the background.
18+
*/
19+
export async function runBulkOperationQuery(
20+
options: BulkOperationRunQueryOptions,
21+
): Promise<{result?: BulkOperation; errors?: BulkOperationError[]}> {
22+
const {storeFqdn, query} = options
23+
const adminSession = await ensureAuthenticatedAdmin(storeFqdn)
24+
const response = await adminRequest<BulkOperationRunQuerySchema>(BulkOperationRunQuery, adminSession, {query})
25+
26+
if (response.bulkOperationRunQuery.userErrors.length > 0) {
27+
return {
28+
errors: response.bulkOperationRunQuery.userErrors,
29+
}
30+
}
31+
32+
const bulkOperation = response.bulkOperationRunQuery.bulkOperation
33+
if (bulkOperation) {
34+
return {result: bulkOperation}
35+
}
36+
37+
return {
38+
errors: [{field: null, message: 'No bulk operation was created'}],
39+
}
40+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {runBulkOperationQuery} from './bulk-operation-run-query.js'
2+
import {adminRequest} from '@shopify/cli-kit/node/api/admin'
3+
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
4+
import {describe, test, expect, vi, beforeEach} from 'vitest'
5+
6+
vi.mock('@shopify/cli-kit/node/api/admin')
7+
vi.mock('@shopify/cli-kit/node/session')
8+
9+
describe('runBulkOperationQuery', () => {
10+
const mockSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'}
11+
const successfulBulkOperation = {
12+
id: 'gid://shopify/BulkOperation/123',
13+
status: 'CREATED',
14+
errorCode: null,
15+
createdAt: '2024-01-01T00:00:00Z',
16+
objectCount: '0',
17+
fileSize: '0',
18+
url: null,
19+
}
20+
const mockSuccessResponse = {
21+
bulkOperationRunQuery: {
22+
bulkOperation: successfulBulkOperation,
23+
userErrors: [],
24+
},
25+
}
26+
27+
beforeEach(() => {
28+
vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockSession)
29+
})
30+
31+
test('returns a bulk operation when request succeeds', async () => {
32+
vi.mocked(adminRequest).mockResolvedValue(mockSuccessResponse)
33+
34+
const bulkOperationResult = await runBulkOperationQuery({
35+
storeFqdn: 'test-store.myshopify.com',
36+
query: 'query { products { edges { node { id } } } }',
37+
})
38+
39+
expect(bulkOperationResult.result).toEqual(successfulBulkOperation)
40+
expect(bulkOperationResult.errors).toBeUndefined()
41+
})
42+
})

packages/cli/oclif.manifest.json

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -814,7 +814,7 @@
814814
"args": {
815815
},
816816
"customPluginName": "@shopify/app",
817-
"description": "Execute app operations.",
817+
"description": "Execute bulk operations against the Shopify Admin API.",
818818
"flags": {
819819
"client-id": {
820820
"description": "The Client ID of your app.",
@@ -855,6 +855,16 @@
855855
"noCacheDefault": true,
856856
"type": "option"
857857
},
858+
"query": {
859+
"char": "q",
860+
"description": "The GraphQL query, as a string.",
861+
"env": "SHOPIFY_FLAG_QUERY",
862+
"hasDynamicHelp": false,
863+
"multiple": false,
864+
"name": "query",
865+
"required": true,
866+
"type": "option"
867+
},
858868
"reset": {
859869
"allowNo": false,
860870
"description": "Reset all your settings.",
@@ -866,6 +876,15 @@
866876
"name": "reset",
867877
"type": "boolean"
868878
},
879+
"store": {
880+
"char": "s",
881+
"description": "Store URL. Must be an existing development or Shopify Plus sandbox store.",
882+
"env": "SHOPIFY_FLAG_STORE",
883+
"hasDynamicHelp": false,
884+
"multiple": false,
885+
"name": "store",
886+
"type": "option"
887+
},
869888
"verbose": {
870889
"allowNo": false,
871890
"description": "Increase the verbosity of the output.",
@@ -884,7 +903,7 @@
884903
"pluginName": "@shopify/cli",
885904
"pluginType": "core",
886905
"strict": true,
887-
"summary": "Execute app operations."
906+
"summary": "Execute bulk operations."
888907
},
889908
"app:function:build": {
890909
"aliases": [

0 commit comments

Comments
 (0)