diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-cancel.ts b/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-cancel.ts new file mode 100644 index 0000000000..a76f341ced --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-cancel.ts @@ -0,0 +1,102 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-redundant-type-constituents */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type BulkOperationCancelMutationVariables = Types.Exact<{ + id: Types.Scalars['ID']['input'] +}> + +export type BulkOperationCancelMutation = { + bulkOperationCancel?: { + bulkOperation?: { + completedAt?: unknown | null + createdAt: unknown + errorCode?: Types.BulkOperationErrorCode | null + fileSize?: unknown | null + id: string + objectCount: unknown + partialDataUrl?: string | null + query: string + rootObjectCount: unknown + status: Types.BulkOperationStatus + type: Types.BulkOperationType + url?: string | null + } | null + userErrors: {field?: string[] | null; message: string}[] + } | null +} + +export const BulkOperationCancel = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: {kind: 'Name', value: 'BulkOperationCancel'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'id'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'bulkOperationCancel'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'id'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'id'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'bulkOperation'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'completedAt'}}, + {kind: 'Field', name: {kind: 'Name', value: 'createdAt'}}, + {kind: 'Field', name: {kind: 'Name', value: 'errorCode'}}, + {kind: 'Field', name: {kind: 'Name', value: 'fileSize'}}, + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'objectCount'}}, + {kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}}, + {kind: 'Field', name: {kind: 'Name', value: 'query'}}, + {kind: 'Field', name: {kind: 'Name', value: 'rootObjectCount'}}, + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: 'type'}}, + {kind: 'Field', name: {kind: 'Name', value: 'url'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'userErrors'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'field'}}, + {kind: 'Field', name: {kind: 'Name', value: 'message'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-cancel.graphql b/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-cancel.graphql new file mode 100644 index 0000000000..2aac9022a3 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-cancel.graphql @@ -0,0 +1,23 @@ +mutation BulkOperationCancel($id: ID!) { + bulkOperationCancel(id: $id) { + bulkOperation { + completedAt + createdAt + errorCode + fileSize + id + objectCount + partialDataUrl + query + rootObjectCount + status + type + url + } + userErrors { + field + message + } + } +} + diff --git a/packages/app/src/cli/commands/app/bulk/cancel.ts b/packages/app/src/cli/commands/app/bulk/cancel.ts new file mode 100644 index 0000000000..2b39e9ef89 --- /dev/null +++ b/packages/app/src/cli/commands/app/bulk/cancel.ts @@ -0,0 +1,47 @@ +import {appFlags} from '../../../flags.js' +import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js' +import {cancelBulkOperation} from '../../../services/bulk-operations/cancel-bulk-operation.js' +import {normalizeBulkOperationId} from '../../../services/bulk-operations/bulk-operation-status.js' +import {Flags} from '@oclif/core' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' + +export default class BulkCancel extends AppLinkedCommand { + static summary = 'Cancel a bulk operation.' + + static description = 'Cancels a running bulk operation by ID.' + + static hidden = true + + static flags = { + ...globalFlags, + ...appFlags, + id: Flags.string({ + description: 'The bulk operation ID to cancel (numeric ID or full GID).', + env: 'SHOPIFY_FLAG_ID', + required: true, + }), + store: Flags.string({ + char: 's', + description: 'The store domain. Must be an existing dev store.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + }), + } + + async run(): Promise { + const {flags} = await this.parse(BulkCancel) + + const {appContextResult, store} = await prepareAppStoreContext(flags) + + await cancelBulkOperation({ + organization: appContextResult.organization, + storeFqdn: store.shopDomain, + operationId: normalizeBulkOperationId(flags.id), + remoteApp: appContextResult.remoteApp, + }) + + return {app: appContextResult.app} + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index a40d683c41..4c8de1f45a 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -1,4 +1,5 @@ import Build from './commands/app/build.js' +import BulkCancel from './commands/app/bulk/cancel.js' import BulkStatus from './commands/app/bulk/status.js' import ConfigLink from './commands/app/config/link.js' import ConfigUse from './commands/app/config/use.js' @@ -39,6 +40,7 @@ import FunctionInfo from './commands/app/function/info.js' */ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlinkedCommand} = { 'app:build': Build, + 'app:bulk:cancel': BulkCancel, 'app:bulk:status': BulkStatus, 'app:deploy': Deploy, 'app:dev': Dev, diff --git a/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.test.ts new file mode 100644 index 0000000000..aae1203e4d --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.test.ts @@ -0,0 +1,164 @@ +import {cancelBulkOperation} from './cancel-bulk-operation.js' +import {createAdminSessionAsApp, formatOperationInfo} from '../graphql/common.js' +import {OrganizationApp, Organization, OrganizationSource} from '../../models/organization.js' +import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {renderInfo, renderError, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' + +vi.mock('../graphql/common.js') +vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('@shopify/cli-kit/node/ui') + +describe('cancelBulkOperation', () => { + const mockOrganization: Organization = { + id: 'test-org-id', + businessName: 'Test Organization', + source: OrganizationSource.BusinessPlatform, + } + + const mockRemoteApp = { + apiKey: 'test-app-client-id', + apiSecretKeys: [{secret: 'test-api-secret'}], + title: 'Test App', + } as OrganizationApp + + const storeFqdn = 'test-store.myshopify.com' + const operationId = 'gid://shopify/BulkOperation/123' + const mockAdminSession = {token: 'test-token', storeFqdn} + + beforeEach(() => { + vi.mocked(createAdminSessionAsApp).mockResolvedValue(mockAdminSession) + vi.mocked(formatOperationInfo).mockReturnValue([ + `Organization: ${mockOrganization.businessName}`, + `App: ${mockRemoteApp.title}`, + `Store: ${storeFqdn}`, + ]) + }) + + afterEach(() => { + mockAndCaptureOutput().clear() + }) + + test('renders initial info message with operation details', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({ + bulkOperationCancel: { + bulkOperation: { + id: operationId, + status: 'CANCELING', + createdAt: '2024-01-01T00:00:00Z', + completedAt: null, + }, + userErrors: [], + }, + }) + + await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp}) + + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({ + headline: 'Canceling bulk operation.', + }), + ) + }) + + test('calls adminRequestDoc with correct parameters', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({ + bulkOperationCancel: { + bulkOperation: { + id: operationId, + status: 'CANCELING', + createdAt: '2024-01-01T00:00:00Z', + completedAt: null, + }, + userErrors: [], + }, + }) + + await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp}) + + expect(adminRequestDoc).toHaveBeenCalledWith({ + query: expect.any(Object), + session: mockAdminSession, + variables: {id: operationId}, + version: '2026-01', + }) + }) + + test.each([ + { + status: 'CANCELING' as const, + renderer: 'renderSuccess', + headline: 'Bulk operation is being cancelled.', + }, + { + status: 'CANCELED' as const, + renderer: 'renderWarning', + headline: 'Bulk operation is already canceled.', + }, + { + status: 'COMPLETED' as const, + renderer: 'renderWarning', + headline: 'Bulk operation is already completed.', + }, + { + status: 'RUNNING' as const, + renderer: 'renderInfo', + headline: 'Bulk operation in progress', + }, + ])('renders $renderer for $status status', async ({status, renderer, headline}) => { + vi.mocked(adminRequestDoc).mockResolvedValue({ + bulkOperationCancel: { + bulkOperation: { + id: operationId, + status, + createdAt: '2024-01-01T00:00:00Z', + completedAt: status === 'CANCELING' || status === 'RUNNING' ? null : '2024-01-01T01:00:00Z', + }, + userErrors: [], + }, + }) + + await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp}) + + const rendererFn = {renderSuccess, renderWarning, renderInfo}[renderer] + expect(rendererFn).toHaveBeenCalledWith( + expect.objectContaining({ + headline: expect.stringContaining(headline), + }), + ) + }) + + test('renders user errors when present', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({ + bulkOperationCancel: { + bulkOperation: null, + userErrors: [{field: ['id'], message: 'Operation not found'}], + }, + }) + + await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp}) + + expect(renderError).toHaveBeenCalledWith({ + headline: 'Bulk operation cancellation errors.', + body: 'id: Operation not found', + }) + }) + + test('renders error when no operation is returned and no user errors', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({ + bulkOperationCancel: { + bulkOperation: null, + userErrors: [], + }, + }) + + await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp}) + + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({ + headline: 'Bulk operation not found or could not be canceled.', + }), + ) + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts new file mode 100644 index 0000000000..dab844a03f --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts @@ -0,0 +1,76 @@ +import {renderBulkOperationUserErrors, formatBulkOperationCancellationResult} from './format-bulk-operation-status.js' +import { + BulkOperationCancel, + BulkOperationCancelMutation, + BulkOperationCancelMutationVariables, +} from '../../api/graphql/bulk-operations/generated/bulk-operation-cancel.js' +import {formatOperationInfo, createAdminSessionAsApp} from '../graphql/common.js' +import {OrganizationApp, Organization} from '../../models/organization.js' +import {renderInfo, renderError, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' + +const API_VERSION = '2026-01' + +interface CancelBulkOperationOptions { + organization: Organization + storeFqdn: string + operationId: string + remoteApp: OrganizationApp +} + +export async function cancelBulkOperation(options: CancelBulkOperationOptions): Promise { + const {organization, storeFqdn, operationId, remoteApp} = options + + renderInfo({ + headline: 'Canceling bulk operation.', + body: [ + { + list: { + items: [`ID: ${operationId}`, ...formatOperationInfo({organization, remoteApp, storeFqdn})], + }, + }, + ], + }) + + const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) + + const response = await adminRequestDoc({ + query: BulkOperationCancel, + session: adminSession, + variables: {id: operationId}, + version: API_VERSION, + }) + + if (response.bulkOperationCancel?.userErrors?.length) { + renderBulkOperationUserErrors(response.bulkOperationCancel.userErrors, 'Bulk operation cancellation errors.') + return + } + + const operation = response.bulkOperationCancel?.bulkOperation + if (operation) { + const result = formatBulkOperationCancellationResult(operation) + const renderOptions = { + headline: result.headline, + ...(result.body && {body: result.body}), + ...(result.customSections && {customSections: result.customSections}), + } + + switch (result.renderType) { + case 'success': + renderSuccess(renderOptions) + break + case 'warning': + renderWarning(renderOptions) + break + case 'info': + renderInfo(renderOptions) + break + } + } else { + renderError({ + headline: 'Bulk operation not found or could not be canceled.', + body: outputContent`ID: ${outputToken.yellow(operationId)}`.value, + }) + } +} diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts index 06c5d7ff4c..b903992ec4 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts @@ -89,15 +89,13 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr : await runBulkOperationQuery({adminSession, query, version}) if (bulkOperationResponse?.userErrors?.length) { - const errorMessages = bulkOperationResponse.userErrors.map( - (error: {field?: string[] | null; message: string}) => - `${error.field ? `${error.field.join('.')}: ` : ''}${error.message}`, - ) renderError({ headline: 'Error creating bulk operation.', body: { list: { - items: errorMessages, + items: bulkOperationResponse.userErrors.map((error) => + error.field ? `${error.field.join('.')}: ${error.message}` : error.message, + ), }, }, }) diff --git a/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.test.ts b/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.test.ts index 59e19d9b82..7c1a8a8736 100644 --- a/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.test.ts +++ b/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.test.ts @@ -1,6 +1,11 @@ -import {formatBulkOperationStatus} from './format-bulk-operation-status.js' +import { + formatBulkOperationStatus, + renderBulkOperationUserErrors, + formatBulkOperationCancellationResult, +} from './format-bulk-operation-status.js' import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' -import {describe, test, expect} from 'vitest' +import {describe, test, expect, afterEach} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' type BulkOperation = NonNullable @@ -19,6 +24,10 @@ function createMockOperation(overrides: Partial = {}): BulkOperat } } +afterEach(() => { + mockAndCaptureOutput().clear() +}) + describe('formatBulkOperationStatus', () => { test('formats RUNNING status for query with object count', () => { const result = formatBulkOperationStatus(createMockOperation({status: 'RUNNING', type: 'QUERY', objectCount: '42'})) @@ -90,3 +99,141 @@ describe('formatBulkOperationStatus', () => { expect(result.value).toBe('Bulk operation status: UNKNOWN_STATUS') }) }) + +describe('renderBulkOperationUserErrors', () => { + test('renders user errors with field paths', () => { + const userErrors = [ + {field: ['input', 'id'], message: 'Invalid ID format'}, + {field: ['variables'], message: 'Variables are required'}, + ] + + const output = mockAndCaptureOutput() + renderBulkOperationUserErrors(userErrors, 'Test errors') + + expect(output.output()).toContain('Test errors') + expect(output.output()).toContain('input.id: Invalid ID format') + expect(output.output()).toContain('variables: Variables are required') + }) + + test('renders user errors without field paths as "unknown"', () => { + const userErrors = [{field: null, message: 'Something went wrong'}] + + const output = mockAndCaptureOutput() + renderBulkOperationUserErrors(userErrors, 'General errors') + + expect(output.output()).toContain('General errors') + expect(output.output()).toContain('unknown: Something went wrong') + }) + + test('renders multiple user errors', () => { + const userErrors = [ + {field: ['field1'], message: 'Error 1'}, + {field: ['field2'], message: 'Error 2'}, + {field: null, message: 'Error 3'}, + ] + + const output = mockAndCaptureOutput() + renderBulkOperationUserErrors(userErrors, 'Multiple errors') + + expect(output.output()).toContain('field1: Error 1') + expect(output.output()).toContain('field2: Error 2') + expect(output.output()).toContain('unknown: Error 3') + }) +}) + +describe('formatBulkOperationCancellationResult', () => { + test('formats CANCELING status with success render type and status command', () => { + const operation = createMockOperation({ + id: 'gid://shopify/BulkOperation/6578182226092', + status: 'CANCELING', + }) + const result = formatBulkOperationCancellationResult(operation) + + expect(result.headline).toBe('Bulk operation is being cancelled.') + expect(result.body).toEqual([ + 'This may take a few moments. Check the status with:\n', + {command: 'shopify app bulk status --id=6578182226092'}, + ]) + expect(result.customSections).toBeUndefined() + expect(result.renderType).toBe('success') + }) + + test.each([ + { + status: 'CANCELED' as const, + headline: 'Bulk operation is already canceled.', + body: "This operation has already finished and can't be canceled.", + renderType: 'warning', + hasCustomSections: true, + }, + { + status: 'COMPLETED' as const, + headline: 'Bulk operation is already completed.', + body: "This operation has already finished and can't be canceled.", + renderType: 'warning', + hasCustomSections: true, + }, + { + status: 'FAILED' as const, + headline: 'Bulk operation is already failed.', + body: "This operation has already finished and can't be canceled.", + renderType: 'warning', + hasCustomSections: true, + }, + { + status: 'RUNNING' as const, + headline: 'Bulk operation in progress', + body: undefined, + renderType: 'info', + hasCustomSections: false, + }, + ])( + 'formats $status status with $renderType render type', + ({status, headline, body, renderType, hasCustomSections}) => { + const operation = createMockOperation({status}) + const result = formatBulkOperationCancellationResult(operation) + + expect(result.headline).toContain(headline) + expect(result.body).toBe(body) + expect(result.renderType).toBe(renderType) + if (hasCustomSections) { + expect(result.customSections).toBeDefined() + } else { + expect(result.customSections).toBeUndefined() + } + }, + ) + + test('includes operation details in custom sections for finished operations', () => { + const operation = createMockOperation({ + id: 'gid://shopify/BulkOperation/999', + status: 'CANCELED', + createdAt: '2024-01-01T00:00:00Z', + }) + const result = formatBulkOperationCancellationResult(operation) + + const items = result.customSections?.[0]?.body[0]?.list.items ?? [] + expect(items.some((item) => item.includes('gid://shopify/BulkOperation/999'))).toBe(true) + expect(items.some((item) => item.includes('CANCELED'))).toBe(true) + expect(items.some((item) => item.includes('Created at'))).toBe(true) + }) + + test('includes completedAt when operation is finished', () => { + const operation = createMockOperation({ + status: 'CANCELED', + completedAt: '2024-01-01T01:00:00Z', + }) + const result = formatBulkOperationCancellationResult(operation) + + const items = result.customSections?.[0]?.body[0]?.list.items ?? [] + expect(items.some((item) => item.includes('Completed at'))).toBe(true) + }) + + test('does not include completedAt when operation has no completedAt', () => { + const operation = createMockOperation({status: 'CANCELED', completedAt: null}) + const result = formatBulkOperationCancellationResult(operation) + + const items = result.customSections?.[0]?.body[0]?.list.items ?? [] + expect(items.some((item) => item.includes('Completed at'))).toBe(false) + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.ts b/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.ts index b7c997cd71..906ab76405 100644 --- a/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.ts +++ b/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.ts @@ -1,5 +1,7 @@ +import {extractBulkOperationId} from './bulk-operation-status.js' import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' import {outputContent, outputToken, TokenizedString} from '@shopify/cli-kit/node/output' +import {renderError, TokenItem} from '@shopify/cli-kit/node/ui' export function formatBulkOperationStatus( operation: NonNullable, @@ -31,3 +33,67 @@ export function formatBulkOperationStatus( return outputContent`Bulk operation status: ${operation.status}` } } + +interface UserError { + field?: string[] | null + message: string +} + +export function renderBulkOperationUserErrors(userErrors: UserError[], headline: string): void { + const errorMessages = userErrors + .map((error) => outputContent`${error.field?.join('.') ?? 'unknown'}: ${error.message}`.value) + .join('\n') + + renderError({ + headline, + body: errorMessages, + }) +} + +interface BulkOperationCancellationResult { + headline: string + body?: TokenItem + customSections?: {body: {list: {items: string[]}}[]}[] + renderType: 'success' | 'warning' | 'info' +} + +export function formatBulkOperationCancellationResult( + operation: NonNullable, +): BulkOperationCancellationResult { + const headline = formatBulkOperationStatus(operation).value + + switch (operation.status) { + case 'CANCELING': + return { + headline: 'Bulk operation is being cancelled.', + body: [ + 'This may take a few moments. Check the status with:\n', + {command: `shopify app bulk status --id=${extractBulkOperationId(operation.id)}`}, + ], + renderType: 'success', + } + case 'CANCELED': + case 'COMPLETED': + case 'FAILED': { + const items = [ + outputContent`ID: ${outputToken.cyan(operation.id)}`.value, + outputContent`Status: ${outputToken.yellow(operation.status)}`.value, + outputContent`Created at: ${outputToken.gray(String(operation.createdAt))}`.value, + ...(operation.completedAt + ? [outputContent`Completed at: ${outputToken.gray(String(operation.completedAt))}`.value] + : []), + ] + return { + headline: outputContent`Bulk operation is already ${operation.status.toLowerCase()}.`.value, + body: outputContent`This operation has already finished and can't be canceled.`.value, + customSections: [{body: [{list: {items}}]}], + renderType: 'warning', + } + } + default: + return { + headline, + renderType: 'info', + } + } +} diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 799d2dc3de..372b54675d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -86,6 +86,102 @@ "strict": true, "summary": "Build the app, including extensions." }, + "app:bulk:cancel": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Cancels a running bulk operation by ID.", + "flags": { + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "id": { + "description": "The bulk operation ID to cancel (numeric ID or full GID).", + "env": "SHOPIFY_FLAG_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "id", + "required": true, + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "store": { + "char": "s", + "description": "The store domain. Must be an existing dev store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "app:bulk:cancel", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Cancel a bulk operation." + }, "app:bulk:execute": { "aliases": [ ],