Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/app-bulk-status-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Add typed JSON output to app bulk status.
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,15 @@
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-bulk-status.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-status.interface.ts",
"syntaxKind": "PropertySignature",
Expand All @@ -480,7 +489,7 @@
"environmentValue": "SHOPIFY_FLAG_STORE"
}
],
"value": "export interface appbulkstatus {\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 (numeric ID or full GID). If not provided, lists all bulk operations belonging to this app on this store in the last 7 days.\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}"
"value": "export interface appbulkstatus {\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 (numeric ID or full GID). If not provided, lists all bulk operations belonging to this app on this store in the last 7 days.\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}"
}
},
"appconfiglink": {
Expand Down
158 changes: 158 additions & 0 deletions packages/app/src/cli/commands/app/bulk/output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import BulkStatus from './status.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 {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 {Config} from '@oclif/core'
import {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<typeof import('../../../services/graphql/common.js')>()),
resolveApiVersion: vi.fn(),
}))

vi.mock('@shopify/cli-kit/node/session', async (importOriginal) => ({
...(await importOriginal<typeof import('@shopify/cli-kit/node/session')>()),
ensureAuthenticatedAdminAsApp: vi.fn(),
}))

vi.mock('@shopify/cli-kit/node/api/bulk-operations', async (importOriginal) => ({
...(await importOriginal<typeof import('@shopify/cli-kit/node/api/bulk-operations')>()),
fetchBulkOperationById: vi.fn(),
fetchRecentBulkOperations: vi.fn(),
}))

function bulkOperation(overrides: Partial<BulkOperation> = {}): BulkOperation {
return testBulkOperation({
objectCount: '900719925474099312345',
createdAt: '2026-09-01T02:00:00.789+02:00',
...overrides,
})
}

function expectedOperation() {
return {
gid: 'gid://shopify/BulkOperation/123',
type: 'QUERY',
status: 'RUNNING',
errorCode: null,
createdAt: '2026-09-01T00:00:00Z',
completedAt: null,
objectCount: '900719925474099312345',
url: null,
partialDataUrl: null,
}
}

async function runCommand(Command: typeof BulkStatus, 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(ensureAuthenticatedAdminAsApp).mockResolvedValue(session)
vi.mocked(resolveApiVersion).mockResolvedValue('2026-01')
const config = await Config.load()
return runWithCommandEventsForCommand(argv, () => new Command(argv, config).run())
}

function assertDiagnosticEvents(stderr: string, headline: string) {
const events = stderr
.trim()
.split('\n')
.map((line) => JSON.parse(line))
expect(events).toContainEqual(
expect.objectContaining({type: 'diagnostic', level: 'info', message: expect.stringContaining(headline)}),
)
expect(events.every((event) => event.type === 'diagnostic' || event.type === 'progress')).toBe(true)
}

test('lists operations without inventing an unavailable type or exposing app credentials', async () => {
const {type: _type, ...operation} = bulkOperation()
const {type: _expectedType, ...expected} = expectedOperation()
vi.mocked(fetchRecentBulkOperations).mockResolvedValue([operation])
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand(BulkStatus, ['--json'])
expect(JSON.parse(stdout())).toEqual({
storeDomain: 'shop.myshopify.com',
apiVersion: '2026-01',
operations: [expected],
pageInfo: {hasNextPage: false},
})
assertDiagnosticEvents(stderr(), 'Listing bulk operations.')
})
})

test('an empty list is one object with an empty collection', async () => {
vi.mocked(fetchRecentBulkOperations).mockResolvedValue([])
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand(BulkStatus, ['--json'])
expect(JSON.parse(stdout())).toEqual({
storeDomain: 'shop.myshopify.com',
apiVersion: '2026-01',
operations: [],
pageInfo: {hasNextPage: false},
})
assertDiagnosticEvents(stderr(), 'Listing bulk operations.')
})
})

