Skip to content

Commit bce54f6

Browse files
Implement shopify app bulk status subcommand
Allows users to check the status of a bulk operation by ID.
1 parent 70c69c7 commit bce54f6

File tree

9 files changed

+449
-0
lines changed

9 files changed

+449
-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: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
remoteApp: appContextResult.remoteApp,
53+
})
54+
55+
return {app: appContextResult.app}
56+
}
57+
}

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: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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 {OrganizationApp} from '../../models/organization.js'
4+
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
5+
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
6+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
7+
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
8+
9+
vi.mock('@shopify/cli-kit/node/session')
10+
vi.mock('@shopify/cli-kit/node/api/admin')
11+
12+
const storeFqdn = 'test-store.myshopify.com'
13+
const operationId = 'gid://shopify/BulkOperation/123'
14+
const remoteApp = {
15+
id: '123',
16+
title: 'Test App',
17+
apiKey: 'test-key',
18+
organizationId: 'org-123',
19+
apiSecretKeys: [{secret: 'test-secret'}],
20+
grantedScopes: [],
21+
flags: [],
22+
developerPlatformClient: {} as any,
23+
} as OrganizationApp
24+
25+
beforeEach(() => {
26+
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({token: 'test-token', storeFqdn})
27+
})
28+
29+
afterEach(() => {
30+
mockAndCaptureOutput().clear()
31+
})
32+
33+
describe('getBulkOperationStatus', () => {
34+
function mockBulkOperation(
35+
overrides?: Partial<NonNullable<GetBulkOperationByIdQuery['bulkOperation']>>,
36+
): GetBulkOperationByIdQuery {
37+
return {
38+
bulkOperation: {
39+
id: operationId,
40+
status: 'RUNNING',
41+
errorCode: null,
42+
objectCount: 100,
43+
createdAt: new Date(Date.now() - 120000).toISOString(),
44+
completedAt: null,
45+
url: null,
46+
partialDataUrl: null,
47+
...overrides,
48+
},
49+
}
50+
}
51+
52+
test('renders success banner for completed operation', async () => {
53+
vi.mocked(adminRequestDoc).mockResolvedValue(
54+
mockBulkOperation({
55+
status: 'COMPLETED',
56+
completedAt: new Date(Date.now() - 60000).toISOString(),
57+
url: 'https://example.com/results.jsonl',
58+
}),
59+
)
60+
61+
const output = mockAndCaptureOutput()
62+
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})
63+
64+
expect(output.output()).toContain('Bulk operation succeeded:')
65+
expect(output.output()).toContain('100 objects')
66+
expect(output.output()).toContain(operationId)
67+
expect(output.output()).toContain('Finished')
68+
expect(output.output()).toContain('Download results')
69+
})
70+
71+
test('renders info banner for running operation', async () => {
72+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING', objectCount: 500}))
73+
74+
const output = mockAndCaptureOutput()
75+
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})
76+
77+
expect(output.info()).toContain('Bulk operation in progress...')
78+
expect(output.info()).toContain('500 objects')
79+
expect(output.info()).toContain('Started')
80+
})
81+
82+
test('renders error banner for failed operation', async () => {
83+
vi.mocked(adminRequestDoc).mockResolvedValue(
84+
mockBulkOperation({
85+
status: 'FAILED',
86+
errorCode: 'ACCESS_DENIED',
87+
completedAt: new Date(Date.now() - 60000).toISOString(),
88+
partialDataUrl: 'https://example.com/partial.jsonl',
89+
}),
90+
)
91+
92+
const output = mockAndCaptureOutput()
93+
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})
94+
95+
expect(output.error()).toContain('Error: ACCESS_DENIED')
96+
expect(output.error()).toContain('Finished')
97+
expect(output.error()).toContain('Download partial results')
98+
})
99+
100+
test('renders error banner when operation not found', async () => {
101+
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null})
102+
103+
const output = mockAndCaptureOutput()
104+
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})
105+
106+
expect(output.error()).toContain('Bulk operation not found.')
107+
expect(output.error()).toContain(operationId)
108+
})
109+
110+
test('renders info banner for created operation', async () => {
111+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CREATED', objectCount: 0}))
112+
113+
const output = mockAndCaptureOutput()
114+
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})
115+
116+
expect(output.info()).toContain('Starting...')
117+
})
118+
119+
test('renders info banner for canceled operation', async () => {
120+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CANCELED'}))
121+
122+
const output = mockAndCaptureOutput()
123+
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})
124+
125+
expect(output.info()).toContain('Bulk operation canceled.')
126+
})
127+
128+
describe('time formatting', () => {
129+
test('uses "Started" for running operations', async () => {
130+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'}))
131+
132+
const output = mockAndCaptureOutput()
133+
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})
134+
135+
expect(output.output()).toContain('Started')
136+
})
137+
138+
test('uses "Finished" for completed operations', async () => {
139+
vi.mocked(adminRequestDoc).mockResolvedValue(
140+
mockBulkOperation({
141+
status: 'COMPLETED',
142+
completedAt: new Date(Date.now() - 60000).toISOString(),
143+
}),
144+
)
145+
146+
const output = mockAndCaptureOutput()
147+
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})
148+
149+
expect(output.output()).toContain('Finished')
150+
})
151+
})
152+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {BulkOperation} from './watch-bulk-operation.js'
2+
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
3+
import {
4+
GetBulkOperationById,
5+
GetBulkOperationByIdQuery,
6+
} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
7+
import {OrganizationApp} from '../../models/organization.js'
8+
import {renderInfo, renderSuccess, renderError} from '@shopify/cli-kit/node/ui'
9+
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
10+
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
11+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
12+
import {timeAgo} from '@shopify/cli-kit/common/string'
13+
import {BugError} from '@shopify/cli-kit/node/error'
14+
15+
const API_VERSION = '2026-01'
16+
17+
interface GetBulkOperationStatusOptions {
18+
storeFqdn: string
19+
operationId: string
20+
remoteApp: OrganizationApp
21+
}
22+
23+
export async function getBulkOperationStatus(options: GetBulkOperationStatusOptions): Promise<void> {
24+
const {storeFqdn, operationId, remoteApp} = options
25+
26+
const appSecret = remoteApp.apiSecretKeys[0]?.secret
27+
if (!appSecret) throw new BugError('No API secret keys found for app')
28+
29+
const adminSession = await ensureAuthenticatedAdminAsApp(storeFqdn, remoteApp.apiKey, appSecret)
30+
31+
const response = await adminRequestDoc<GetBulkOperationByIdQuery, {id: string}>({
32+
query: GetBulkOperationById,
33+
session: adminSession,
34+
variables: {id: operationId},
35+
version: API_VERSION,
36+
})
37+
38+
if (response.bulkOperation) {
39+
renderBulkOperationStatus(response.bulkOperation)
40+
} else {
41+
renderError({
42+
headline: 'Bulk operation not found.',
43+
body: outputContent`ID: ${outputToken.yellow(operationId)}`.value,
44+
})
45+
}
46+
}
47+
48+
function renderBulkOperationStatus(operation: BulkOperation): void {
49+
const {id, status, createdAt, completedAt, url, partialDataUrl} = operation
50+
const statusDescription = formatBulkOperationStatus(operation).value
51+
const timeDifference = formatTimeDifference(createdAt, completedAt)
52+
const operationInfo = outputContent`ID: ${outputToken.yellow(id)}\n${timeDifference}`.value
53+
54+
if (status === 'COMPLETED') {
55+
const downloadLink = url ? outputToken.link('Download results', url) : ''
56+
renderSuccess({headline: statusDescription, body: outputContent`${operationInfo}\n${downloadLink}`.value})
57+
} else if (status === 'FAILED') {
58+
const downloadLink = partialDataUrl ? outputToken.link('Download partial results', partialDataUrl) : ''
59+
renderError({headline: statusDescription, body: outputContent`${operationInfo}\n${downloadLink}`.value})
60+
} else {
61+
renderInfo({headline: statusDescription, body: operationInfo})
62+
}
63+
}
64+
65+
function formatTimeDifference(createdAt: unknown, completedAt?: unknown): string {
66+
const now = new Date()
67+
68+
if (completedAt) {
69+
return `Finished ${timeAgo(new Date(String(completedAt)), now)}`
70+
} else {
71+
return `Started ${timeAgo(new Date(String(createdAt)), now)}`
72+
}
73+
}

