Skip to content

Commit 16085c8

Browse files
Implement shopify app bulk status subcommand
Allows users to check the status of a bulk operation by ID.
1 parent 4c19852 commit 16085c8

File tree

7 files changed

+414
-0
lines changed

7 files changed

+414
-0
lines changed

packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type GetBulkOperationByIdQuery = {
1414
errorCode?: Types.BulkOperationErrorCode | null
1515
id: string
1616
objectCount: unknown
17+
partialDataUrl?: string | null
1718
status: Types.BulkOperationStatus
1819
url?: string | null
1920
} | null
@@ -54,6 +55,7 @@ export const GetBulkOperationById = {
5455
{kind: 'Field', name: {kind: 'Name', value: 'errorCode'}},
5556
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
5657
{kind: 'Field', name: {kind: 'Name', value: 'objectCount'}},
58+
{kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}},
5759
{kind: 'Field', name: {kind: 'Name', value: 'status'}},
5860
{kind: 'Field', name: {kind: 'Name', value: 'url'}},
5961
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},

packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ query GetBulkOperationById($id: ID!) {
55
errorCode
66
id
77
objectCount
8+
partialDataUrl
89
status
910
url
1011
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {appFlags} from '../../../flags.js'
2+
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
3+
import {linkedAppContext} from '../../../services/app-context.js'
4+
import {storeContext} from '../../../services/store-context.js'
5+
import {getBulkOperationStatus} from '../../../services/bulk-operations/bulk-operation-status.js'
6+
import {Flags} from '@oclif/core'
7+
import {globalFlags} from '@shopify/cli-kit/node/cli'
8+
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
9+
10+
export default class BulkStatus extends AppLinkedCommand {
11+
static summary = 'Check the status of a bulk operation.'
12+
13+
static description = 'Check the status of a bulk operation by ID.'
14+
15+
static hidden = true
16+
17+
static flags = {
18+
...globalFlags,
19+
...appFlags,
20+
id: Flags.string({
21+
description: 'The bulk operation ID.',
22+
env: 'SHOPIFY_FLAG_ID',
23+
required: true,
24+
}),
25+
store: Flags.string({
26+
char: 's',
27+
description: 'The store domain. Must be an existing dev store.',
28+
env: 'SHOPIFY_FLAG_STORE',
29+
parse: async (input) => normalizeStoreFqdn(input),
30+
}),
31+
}
32+
33+
async run(): Promise<AppLinkedCommandOutput> {
34+
const {flags} = await this.parse(BulkStatus)
35+
36+
const appContextResult = await linkedAppContext({
37+
directory: flags.path,
38+
clientId: flags['client-id'],
39+
forceRelink: flags.reset,
40+
userProvidedConfigName: flags.config,
41+
})
42+
43+
const store = await storeContext({
44+
appContextResult,
45+
storeFqdn: flags.store,
46+
forceReselectStore: flags.reset,
47+
})
48+
49+
await getBulkOperationStatus({
50+
storeFqdn: store.shopDomain,
51+
operationId: flags.id,
52+
})
53+
54+
return {app: appContextResult.app}
55+
}
56+
}

packages/app/src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Build from './commands/app/build.js'
2+
import BulkStatus from './commands/app/bulk/status.js'
23
import ConfigLink from './commands/app/config/link.js'
34
import ConfigUse from './commands/app/config/use.js'
45
import DemoWatcher from './commands/app/demo/watcher.js'
@@ -36,6 +37,7 @@ import FunctionInfo from './commands/app/function/info.js'
3637
*/
3738
export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlinkedCommand} = {
3839
'app:build': Build,
40+
'app:bulk:status': BulkStatus,
3941
'app:deploy': Deploy,
4042
'app:dev': Dev,
4143
'app:dev:clean': DevClean,
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import {getBulkOperationStatus} from './bulk-operation-status.js'
2+
import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
3+
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
4+
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
5+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
6+
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
7+
8+
vi.mock('@shopify/cli-kit/node/session')
9+
vi.mock('@shopify/cli-kit/node/api/admin')
10+
11+
describe('getBulkOperationStatus', () => {
12+
const storeFqdn = 'test-store.myshopify.com'
13+
const operationId = 'gid://shopify/BulkOperation/123'
14+
15+
beforeEach(() => {
16+
vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue({token: 'test-token', storeFqdn})
17+
})
18+
19+
afterEach(() => {
20+
mockAndCaptureOutput().clear()
21+
})
22+
23+
function mockBulkOperation(
24+
overrides?: Partial<NonNullable<GetBulkOperationByIdQuery['bulkOperation']>>,
25+
): GetBulkOperationByIdQuery {
26+
return {
27+
bulkOperation: {
28+
id: operationId,
29+
status: 'RUNNING',
30+
errorCode: null,
31+
objectCount: 100,
32+
createdAt: new Date(Date.now() - 120000).toISOString(),
33+
completedAt: null,
34+
url: null,
35+
partialDataUrl: null,
36+
...overrides,
37+
},
38+
}
39+
}
40+
41+
test('renders success banner for completed operation', async () => {
42+
vi.mocked(adminRequestDoc).mockResolvedValue(
43+
mockBulkOperation({
44+
status: 'COMPLETED',
45+
completedAt: new Date(Date.now() - 60000).toISOString(),
46+
url: 'https://example.com/results.jsonl',
47+
}),
48+
)
49+
50+
const output = mockAndCaptureOutput()
51+
await getBulkOperationStatus({storeFqdn, operationId})
52+
53+
expect(output.output()).toContain('COMPLETED')
54+
expect(output.output()).toContain(operationId)
55+
expect(output.output()).toContain('Finished')
56+
expect(output.output()).toContain('Download results')
57+
})
58+
59+
test('renders info banner for running operation', async () => {
60+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING', objectCount: 500}))
61+
62+
const output = mockAndCaptureOutput()
63+
await getBulkOperationStatus({storeFqdn, operationId})
64+
65+
expect(output.info()).toContain('RUNNING')
66+
expect(output.info()).toContain('500 complete')
67+
expect(output.info()).toContain('Started')
68+
})
69+
70+
test('renders error banner for failed operation', async () => {
71+
vi.mocked(adminRequestDoc).mockResolvedValue(
72+
mockBulkOperation({
73+
status: 'FAILED',
74+
errorCode: 'ACCESS_DENIED',
75+
completedAt: new Date(Date.now() - 60000).toISOString(),
76+
partialDataUrl: 'https://example.com/partial.jsonl',
77+
}),
78+
)
79+
80+
const output = mockAndCaptureOutput()
81+
await getBulkOperationStatus({storeFqdn, operationId})
82+
83+
expect(output.error()).toContain('ERROR: ACCESS_DENIED')
84+
expect(output.error()).toContain('Finished')
85+
expect(output.error()).toContain('Download partial results')
86+
})
87+
88+
test('throws error when operation not found', async () => {
89+
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null})
90+
91+
await expect(getBulkOperationStatus({storeFqdn, operationId})).rejects.toThrow(
92+
`Bulk operation with ID ${operationId} not found.`,
93+
)
94+
})
95+
96+
test('handles created status', async () => {
97+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CREATED', objectCount: 0}))
98+
99+
const output = mockAndCaptureOutput()
100+
await getBulkOperationStatus({storeFqdn, operationId})
101+
102+
expect(output.info()).toContain('CREATED')
103+
})
104+
105+
test('handles canceled status', async () => {
106+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CANCELED'}))
107+
108+
const output = mockAndCaptureOutput()
109+
await getBulkOperationStatus({storeFqdn, operationId})
110+
111+
expect(output.info()).toContain('CANCELED')
112+
})
113+
114+
describe('time formatting', () => {
115+
test('formats singular time correctly', async () => {
116+
vi.mocked(adminRequestDoc).mockResolvedValue(
117+
mockBulkOperation({completedAt: new Date(Date.now() - 60000).toISOString()}),
118+
)
119+
120+
const output = mockAndCaptureOutput()
121+
await getBulkOperationStatus({storeFqdn, operationId})
122+
123+
expect(output.output()).toMatch(/1 minute ago/)
124+
})
125+
126+
test('formats plural time correctly', async () => {
127+
vi.mocked(adminRequestDoc).mockResolvedValue(
128+
mockBulkOperation({createdAt: new Date(Date.now() - 180000).toISOString()}),
129+
)
130+
131+
const output = mockAndCaptureOutput()
132+
await getBulkOperationStatus({storeFqdn, operationId})
133+
134+
expect(output.output()).toMatch(/3 minutes ago/)
135+
})
136+
137+
test('uses "Started" for running and "Finished" for completed', async () => {
138+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'}))
139+
140+
const output = mockAndCaptureOutput()
141+
await getBulkOperationStatus({storeFqdn, operationId})
142+
143+
expect(output.output()).toContain('Started')
144+
expect(output.output()).not.toContain('Finished')
145+
146+
output.clear()
147+
148+
vi.mocked(adminRequestDoc).mockResolvedValue(
149+
mockBulkOperation({
150+
status: 'COMPLETED',
151+
completedAt: new Date(Date.now() - 60000).toISOString(),
152+
}),
153+
)
154+
155+
await getBulkOperationStatus({storeFqdn, operationId})
156+
157+
expect(output.output()).toContain('Finished')
158+
expect(output.output()).not.toContain('Started')
159+
})
160+
})
161+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {BulkOperation} from './watch-bulk-operation.js'
2+
import {
3+
GetBulkOperationById,
4+
GetBulkOperationByIdQuery,
5+
} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
6+
import {renderInfo, renderSuccess, renderError} from '@shopify/cli-kit/node/ui'
7+
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
8+
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
9+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
10+
import {AbortError} from '@shopify/cli-kit/node/error'
11+
12+
const API_VERSION = '2026-01'
13+
14+
interface GetBulkOperationStatusOptions {
15+
storeFqdn: string
16+
operationId: string
17+
}
18+
19+
export async function getBulkOperationStatus(options: GetBulkOperationStatusOptions): Promise<void> {
20+
const {storeFqdn, operationId} = options
21+
22+
const adminSession = await ensureAuthenticatedAdmin(storeFqdn)
23+
24+
const response = await adminRequestDoc<GetBulkOperationByIdQuery, {id: string}>({
25+
query: GetBulkOperationById,
26+
session: adminSession,
27+
variables: {id: operationId},
28+
version: API_VERSION,
29+
})
30+
31+
if (!response.bulkOperation) {
32+
throw new AbortError(`Bulk operation with ID ${operationId} not found.`)
33+
}
34+
35+
renderBulkOperationStatus(response.bulkOperation)
36+
}
37+
38+
function renderBulkOperationStatus(operation: BulkOperation): void {
39+
const {id, status, errorCode, objectCount, createdAt, completedAt, url, partialDataUrl} = operation
40+
const timeInfo = formatTimeInfo(createdAt, completedAt)
41+
42+
if (status === 'COMPLETED') {
43+
renderSuccess({
44+
headline: 'COMPLETED.',
45+
body: outputContent`${id}\n${timeInfo}\n${url ? outputToken.link('Download results', url) : ''}`.value,
46+
})
47+
} else if (status === 'FAILED') {
48+
renderError({
49+
headline: `ERROR: ${errorCode ?? 'Something went wrong.'}`,
50+
body: outputContent`${id}\n${timeInfo}\n${
51+
partialDataUrl ? outputToken.link('Download partial results', partialDataUrl) : ''
52+
}`.value,
53+
})
54+
} else {
55+
const headline = status === 'RUNNING' ? `RUNNING (${objectCount} complete)` : status
56+
renderInfo({
57+
headline,
58+
body: `${id}\n${timeInfo}`,
59+
})
60+
}
61+
}
62+
63+
function formatTimeInfo(createdAt: unknown, completedAt?: unknown): string {
64+
const created = new Date(String(createdAt))
65+
const now = new Date()
66+
67+
if (completedAt) {
68+
const completed = new Date(String(completedAt))
69+
const finishedAgo = timeAgo(completed, now)
70+
return `Finished ${finishedAgo}`
71+
} else {
72+
const startedAgo = timeAgo(created, now)
73+
return `Started ${startedAgo}`
74+
}
75+
}
76+
77+
function timeAgo(from: Date, to: Date): string {
78+
const seconds = Math.floor((to.getTime() - from.getTime()) / 1000)
79+
80+
if (seconds < 60) {
81+
return `${seconds} second${seconds === 1 ? '' : 's'} ago`
82+
}
83+
84+
const minutes = Math.floor(seconds / 60)
85+
if (minutes < 60) {
86+
return `${minutes} minute${minutes === 1 ? '' : 's'} ago`
87+
}
88+
89+
const hours = Math.floor(minutes / 60)
90+
if (hours < 24) {
91+
return `${hours} hour${hours === 1 ? '' : 's'} ago`
92+
}
93+
94+
const days = Math.floor(hours / 24)
95+
return `${days} day${days === 1 ? '' : 's'} ago`
96+
}

0 commit comments

Comments
 (0)