test('a missing requested operation is distinct from an empty list', async () => {
vi.mocked(fetchBulkOperationById).mockResolvedValue(null)
await withCapturedStandardStreams(async ({stdout}) => {
await runCommand(BulkStatus, ['--json', '--id', '123'])
expect(JSON.parse(stdout())).toEqual({
storeDomain: 'shop.myshopify.com',
apiVersion: '2026-01',
operationGid: 'gid://shopify/BulkOperation/123',
operation: null,
})
})
})

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.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}) => {
vi.mocked(fetchBulkOperationById).mockResolvedValue(bulkOperation())
const argv = ['--id', '123', ...(json ? ['--json'] : []), ...(noInput ? ['--no-input'] : [])]
await withCapturedStandardStreams(async ({stdout, stderr}) => {
const result = await runCommand(BulkStatus, argv)
expect(result.app).toBeDefined()
if (json) {
expect(JSON.parse(stdout())).toEqual({
storeDomain: 'shop.myshopify.com',
apiVersion: '2026-01',
operationGid: 'gid://shopify/BulkOperation/123',
operation: expectedOperation(),
})
assertDiagnosticEvents(stderr(), 'Checking bulk operation status.')
} else {
expect(stdout()).toBe('')
expect(stderr()).toContain('Checking bulk operation status.')
}
const contextFlags = vi.mocked(prepareAppStoreContext).mock.calls[0]?.[0]
expect(contextFlags).toHaveProperty('json', json)
if (noInput) expect(contextFlags).toHaveProperty('no-input', true)
else expect(contextFlags).not.toHaveProperty('no-input')
})
})
11 changes: 7 additions & 4 deletions packages/app/src/cli/commands/app/bulk/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {describe, expect, test, vi, beforeEach} from 'vitest'

vi.mock('../../../services/bulk-operations/bulk-operation-status.js')
vi.mock('../../../utilities/execute-command-helpers.js')
vi.mock('../../../services/bulk-operations/status-result.js')
vi.mock('../../../services/bulk-operations/progress.js')

describe('app bulk status command', () => {
const app = testAppLinked()
Expand All @@ -32,8 +34,11 @@ describe('app bulk status command', () => {
},
store,
})
vi.mocked(getBulkOperationStatus).mockResolvedValue()
vi.mocked(listBulkOperations).mockResolvedValue()
vi.mocked(getBulkOperationStatus).mockResolvedValue({
operationId: 'gid://shopify/BulkOperation/123',
operation: null,
})
vi.mocked(listBulkOperations).mockResolvedValue({operations: []})
})

test('calls getBulkOperationStatus when id is provided', async () => {
Expand All @@ -48,7 +53,6 @@ describe('app bulk status command', () => {
}),
)
expect(getBulkOperationStatus).toHaveBeenCalledWith({
organization,
storeFqdn: 'shop.myshopify.com',
operationId: 'gid://shopify/BulkOperation/123',
remoteApp,
Expand All @@ -67,7 +71,6 @@ describe('app bulk status command', () => {
}),
)
expect(listBulkOperations).toHaveBeenCalledWith({
organization,
storeFqdn: 'shop.myshopify.com',
remoteApp,
})
Expand Down
34 changes: 21 additions & 13 deletions packages/app/src/cli/commands/app/bulk/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import {appFlags} from '../../../flags.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js'
import {getBulkOperationStatus, listBulkOperations} from '../../../services/bulk-operations/bulk-operation-status.js'
import {bulkOperationStatusJsonOutputSchema} from '../../../services/bulk-operations/types.js'
import {renderBulkOperationStatusResult} from '../../../services/bulk-operations/status-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'

Expand All @@ -20,6 +23,7 @@ export default class BulkStatus extends AppLinkedCommand {

static flags = {
...globalFlags,
...jsonFlag,
...appFlags,
id: Flags.string({
description:
Expand All @@ -34,25 +38,29 @@ export default class BulkStatus extends AppLinkedCommand {
}),
}

static get jsonOutputSchema() {
return bulkOperationStatusJsonOutputSchema
}

async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(BulkStatus)

const {appContextResult, store} = await prepareAppStoreContext(flags)

if (flags.id) {
await getBulkOperationStatus({
const format = flags.json ? 'json' : 'text'
logBulkOperationStart(
flags.id ? 'Checking bulk operation status.' : 'Listing bulk operations.',
{
organization: appContextResult.organization,
storeFqdn: store.shopDomain,
operationId: normalizeBulkOperationId(flags.id),
remoteApp: appContextResult.remoteApp,
})
} else {
await listBulkOperations({
organization: appContextResult.organization,
storeFqdn: store.shopDomain,
remoteApp: appContextResult.remoteApp,
})
}
},
format,
)
const options = {storeFqdn: store.shopDomain, remoteApp: appContextResult.remoteApp}
const result = flags.id
? await getBulkOperationStatus({...options, operationId: normalizeBulkOperationId(flags.id)})
: await listBulkOperations(options)
renderBulkOperationStatusResult(result, format)

return {app: appContextResult.app}
}
Expand Down
Loading
Loading