packages/cli-kit/src/public/common/string.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
linesToColumns,
77
normalizeDelimitedString,
88
pluralize,
9+
timeAgo,
910
tryParseInt,
1011
} from './string.js'
1112
import {describe, expect, test} from 'vitest'
@@ -200,3 +201,43 @@ describe('normalizeDelimitedString', () => {
200201
expect(result).toEqual('read_products,write_products')
201202
})
202203
})
204+
205+
describe('timeAgo', () => {
206+
const second = 1000
207+
const minute = 60 * second
208+
const hour = 60 * minute
209+
const day = 24 * hour
210+
const now = new Date(0)
211+
212+
test('formats seconds (singular)', () => {
213+
expect(timeAgo(new Date(now.getTime() - second), now)).toBe('1 second ago')
214+
})
215+
216+
test('formats seconds (plural)', () => {
217+
expect(timeAgo(new Date(now.getTime() - 30 * second), now)).toBe('30 seconds ago')
218+
})
219+
220+
test('formats minutes (singular)', () => {
221+
expect(timeAgo(new Date(now.getTime() - minute), now)).toBe('1 minute ago')
222+
})
223+
224+
test('formats minutes (plural)', () => {
225+
expect(timeAgo(new Date(now.getTime() - 3 * minute), now)).toBe('3 minutes ago')
226+
})
227+
228+
test('formats hours (singular)', () => {
229+
expect(timeAgo(new Date(now.getTime() - hour), now)).toBe('1 hour ago')
230+
})
231+
232+
test('formats hours (plural)', () => {
233+
expect(timeAgo(new Date(now.getTime() - 5 * hour), now)).toBe('5 hours ago')
234+
})
235+
236+
test('formats days (singular)', () => {
237+
expect(timeAgo(new Date(now.getTime() - day), now)).toBe('1 day ago')
238+
})
239+
240+
test('formats days (plural)', () => {
241+
expect(timeAgo(new Date(now.getTime() - 7 * day), now)).toBe('7 days ago')
242+
})
243+
})

0 commit comments

Comments
 (0)