diff --git a/.changeset/app-bulk-cancel-json.md b/.changeset/app-bulk-cancel-json.md new file mode 100644 index 00000000000..6fe347d18a2 --- /dev/null +++ b/.changeset/app-bulk-cancel-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output to app bulk cancel. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 3c454479b9a..194b4e02be4 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -196,6 +196,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-bulk-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-bulk-cancel.interface.ts", "syntaxKind": "PropertySignature", @@ -206,7 +215,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface appbulkcancel {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * The bulk operation ID to cancel (numeric ID or full GID).\n * @environment SHOPIFY_FLAG_ID\n */\n '--id ': string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The store domain. Must be an existing dev store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appbulkcancel {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * The bulk operation ID to cancel (numeric ID or full GID).\n * @environment SHOPIFY_FLAG_ID\n */\n '--id ': string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The store domain. Must be an existing dev store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appbulkexecute": { diff --git a/packages/app/src/cli/commands/app/bulk/cancel.test.ts b/packages/app/src/cli/commands/app/bulk/cancel.test.ts index cecdfc3ca40..ae8d85883e8 100644 --- a/packages/app/src/cli/commands/app/bulk/cancel.test.ts +++ b/packages/app/src/cli/commands/app/bulk/cancel.test.ts @@ -1,4 +1,5 @@ import BulkCancel from './cancel.js' +import {renderCancelBulkOperationResult} from '../../../services/bulk-operations/cancel-result.js' import {cancelBulkOperation} from '../../../services/bulk-operations/cancel-bulk-operation.js' import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js' import { @@ -12,6 +13,8 @@ import {describe, expect, test, vi, beforeEach} from 'vitest' vi.mock('../../../services/bulk-operations/cancel-bulk-operation.js') vi.mock('../../../utilities/execute-command-helpers.js') +vi.mock('../../../services/bulk-operations/cancel-result.js') +vi.mock('../../../services/bulk-operations/progress.js') describe('app bulk cancel command', () => { const app = testAppLinked() @@ -31,7 +34,7 @@ describe('app bulk cancel command', () => { }, store, }) - vi.mocked(cancelBulkOperation).mockResolvedValue() + vi.mocked(cancelBulkOperation).mockResolvedValue({operation: null, userErrors: []}) }) test('prepares app/store context and cancels bulk operation', async () => { @@ -46,8 +49,12 @@ describe('app bulk cancel command', () => { store: 'shop.myshopify.com', }), ) + expect(renderCancelBulkOperationResult).toHaveBeenCalledWith( + {operation: null, userErrors: []}, + 'gid://shopify/BulkOperation/12345', + 'text', + ) expect(cancelBulkOperation).toHaveBeenCalledWith({ - organization: expect.any(Object), storeFqdn: 'shop.myshopify.com', operationId: 'gid://shopify/BulkOperation/12345', remoteApp, diff --git a/packages/app/src/cli/commands/app/bulk/cancel.ts b/packages/app/src/cli/commands/app/bulk/cancel.ts index 7cc9ee43687..466359a44ea 100644 --- a/packages/app/src/cli/commands/app/bulk/cancel.ts +++ b/packages/app/src/cli/commands/app/bulk/cancel.ts @@ -2,18 +2,24 @@ 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 {cancelBulkOperationJsonOutputSchema} from '../../../services/bulk-operations/types.js' +import {renderCancelBulkOperationResult} from '../../../services/bulk-operations/cancel-result.js' +import {logBulkOperationStart} from '../../../services/bulk-operations/progress.js' import {Flags} from '@oclif/core' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeBulkOperationId} from '@shopify/cli-kit/node/api/bulk-operations' 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 descriptionWithMarkdown = 'Cancels a running bulk operation by ID.' + + static description = this.descriptionForHelp() static flags = { ...globalFlags, + ...jsonFlag, ...appFlags, id: Flags.string({ description: 'The bulk operation ID to cancel (numeric ID or full GID).', @@ -28,17 +34,32 @@ export default class BulkCancel extends AppLinkedCommand { }), } + static get jsonOutputSchema() { + return cancelBulkOperationJsonOutputSchema + } + async run(): Promise { const {flags} = await this.parse(BulkCancel) const {appContextResult, store} = await prepareAppStoreContext(flags) - - await cancelBulkOperation({ - organization: appContextResult.organization, + const format = flags.json ? 'json' : 'text' + const operationId = normalizeBulkOperationId(flags.id) + logBulkOperationStart( + 'Canceling bulk operation.', + { + organization: appContextResult.organization, + remoteApp: appContextResult.remoteApp, + storeFqdn: store.shopDomain, + operationId, + }, + format, + ) + const result = await cancelBulkOperation({ storeFqdn: store.shopDomain, - operationId: normalizeBulkOperationId(flags.id), + operationId, remoteApp: appContextResult.remoteApp, }) + renderCancelBulkOperationResult(result, operationId, format) return {app: appContextResult.app} } diff --git a/packages/app/src/cli/commands/app/bulk/output.test.ts b/packages/app/src/cli/commands/app/bulk/output.test.ts index d3bd76637e6..ee930dc0dbd 100644 --- a/packages/app/src/cli/commands/app/bulk/output.test.ts +++ b/packages/app/src/cli/commands/app/bulk/output.test.ts @@ -1,23 +1,33 @@ import BulkStatus from './status.js' +import BulkCancel from './cancel.js' import { testBulkOperation, testBulkOperationContext, } from '../../../services/bulk-operations/bulk-operation.test-data.js' import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js' -import {resolveApiVersion} from '../../../services/graphql/common.js' -import {bulkOperationStatusJsonOutputSchema} from '../../../services/bulk-operations/types.js' -import {fetchBulkOperationById, fetchRecentBulkOperations} from '@shopify/cli-kit/node/api/bulk-operations' +import {createAdminSessionAsApp, resolveApiVersion} from '../../../services/graphql/common.js' +import { + bulkOperationStatusJsonOutputSchema, + cancelBulkOperationJsonOutputSchema, +} from '../../../services/bulk-operations/types.js' +import { + fetchBulkOperationById, + fetchRecentBulkOperations, + cancelBulkOperationRequest, +} from '@shopify/cli-kit/node/api/bulk-operations' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {AbortError, handler} from '@shopify/cli-kit/node/error' import {Config} from '@oclif/core' -import {expect, test, vi} from 'vitest' +import {afterEach, expect, test, vi} from 'vitest' import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' vi.mock('../../../utilities/execute-command-helpers.js') vi.mock('../../../services/graphql/common.js', async (importOriginal) => ({ ...(await importOriginal()), + createAdminSessionAsApp: vi.fn(), resolveApiVersion: vi.fn(), })) @@ -30,8 +40,16 @@ vi.mock('@shopify/cli-kit/node/api/bulk-operations', async (importOriginal) => ( ...(await importOriginal()), fetchBulkOperationById: vi.fn(), fetchRecentBulkOperations: vi.fn(), + cancelBulkOperationRequest: vi.fn(), })) +const originalExitCode = process.exitCode + +afterEach(() => { + vi.unstubAllEnvs() + process.exitCode = originalExitCode +}) + function bulkOperation(overrides: Partial = {}): BulkOperation { return testBulkOperation({ objectCount: '900719925474099312345', @@ -54,13 +72,12 @@ function expectedOperation() { } } -async function runCommand(Command: typeof BulkStatus, argv: string[]) { +async function runCommand(Command: typeof BulkStatus | typeof BulkCancel, argv: string[]) { const {appContextResult, store} = testBulkOperationContext() - const remoteApp = appContextResult.remoteApp vi.mocked(prepareAppStoreContext).mockResolvedValue({appContextResult, store}) const session = {storeFqdn: store.shopDomain, token: 'token'} - + vi.mocked(createAdminSessionAsApp).mockResolvedValue(session) vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(session) vi.mocked(resolveApiVersion).mockResolvedValue('2026-01') const config = await Config.load() @@ -118,14 +135,59 @@ test('a missing requested operation is distinct from an empty list', async () => operationGid: 'gid://shopify/BulkOperation/123', operation: null, }) + expect(process.exitCode).toBe(originalExitCode) }) }) -test('exposes the status schema, JSON flag, and help', () => { - expect(BulkStatus.jsonOutputSchema).toBe(bulkOperationStatusJsonOutputSchema) - expect(BulkStatus.flags.json).toBeDefined() - expect(BulkStatus.baseFlags).toHaveProperty('json-schema') - expect(BulkStatus.descriptionForHelp()).toContain(bulkOperationStatusJsonOutputSchema.name) +test('cancellation emits one result through the real service, codec, presenter, and writer', async () => { + vi.mocked(cancelBulkOperationRequest).mockResolvedValue({ + bulkOperation: {...bulkOperation(), query: 'query { shop { name } }', rootObjectCount: '2'}, + userErrors: [], + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(BulkCancel, ['--id', '123', '--json']) + expect(JSON.parse(stdout())).toEqual({ + storeDomain: 'shop.myshopify.com', + apiVersion: '2026-01', + status: 'success', + operation: expectedOperation(), + }) + assertDiagnosticEvents(stderr(), 'Canceling bulk operation.') + }) +}) + +test.each([{name: 'cancel', Command: BulkCancel}])( + '$name user errors reach the fatal envelope without a success document', + async ({Command}) => { + const userErrors = [{field: ['id'], message: 'Operation rejected'}] + vi.mocked(cancelBulkOperationRequest).mockResolvedValue({bulkOperation: null, userErrors}) + + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const argv = ['--id', '123'] + try { + await runCommand(Command, [...argv, '--json']) + expect.fail('The command must reject the operation.') + } catch (error) { + if (!(error instanceof AbortError)) throw error + expect(error).toMatchObject({details: {userErrors}}) + expect(stdout()).toBe('') + await handler(error) + } + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', details: {userErrors}}}) + assertDiagnosticEvents(stderr(), 'Canceling bulk operation.') + }) + }, +) + +test.each([ + {name: 'status', Command: BulkStatus, schema: bulkOperationStatusJsonOutputSchema}, + {name: 'cancel', Command: BulkCancel, schema: cancelBulkOperationJsonOutputSchema}, +] as const)('exposes the schema, JSON flag, and help for $name', ({Command, schema}) => { + expect(Command.jsonOutputSchema).toBe(schema) + expect(Command.flags.json).toBeDefined() + expect(Command.baseFlags).toHaveProperty('json-schema') + expect(Command.descriptionForHelp()).toContain(schema.name) }) test.each([ {json: true, noInput: false}, 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 index efee9318fe7..503b391e1cb 100644 --- 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 @@ -1,4 +1,6 @@ -import {cancelBulkOperation} from './cancel-bulk-operation.js' +import {renderCancelBulkOperationResult} from './cancel-result.js' +import {logBulkOperationStart} from './progress.js' +import {cancelBulkOperation as runCancelBulkOperation} 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' @@ -40,6 +42,25 @@ describe('cancelBulkOperation', () => { mockAndCaptureOutput().clear() }) + test('returns the operation and user errors without presenting the result', async () => { + const operation = {id: operationId, status: 'CANCELING', createdAt: '2024-01-01T00:00:00Z', completedAt: null} + const userErrors = [{field: ['id'], message: 'Cannot cancel'}] + vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperationCancel: {bulkOperation: operation, userErrors}}) + const output = mockAndCaptureOutput() + + await expect(runCancelBulkOperation({storeFqdn, operationId, remoteApp: mockRemoteApp})).resolves.toEqual({ + store: storeFqdn, + apiVersion: '2026-01', + operation, + userErrors, + }) + expect(output.output()).toBe('') + expect(renderInfo).not.toHaveBeenCalled() + expect(renderError).not.toHaveBeenCalled() + expect(renderSuccess).not.toHaveBeenCalled() + expect(renderWarning).not.toHaveBeenCalled() + }) + test('renders initial info message with operation details', async () => { vi.mocked(adminRequestDoc).mockResolvedValue({ bulkOperationCancel: { @@ -53,7 +74,12 @@ describe('cancelBulkOperation', () => { }, }) - await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp}) + await cancelAndPresentBulkOperation({ + organization: mockOrganization, + storeFqdn, + operationId, + remoteApp: mockRemoteApp, + }) expect(renderInfo).toHaveBeenCalledWith( expect.objectContaining({ @@ -75,7 +101,12 @@ describe('cancelBulkOperation', () => { }, }) - await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp}) + await cancelAndPresentBulkOperation({ + organization: mockOrganization, + storeFqdn, + operationId, + remoteApp: mockRemoteApp, + }) expect(adminRequestDoc).toHaveBeenCalledWith({ query: expect.any(Object), @@ -119,7 +150,12 @@ describe('cancelBulkOperation', () => { }, }) - await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp}) + await cancelAndPresentBulkOperation({ + organization: mockOrganization, + storeFqdn, + operationId, + remoteApp: mockRemoteApp, + }) const rendererFn = {renderSuccess, renderWarning, renderInfo}[renderer] expect(rendererFn).toHaveBeenCalledWith( @@ -137,7 +173,12 @@ describe('cancelBulkOperation', () => { }, }) - await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp}) + await cancelAndPresentBulkOperation({ + organization: mockOrganization, + storeFqdn, + operationId, + remoteApp: mockRemoteApp, + }) expect(renderError).toHaveBeenCalledWith({ headline: 'Failed to cancel bulk operation.', @@ -153,7 +194,12 @@ describe('cancelBulkOperation', () => { }, }) - await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp}) + await cancelAndPresentBulkOperation({ + organization: mockOrganization, + storeFqdn, + operationId, + remoteApp: mockRemoteApp, + }) expect(renderError).toHaveBeenCalledWith( expect.objectContaining({ @@ -162,3 +208,12 @@ describe('cancelBulkOperation', () => { ) }) }) + +async function cancelAndPresentBulkOperation( + input: Parameters[0] & {organization: Organization}, +) { + logBulkOperationStart('Canceling bulk operation.', input, 'text') + const result = await runCancelBulkOperation(input) + renderCancelBulkOperationResult(result, input.operationId, 'text') + return result +} 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 index f16311971f3..58c9716d0db 100644 --- a/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts @@ -1,76 +1,23 @@ -import {formatOperationInfo, createAdminSessionAsApp} from '../graphql/common.js' -import {OrganizationApp, Organization} from '../../models/organization.js' -import { - cancelBulkOperationRequest, - renderBulkOperationUserErrors, - formatBulkOperationCancellationResult, - extractBulkOperationId, -} from '@shopify/cli-kit/node/api/bulk-operations' -import {renderInfo, renderError, renderSuccess, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui' -import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {createAdminSessionAsApp} from '../graphql/common.js' +import {OrganizationApp} from '../../models/organization.js' +import {BULK_OPERATIONS_MIN_API_VERSION, cancelBulkOperationRequest} from '@shopify/cli-kit/node/api/bulk-operations' +import type {CancelBulkOperationResult} from './types.js' 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})], - }, - }, - ], - }) - +export async function cancelBulkOperation(options: CancelBulkOperationOptions): Promise { + const {storeFqdn, operationId, remoteApp} = options const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) + const response = await cancelBulkOperationRequest({adminSession, operationId}) - const bulkOperationCancel = await cancelBulkOperationRequest({adminSession, operationId}) - - if (bulkOperationCancel?.userErrors?.length) { - renderBulkOperationUserErrors(bulkOperationCancel.userErrors, 'Failed to cancel bulk operation.') - return - } - - const operation = bulkOperationCancel?.bulkOperation - if (operation) { - const result = formatBulkOperationCancellationResult(operation) - // The engine is command-agnostic; this command writes its own "check status" hint. - const body: TokenItem | undefined = - operation.status === 'CANCELING' - ? [ - 'This may take a few moments. Check the status with:\n', - {command: `shopify app bulk status --id=${extractBulkOperationId(operation.id)}`}, - ] - : result.body - const renderOptions = { - headline: result.headline, - ...(body && {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, - }) + return { + store: storeFqdn, + apiVersion: BULK_OPERATIONS_MIN_API_VERSION, + operation: response?.bulkOperation ?? null, + userErrors: response?.userErrors ?? [], } } diff --git a/packages/app/src/cli/services/bulk-operations/cancel-result.ts b/packages/app/src/cli/services/bulk-operations/cancel-result.ts new file mode 100644 index 00000000000..aed2831fc97 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/cancel-result.ts @@ -0,0 +1,74 @@ +import {cancelBulkOperationJsonOutputSchema, type CancelBulkOperationResult} from './types.js' +import {bulkOperationJsonContext, toBulkOperationJson} from './json.js' +import { + renderBulkOperationUserErrors, + formatBulkOperationCancellationResult, + extractBulkOperationId, +} from '@shopify/cli-kit/node/api/bulk-operations' +import {renderInfo, renderError, renderSuccess, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' + +export function renderCancelBulkOperationResult( + result: CancelBulkOperationResult, + operationId: string, + format: 'text' | 'json', +): void { + if (format === 'json') { + if (result.userErrors.length || !result.operation) { + const error = new AbortError('Failed to cancel bulk operation.') + error.details = { + ...bulkOperationJsonContext(result), + operationGid: operationId, + userErrors: result.userErrors, + } + throw error + } + outputResult( + cancelBulkOperationJsonOutputSchema.encode({ + ...bulkOperationJsonContext(result), + status: 'success', + operation: toBulkOperationJson(result.operation), + }), + ) + return + } + if (result.userErrors.length) { + renderBulkOperationUserErrors(result.userErrors, 'Failed to cancel bulk operation.') + return + } + + const operation = result.operation + if (operation) { + const cancellation = formatBulkOperationCancellationResult(operation) + const body: TokenItem | undefined = + operation.status === 'CANCELING' + ? [ + 'This may take a few moments. Check the status with:\n', + {command: `shopify app bulk status --id=${extractBulkOperationId(operation.id)}`}, + ] + : cancellation.body + const renderOptions = { + headline: cancellation.headline, + ...(body && {body}), + ...(cancellation.customSections && {customSections: cancellation.customSections}), + } + + switch (cancellation.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/result.test.ts b/packages/app/src/cli/services/bulk-operations/result.test.ts index 6a22e838bae..24bcc561013 100644 --- a/packages/app/src/cli/services/bulk-operations/result.test.ts +++ b/packages/app/src/cli/services/bulk-operations/result.test.ts @@ -1,16 +1,58 @@ import {testBulkOperation} from './bulk-operation.test-data.js' +import {renderCancelBulkOperationResult} from './cancel-result.js' import {renderBulkOperationStatusResult} from './status-result.js' -import {expect, test, vi} from 'vitest' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {renderError} from '@shopify/cli-kit/node/ui' +import {AbortError} from '@shopify/cli-kit/node/error' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('@shopify/cli-kit/node/ui') +const originalExitCode = process.exitCode + +beforeEach(() => { + mockAndCaptureOutput().clear() +}) + +afterEach(() => { + process.exitCode = originalExitCode +}) + +function completedResult() { + return { + operation: testBulkOperation({ + status: 'COMPLETED', + completedAt: '2026-09-01T00:01:00Z', + url: 'https://example.com/results.jsonl', + }), + } +} + +test('routes cancellation user errors through the global fatal-error path', () => { + expect(() => + renderCancelBulkOperationResult( + {operation: null, userErrors: [{message: 'Cannot cancel'}]}, + 'gid://shopify/BulkOperation/123', + 'json', + ), + ).toThrow(AbortError) + expect(mockAndCaptureOutput().output()).toBe('') +}) + test('does not invent a list operation type or claim completeness for a capped list', () => { const output = mockAndCaptureOutput() - const {type: _type, ...operation} = testBulkOperation() + const {type: _type, ...operation} = completedResult().operation renderBulkOperationStatusResult({operations: Array.from({length: 100}, () => operation)}, 'json') const result = JSON.parse(output.output()) expect(result.operations[0]).not.toHaveProperty('type') expect(result.operations[0].gid).toBe(operation.id) expect(result.pageInfo).toEqual({hasNextPage: null}) }) + +test('keeps missing cancellation nonfatal in text mode', () => { + renderCancelBulkOperationResult({operation: null, userErrors: []}, '123', 'text') + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({headline: 'Bulk operation not found or could not be canceled.'}), + ) + expect(process.exitCode).toBe(originalExitCode) +}) diff --git a/packages/app/src/cli/services/bulk-operations/types.test.ts b/packages/app/src/cli/services/bulk-operations/types.test.ts index 09a10fb0bce..636f6db7e1a 100644 --- a/packages/app/src/cli/services/bulk-operations/types.test.ts +++ b/packages/app/src/cli/services/bulk-operations/types.test.ts @@ -1,4 +1,4 @@ -import {bulkOperationStatusJsonOutputSchema} from './types.js' +import {bulkOperationStatusJsonOutputSchema, cancelBulkOperationJsonOutputSchema} from './types.js' import {toBulkOperationJson} from './json.js' import {testBulkOperation} from './bulk-operation.test-data.js' import {expect, test} from 'vitest' @@ -14,3 +14,16 @@ test('status result rejects unselected fields and lists without pagination metad expect(() => bulkOperationStatusJsonOutputSchema.validate({...context, operations: []})).toThrow() expect(bulkOperationStatusJsonOutputSchema.jsonSchema.definitions?.BulkOperation).toBeDefined() }) + +test.each([{name: 'cancel', schema: cancelBulkOperationJsonOutputSchema}])( + '$name uses the common operation projection', + ({schema}) => { + const value = { + storeDomain: 'shop.myshopify.com', + apiVersion: '2026-01', + status: 'success' as const, + operation: toBulkOperationJson(testBulkOperation()), + } + expect(JSON.parse(schema.encode(value))).toEqual(value) + }, +) diff --git a/packages/app/src/cli/services/bulk-operations/types.ts b/packages/app/src/cli/services/bulk-operations/types.ts index 1ca99816af9..2796f336f6b 100644 --- a/packages/app/src/cli/services/bulk-operations/types.ts +++ b/packages/app/src/cli/services/bulk-operations/types.ts @@ -33,3 +33,14 @@ export const bulkOperationStatusJsonOutputSchema = defineJsonOutputSchema({ export type BulkOperationStatusResult = BulkOperationContext & ({operationId: string; operation: BulkOperation | null} | {operations: Omit[]}) + +export const cancelBulkOperationJsonOutputSchema = defineJsonOutputSchema({ + name: 'CancelBulkOperationResult', + schema: BulkOperationContextSchema.extend({status: zod.literal('success'), operation: BulkOperationSchema}), + definitions: {BulkOperation: BulkOperationSchema}, +}) + +export interface CancelBulkOperationResult extends BulkOperationContext { + operation: BulkOperation | null + userErrors: {field?: string[] | null; message: string}[] +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 360bbd1005e..68e94d93dc5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -192,14 +192,18 @@ Cancel a bulk operation. ``` USAGE - $ shopify app bulk cancel --id [--auth-alias ] [--client-id | -c ] [--json-schema] - [--no-color] [--no-input] [--path ] [--reset | ] [-s ] [--verbose] + $ shopify app bulk cancel --id [--auth-alias ] [--client-id | -c ] [-j] + [--json-schema] [--no-color] [--no-input] [--path ] [--reset | ] [-s ] [--verbose] FLAGS -c, --config= The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -s, --store= The store domain. Must be an existing dev store. [env: SHOPIFY_FLAG_STORE] @@ -244,6 +248,132 @@ DESCRIPTION Cancel a bulk operation. Cancels a running bulk operation by ID. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `CancelBulkOperationResult` schema. + + ```json + { + "type": "object", + "properties": { + "storeDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^[^.]+\\.myshopify\\.com$" + }, + { + "type": "null" + } + ] + }, + "apiVersion": { + "type": [ + "string", + "null" + ], + "description": "The API version selected for the request." + }, + "status": { + "type": "string", + "const": "success" + }, + "operation": { + "$ref": "#/definitions/BulkOperation" + } + }, + "required": [ + "storeDomain", + "apiVersion", + "status", + "operation" + ], + "additionalProperties": false, + "title": "CancelBulkOperationResult", + "definitions": { + "BulkOperation": { + "type": "object", + "properties": { + "gid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/BulkOperation\\/[^/]+$", + "description": "The Shopify global ID of a bulk operation." + }, + "type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "errorCode": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "completedAt": { + "anyOf": [ + { + "$ref": "#/definitions/BulkOperation/properties/createdAt" + }, + { + "type": "null" + } + ], + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "objectCount": { + "type": "string", + "pattern": "^\\d+$", + "description": "A nonnegative decimal count, without loss of integer precision." + }, + "url": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "partialDataUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "gid", + "type", + "status", + "errorCode", + "createdAt", + "completedAt", + "objectCount", + "url", + "partialDataUrl" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app bulk execute` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 004cc18efea..117f7c094c1 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -114,7 +114,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Cancels a running bulk operation by ID.", + "description": "Cancels a running bulk operation by ID.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `CancelBulkOperationResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[^.]+\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"apiVersion\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"The API version selected for the request.\"\n },\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/BulkOperation\"\n }\n },\n \"required\": [\n \"storeDomain\",\n \"apiVersion\",\n \"status\",\n \"operation\"\n ],\n \"additionalProperties\": false,\n \"title\": \"CancelBulkOperationResult\",\n \"definitions\": {\n \"BulkOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/BulkOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify global ID of a bulk operation.\"\n },\n \"type\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\"\n },\n \"errorCode\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"createdAt\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\",\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"completedAt\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/BulkOperation/properties/createdAt\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"objectCount\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"A nonnegative decimal count, without loss of integer precision.\"\n },\n \"url\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"partialDataUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"gid\",\n \"type\",\n \"status\",\n \"errorCode\",\n \"createdAt\",\n \"completedAt\",\n \"objectCount\",\n \"url\",\n \"partialDataUrl\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Cancels a running bulk operation by ID.", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -155,6 +156,15 @@ "required": true, "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index fc0d957f138..d496cf68c71 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -3,7 +3,6 @@ const commandExceptions = [ // Existing finite commands awaiting migration. Remove entries as they adopt typed JSON output. // Do not add new finite commands to this section. 'packages/app/src/cli/commands/app/build.ts', - 'packages/app/src/cli/commands/app/bulk/cancel.ts', 'packages/app/src/cli/commands/app/bulk/execute.ts', 'packages/app/src/cli/commands/app/config/link.ts', 'packages/app/src/cli/commands/app/config/pull.ts',