diff --git a/packages/app/src/cli/api/graphql/admin-bulk-operations.ts b/packages/app/src/cli/api/graphql/admin-bulk-operations.ts new file mode 100644 index 00000000000..f4c0706a8f9 --- /dev/null +++ b/packages/app/src/cli/api/graphql/admin-bulk-operations.ts @@ -0,0 +1,44 @@ +import {gql} from 'graphql-request' + +// eslint-disable-next-line @shopify/cli/no-inline-graphql +export const BulkOperationRunQuery = gql` + mutation BulkOperationRunQuery($query: String!) { + bulkOperationRunQuery(query: $query) { + bulkOperation { + id + status + errorCode + createdAt + objectCount + fileSize + url + } + userErrors { + field + message + } + } + } +` + +export interface BulkOperation { + id: string + status: string + errorCode: string | null + createdAt: string + objectCount: string + fileSize: string + url: string | null +} + +export interface BulkOperationError { + field: string[] | null + message: string +} + +export interface BulkOperationRunQuerySchema { + bulkOperationRunQuery: { + bulkOperation: BulkOperation | null + userErrors: BulkOperationError[] + } +} diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index a3dba4e5186..5bd5b469b12 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -1,29 +1,86 @@ -import {appFlags} from '../../flags.js' -import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../utilities/app-unlinked-command.js' -import {AppInterface} from '../../models/app/app.js' +import {appFlags, bulkOperationFlags} from '../../flags.js' +import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' +import {linkedAppContext} from '../../services/app-context.js' +import {storeContext} from '../../services/store-context.js' +import {runBulkOperationQuery} from '../../services/bulk-operation-run-query.js' import {globalFlags} from '@shopify/cli-kit/node/cli' -import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {renderSuccess, renderInfo, renderWarning} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' -export default class Execute extends AppUnlinkedCommand { - static summary = 'Execute app operations.' +export default class Execute extends AppLinkedCommand { + static summary = 'Execute bulk operations.' - static description = 'Execute app operations.' + static description = 'Execute bulk operations against the Shopify Admin API.' static hidden = true static flags = { ...globalFlags, ...appFlags, + ...bulkOperationFlags, } - async run(): Promise { - await this.parse(Execute) + async run(): Promise { + const {flags} = await this.parse(Execute) - renderSuccess({ - headline: 'Execute command ran successfully!', - body: 'Placeholder command. Add execution logic here.', + const appContextResult = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, }) - return {app: undefined as unknown as AppInterface} + const store = await storeContext({ + appContextResult, + storeFqdn: flags.store, + forceReselectStore: flags.reset, + }) + + renderInfo({ + headline: 'Starting bulk operation.', + body: `App: ${appContextResult.app.name}\nStore: ${store.shopDomain}`, + }) + + const {result, errors} = await runBulkOperationQuery({ + storeFqdn: store.shopDomain, + query: flags.query, + }) + + if (errors?.length) { + const errorMessages = errors.map((error) => `${error.field?.join('.') ?? 'unknown'}: ${error.message}`).join('\n') + renderWarning({ + headline: 'Bulk operation errors.', + body: errorMessages, + }) + return {app: appContextResult.app} + } + + if (result) { + const infoSections = [ + { + title: 'Bulk Operation Created', + body: [ + { + list: { + items: [ + outputContent`ID: ${outputToken.cyan(result.id)}`.value, + outputContent`Status: ${outputToken.yellow(result.status)}`.value, + outputContent`Created: ${outputToken.gray(result.createdAt)}`.value, + ], + }, + }, + ], + }, + ] + + renderInfo({customSections: infoSections}) + + renderSuccess({ + headline: 'Bulk operation started successfully!', + body: 'Congrats!', + }) + } + + return {app: appContextResult.app} } } diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index bf6944a81ea..6da20e180fb 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -1,5 +1,6 @@ import {Flags} from '@oclif/core' import {resolvePath, cwd} from '@shopify/cli-kit/node/path' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' /** * An object that contains the flags that @@ -33,3 +34,18 @@ export const appFlags = { exclusive: ['config'], }), } + +export const bulkOperationFlags = { + query: Flags.string({ + char: 'q', + description: 'The GraphQL query, as a string.', + env: 'SHOPIFY_FLAG_QUERY', + required: true, + }), + store: Flags.string({ + char: 's', + description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + }), +} diff --git a/packages/app/src/cli/services/bulk-operation-run-query.ts b/packages/app/src/cli/services/bulk-operation-run-query.ts new file mode 100644 index 00000000000..8002b832f54 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operation-run-query.ts @@ -0,0 +1,40 @@ +import { + BulkOperationRunQuery, + BulkOperation, + BulkOperationError, + BulkOperationRunQuerySchema, +} from '../api/graphql/admin-bulk-operations.js' +import {adminRequest} from '@shopify/cli-kit/node/api/admin' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' + +interface BulkOperationRunQueryOptions { + storeFqdn: string + query: string +} + +/** + * Executes a bulk operation query against the Shopify Admin API. + * The operation runs asynchronously in the background. + */ +export async function runBulkOperationQuery( + options: BulkOperationRunQueryOptions, +): Promise<{result?: BulkOperation; errors?: BulkOperationError[]}> { + const {storeFqdn, query} = options + const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + const response = await adminRequest(BulkOperationRunQuery, adminSession, {query}) + + if (response.bulkOperationRunQuery.userErrors.length > 0) { + return { + errors: response.bulkOperationRunQuery.userErrors, + } + } + + const bulkOperation = response.bulkOperationRunQuery.bulkOperation + if (bulkOperation) { + return {result: bulkOperation} + } + + return { + errors: [{field: null, message: 'No bulk operation was created'}], + } +} diff --git a/packages/app/src/cli/services/bulk-operations-run-query.test.ts b/packages/app/src/cli/services/bulk-operations-run-query.test.ts new file mode 100644 index 00000000000..4d2bf4b7c8a --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations-run-query.test.ts @@ -0,0 +1,42 @@ +import {runBulkOperationQuery} from './bulk-operation-run-query.js' +import {adminRequest} from '@shopify/cli-kit/node/api/admin' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('@shopify/cli-kit/node/session') + +describe('runBulkOperationQuery', () => { + const mockSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} + const successfulBulkOperation = { + id: 'gid://shopify/BulkOperation/123', + status: 'CREATED', + errorCode: null, + createdAt: '2024-01-01T00:00:00Z', + objectCount: '0', + fileSize: '0', + url: null, + } + const mockSuccessResponse = { + bulkOperationRunQuery: { + bulkOperation: successfulBulkOperation, + userErrors: [], + }, + } + + beforeEach(() => { + vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockSession) + }) + + test('returns a bulk operation when request succeeds', async () => { + vi.mocked(adminRequest).mockResolvedValue(mockSuccessResponse) + + const bulkOperationResult = await runBulkOperationQuery({ + storeFqdn: 'test-store.myshopify.com', + query: 'query { products { edges { node { id } } } }', + }) + + expect(bulkOperationResult.result).toEqual(successfulBulkOperation) + expect(bulkOperationResult.errors).toBeUndefined() + }) +}) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9ae31dcf8fe..febd61f6e87 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -814,7 +814,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Execute app operations.", + "description": "Execute bulk operations against the Shopify Admin API.", "flags": { "client-id": { "description": "The Client ID of your app.", @@ -855,6 +855,16 @@ "noCacheDefault": true, "type": "option" }, + "query": { + "char": "q", + "description": "The GraphQL query, as a string.", + "env": "SHOPIFY_FLAG_QUERY", + "hasDynamicHelp": false, + "multiple": false, + "name": "query", + "required": true, + "type": "option" + }, "reset": { "allowNo": false, "description": "Reset all your settings.", @@ -866,6 +876,15 @@ "name": "reset", "type": "boolean" }, + "store": { + "char": "s", + "description": "Store URL. Must be an existing development or Shopify Plus sandbox store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "type": "option" + }, "verbose": { "allowNo": false, "description": "Increase the verbosity of the output.", @@ -884,7 +903,7 @@ "pluginName": "@shopify/cli", "pluginType": "core", "strict": true, - "summary": "Execute app operations." + "summary": "Execute bulk operations." }, "app:function:build": { "aliases": [