Skip to content

Commit 01fdcfe

Browse files
authored
Add typed JSON output to app bulk cancel
1 parent 99d2c65 commit 01fdcfe

14 files changed

Lines changed: 488 additions & 125 deletions

File tree

‎.changeset/app-bulk-cancel-json.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli': minor
3+
---
4+
5+
Add typed JSON output to app bulk cancel.

‎docs-shopify.dev/generated/generated_docs_data_v2.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,15 @@
196196
"isOptional": true,
197197
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
198198
},
199+
{
200+
"filePath": "docs-shopify.dev/commands/interfaces/app-bulk-cancel.interface.ts",
201+
"syntaxKind": "PropertySignature",
202+
"name": "-j, --json",
203+
"value": "''",
204+
"description": "Output the result as JSON. Automatically disables color output.",
205+
"isOptional": true,
206+
"environmentValue": "SHOPIFY_FLAG_JSON"
207+
},
199208
{
200209
"filePath": "docs-shopify.dev/commands/interfaces/app-bulk-cancel.interface.ts",
201210
"syntaxKind": "PropertySignature",
@@ -206,7 +215,7 @@
206215
"environmentValue": "SHOPIFY_FLAG_STORE"
207216
}
208217
],
209-
"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 <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * The bulk operation ID to cancel (numeric ID or full GID).\n * @environment SHOPIFY_FLAG_ID\n */\n '--id <value>': 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 <value>'?: 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 <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
218+
"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 <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * The bulk operation ID to cancel (numeric ID or full GID).\n * @environment SHOPIFY_FLAG_ID\n */\n '--id <value>': 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 <value>'?: 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 <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
210219
}
211220
},
212221
"appbulkexecute": {

‎packages/app/src/cli/commands/app/bulk/cancel.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import BulkCancel from './cancel.js'
2+
import {renderCancelBulkOperationResult} from '../../../services/bulk-operations/cancel-result.js'
23
import {cancelBulkOperation} from '../../../services/bulk-operations/cancel-bulk-operation.js'
34
import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js'
45
import {
@@ -12,6 +13,8 @@ import {describe, expect, test, vi, beforeEach} from 'vitest'
1213

1314
vi.mock('../../../services/bulk-operations/cancel-bulk-operation.js')
1415
vi.mock('../../../utilities/execute-command-helpers.js')
16+
vi.mock('../../../services/bulk-operations/cancel-result.js')
17+
vi.mock('../../../services/bulk-operations/progress.js')
1518

1619
describe('app bulk cancel command', () => {
1720
const app = testAppLinked()
@@ -31,7 +34,7 @@ describe('app bulk cancel command', () => {
3134
},
3235
store,
3336
})
34-
vi.mocked(cancelBulkOperation).mockResolvedValue()
37+
vi.mocked(cancelBulkOperation).mockResolvedValue({operation: null, userErrors: []})
3538
})
3639

3740
test('prepares app/store context and cancels bulk operation', async () => {
@@ -46,8 +49,12 @@ describe('app bulk cancel command', () => {
4649
store: 'shop.myshopify.com',
4750
}),
4851
)
52+
expect(renderCancelBulkOperationResult).toHaveBeenCalledWith(
53+
{operation: null, userErrors: []},
54+
'gid://shopify/BulkOperation/12345',
55+
'text',
56+
)
4957
expect(cancelBulkOperation).toHaveBeenCalledWith({
50-
organization: expect.any(Object),
5158
storeFqdn: 'shop.myshopify.com',
5259
operationId: 'gid://shopify/BulkOperation/12345',
5360
remoteApp,

‎packages/app/src/cli/commands/app/bulk/cancel.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,24 @@ import {appFlags} from '../../../flags.js'
22
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
33
import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js'
44
import {cancelBulkOperation} from '../../../services/bulk-operations/cancel-bulk-operation.js'
5+
import {cancelBulkOperationJsonOutputSchema} from '../../../services/bulk-operations/types.js'
6+
import {renderCancelBulkOperationResult} from '../../../services/bulk-operations/cancel-result.js'
7+
import {logBulkOperationStart} from '../../../services/bulk-operations/progress.js'
58
import {Flags} from '@oclif/core'
6-
import {globalFlags} from '@shopify/cli-kit/node/cli'
9+
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
710
import {normalizeBulkOperationId} from '@shopify/cli-kit/node/api/bulk-operations'
811
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
912

1013
export default class BulkCancel extends AppLinkedCommand {
1114
static summary = 'Cancel a bulk operation.'
1215

13-
static description = 'Cancels a running bulk operation by ID.'
16+
static descriptionWithMarkdown = 'Cancels a running bulk operation by ID.'
17+
18+
static description = this.descriptionForHelp()
1419

1520
static flags = {
1621
...globalFlags,
22+
...jsonFlag,
1723
...appFlags,
1824
id: Flags.string({
1925
description: 'The bulk operation ID to cancel (numeric ID or full GID).',
@@ -28,17 +34,32 @@ export default class BulkCancel extends AppLinkedCommand {
2834
}),
2935
}
3036

37+
static get jsonOutputSchema() {
38+
return cancelBulkOperationJsonOutputSchema
39+
}
40+
3141
async run(): Promise<AppLinkedCommandOutput> {
3242
const {flags} = await this.parse(BulkCancel)
3343

3444
const {appContextResult, store} = await prepareAppStoreContext(flags)
35-
36-
await cancelBulkOperation({
37-
organization: appContextResult.organization,
45+
const format = flags.json ? 'json' : 'text'
46+
const operationId = normalizeBulkOperationId(flags.id)
47+
logBulkOperationStart(
48+
'Canceling bulk operation.',
49+
{
50+
organization: appContextResult.organization,
51+
remoteApp: appContextResult.remoteApp,
52+
storeFqdn: store.shopDomain,
53+
operationId,
54+
},
55+
format,
56+
)
57+
const result = await cancelBulkOperation({
3858
storeFqdn: store.shopDomain,
39-
operationId: normalizeBulkOperationId(flags.id),
59+
operationId,
4060
remoteApp: appContextResult.remoteApp,
4161
})
62+
renderCancelBulkOperationResult(result, operationId, format)
4263

4364
return {app: appContextResult.app}
4465
}

‎packages/app/src/cli/commands/app/bulk/output.test.ts‎

Lines changed: 74 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
1+
import {testBulkOperation, testBulkOperationContext} from '../../../services/bulk-operations/bulk-operation.test-data.js'
12
import BulkStatus from './status.js'
2-
import {
3-
testBulkOperation,
4-
testBulkOperationContext,
5-
} from '../../../services/bulk-operations/bulk-operation.test-data.js'
3+
import BulkCancel from './cancel.js'
64
import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js'
7-
import {resolveApiVersion} from '../../../services/graphql/common.js'
8-
import {bulkOperationStatusJsonOutputSchema} from '../../../services/bulk-operations/types.js'
9-
import {fetchBulkOperationById, fetchRecentBulkOperations} from '@shopify/cli-kit/node/api/bulk-operations'
5+
import {createAdminSessionAsApp, resolveApiVersion} from '../../../services/graphql/common.js'
6+
import {
7+
bulkOperationStatusJsonOutputSchema,
8+
cancelBulkOperationJsonOutputSchema,
9+
10+
} from '../../../services/bulk-operations/types.js'
11+
import {fetchBulkOperationById, fetchRecentBulkOperations, cancelBulkOperationRequest} from '@shopify/cli-kit/node/api/bulk-operations'
1012
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
1113
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
1214
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
15+
import {AbortError, handler} from '@shopify/cli-kit/node/error'
1316
import {Config} from '@oclif/core'
14-
import {expect, test, vi} from 'vitest'
17+
import {afterEach, expect, test, vi} from 'vitest'
1518
import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations'
1619

1720
vi.mock('../../../utilities/execute-command-helpers.js')
1821

1922
vi.mock('../../../services/graphql/common.js', async (importOriginal) => ({
2023
...(await importOriginal<typeof import('../../../services/graphql/common.js')>()),
24+
createAdminSessionAsApp: vi.fn(),
2125
resolveApiVersion: vi.fn(),
2226
}))
2327

@@ -30,14 +34,18 @@ vi.mock('@shopify/cli-kit/node/api/bulk-operations', async (importOriginal) => (
3034
...(await importOriginal<typeof import('@shopify/cli-kit/node/api/bulk-operations')>()),
3135
fetchBulkOperationById: vi.fn(),
3236
fetchRecentBulkOperations: vi.fn(),
37+
cancelBulkOperationRequest: vi.fn(),
3338
}))
3439

40+
const originalExitCode = process.exitCode
41+
42+
afterEach(() => {
43+
vi.unstubAllEnvs()
44+
process.exitCode = originalExitCode
45+
})
46+
3547
function bulkOperation(overrides: Partial<BulkOperation> = {}): BulkOperation {
36-
return testBulkOperation({
37-
objectCount: '900719925474099312345',
38-
createdAt: '2026-09-01T02:00:00.789+02:00',
39-
...overrides,
40-
})
48+
return testBulkOperation({objectCount: '900719925474099312345', createdAt: '2026-09-01T02:00:00.789+02:00', ...overrides})
4149
}
4250

4351
function expectedOperation() {
@@ -54,13 +62,12 @@ function expectedOperation() {
5462
}
5563
}
5664

57-
async function runCommand(Command: typeof BulkStatus, argv: string[]) {
65+
async function runCommand(Command: typeof BulkStatus | typeof BulkCancel, argv: string[]) {
5866
const {appContextResult, store} = testBulkOperationContext()
59-
const remoteApp = appContextResult.remoteApp
6067
vi.mocked(prepareAppStoreContext).mockResolvedValue({appContextResult, store})
6168

6269
const session = {storeFqdn: store.shopDomain, token: 'token'}
63-
70+
vi.mocked(createAdminSessionAsApp).mockResolvedValue(session)
6471
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(session)
6572
vi.mocked(resolveApiVersion).mockResolvedValue('2026-01')
6673
const config = await Config.load()
@@ -118,33 +125,68 @@ test('a missing requested operation is distinct from an empty list', async () =>
118125
operationGid: 'gid://shopify/BulkOperation/123',
119126
operation: null,
120127
})
128+
expect(process.exitCode).toBe(originalExitCode)
121129
})
122130
})
123131

124-
test('exposes the status schema, JSON flag, and help', () => {
125-
expect(BulkStatus.jsonOutputSchema).toBe(bulkOperationStatusJsonOutputSchema)
126-
expect(BulkStatus.flags.json).toBeDefined()
127-
expect(BulkStatus.baseFlags).toHaveProperty('json-schema')
128-
expect(BulkStatus.descriptionForHelp()).toContain(bulkOperationStatusJsonOutputSchema.name)
132+
test('cancellation emits one result through the real service, codec, presenter, and writer', async () => {
133+
vi.mocked(cancelBulkOperationRequest).mockResolvedValue({
134+
bulkOperation: {...bulkOperation(), query: 'query { shop { name } }', rootObjectCount: '2'},
135+
userErrors: [],
136+
})
137+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
138+
await runCommand(BulkCancel, ['--id', '123', '--json'])
139+
expect(JSON.parse(stdout())).toEqual({
140+
storeDomain: 'shop.myshopify.com',
141+
apiVersion: '2026-01',
142+
status: 'success',
143+
operation: expectedOperation(),
144+
})
145+
assertDiagnosticEvents(stderr(), 'Canceling bulk operation.')
146+
})
129147
})
148+
130149
test.each([
131-
{json: true, noInput: false},
132-
{json: true, noInput: true},
133-
{json: false, noInput: false},
134-
{json: false, noInput: true},
135-
])('status keeps output mode $json independent from input policy $noInput', async ({json, noInput}) => {
150+
{name: 'cancel', Command: BulkCancel},
151+
])('$name user errors reach the fatal envelope without a success document', async ({Command}) => {
152+
const userErrors = [{field: ['id'], message: 'Operation rejected'}]
153+
vi.mocked(cancelBulkOperationRequest).mockResolvedValue({bulkOperation: null, userErrors})
154+
155+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
156+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
157+
const argv = ['--id', '123']
158+
try {
159+
await runCommand(Command, [...argv, '--json'])
160+
expect.fail('The command must reject the operation.')
161+
} catch (error) {
162+
if (!(error instanceof AbortError)) throw error
163+
expect(error).toMatchObject({details: {userErrors}})
164+
expect(stdout()).toBe('')
165+
await handler(error)
166+
}
167+
expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', details: {userErrors}}})
168+
assertDiagnosticEvents(stderr(), 'Canceling bulk operation.')
169+
})
170+
})
171+
172+
test.each([
173+
{name: 'status', Command: BulkStatus, schema: bulkOperationStatusJsonOutputSchema},
174+
{name: 'cancel', Command: BulkCancel, schema: cancelBulkOperationJsonOutputSchema},
175+
176+
] as const)('exposes the schema, JSON flag, and help for $name', ({Command, schema}) => {
177+
expect(Command.jsonOutputSchema).toBe(schema)
178+
expect(Command.flags.json).toBeDefined()
179+
expect(Command.baseFlags).toHaveProperty('json-schema')
180+
expect(Command.descriptionForHelp()).toContain(schema.name)
181+
})
182+
test.each([{json: true, noInput: false}, {json: true, noInput: true}, {json: false, noInput: false}, {json: false, noInput: true}])('status keeps output mode $json independent from input policy $noInput', async ({json, noInput}) => {
136183
vi.mocked(fetchBulkOperationById).mockResolvedValue(bulkOperation())
137184
const argv = ['--id', '123', ...(json ? ['--json'] : []), ...(noInput ? ['--no-input'] : [])]
138185
await withCapturedStandardStreams(async ({stdout, stderr}) => {
139186
const result = await runCommand(BulkStatus, argv)
140187
expect(result.app).toBeDefined()
141188
if (json) {
142-
expect(JSON.parse(stdout())).toEqual({
143-
storeDomain: 'shop.myshopify.com',
144-
apiVersion: '2026-01',
145-
operationGid: 'gid://shopify/BulkOperation/123',
146-
operation: expectedOperation(),
147-
})
189+
expect(JSON.parse(stdout())).toEqual({storeDomain: 'shop.myshopify.com', apiVersion: '2026-01', operationGid: 'gid://shopify/BulkOperation/123', operation: expectedOperation()})
148190
assertDiagnosticEvents(stderr(), 'Checking bulk operation status.')
149191
} else {
150192
expect(stdout()).toBe('')

0 commit comments

Comments
 (0)