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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type GetBulkOperationByIdQuery = {
errorCode?: Types.BulkOperationErrorCode | null
id: string
objectCount: unknown
partialDataUrl?: string | null
status: Types.BulkOperationStatus
url?: string | null
} | null
Expand Down Expand Up @@ -54,6 +55,7 @@ export const GetBulkOperationById = {
{kind: 'Field', name: {kind: 'Name', value: 'errorCode'}},
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
{kind: 'Field', name: {kind: 'Name', value: 'objectCount'}},
{kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}},
{kind: 'Field', name: {kind: 'Name', value: 'status'}},
{kind: 'Field', name: {kind: 'Name', value: 'url'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ query GetBulkOperationById($id: ID!) {
errorCode
id
objectCount
partialDataUrl
status
url
}
Expand Down
57 changes: 57 additions & 0 deletions packages/app/src/cli/commands/app/bulk/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {appFlags} from '../../../flags.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {storeContext} from '../../../services/store-context.js'
import {getBulkOperationStatus} from '../../../services/bulk-operations/bulk-operation-status.js'
import {Flags} from '@oclif/core'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'

export default class BulkStatus extends AppLinkedCommand {
static summary = 'Check the status of a bulk operation.'

static description = 'Check the status of a bulk operation by ID.'

static hidden = true

static flags = {
...globalFlags,
...appFlags,
id: Flags.string({
description: 'The bulk operation ID.',
env: 'SHOPIFY_FLAG_ID',
required: true,
}),
store: Flags.string({
char: 's',
description: 'The store domain. Must be an existing dev store.',
env: 'SHOPIFY_FLAG_STORE',
parse: async (input) => normalizeStoreFqdn(input),
}),
}

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

const appContextResult = await linkedAppContext({
directory: flags.path,
clientId: flags['client-id'],
forceRelink: flags.reset,
userProvidedConfigName: flags.config,
})

const store = await storeContext({
appContextResult,
storeFqdn: flags.store,
forceReselectStore: flags.reset,
})

await getBulkOperationStatus({
storeFqdn: store.shopDomain,
operationId: flags.id,
remoteApp: appContextResult.remoteApp,
})

return {app: appContextResult.app}
}
}
2 changes: 2 additions & 0 deletions packages/app/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Build from './commands/app/build.js'
import BulkStatus from './commands/app/bulk/status.js'
import ConfigLink from './commands/app/config/link.js'
import ConfigUse from './commands/app/config/use.js'
import DemoWatcher from './commands/app/demo/watcher.js'
Expand Down Expand Up @@ -36,6 +37,7 @@ import FunctionInfo from './commands/app/function/info.js'
*/
export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlinkedCommand} = {
'app:build': Build,
'app:bulk:status': BulkStatus,
'app:deploy': Deploy,
'app:dev': Dev,
'app:dev:clean': DevClean,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {getBulkOperationStatus} from './bulk-operation-status.js'
import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
import {OrganizationApp} from '../../models/organization.js'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'

vi.mock('@shopify/cli-kit/node/session')
vi.mock('@shopify/cli-kit/node/api/admin')

const storeFqdn = 'test-store.myshopify.com'
const operationId = 'gid://shopify/BulkOperation/123'
const remoteApp = {
id: '123',
title: 'Test App',
apiKey: 'test-key',
organizationId: 'org-123',
apiSecretKeys: [{secret: 'test-secret'}],
grantedScopes: [],
flags: [],
developerPlatformClient: {} as any,
} as OrganizationApp

beforeEach(() => {
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({token: 'test-token', storeFqdn})
})

afterEach(() => {
mockAndCaptureOutput().clear()
})

describe('getBulkOperationStatus', () => {
function mockBulkOperation(
overrides?: Partial<NonNullable<GetBulkOperationByIdQuery['bulkOperation']>>,
): GetBulkOperationByIdQuery {
return {
bulkOperation: {
id: operationId,
status: 'RUNNING',
errorCode: null,
objectCount: 100,
createdAt: new Date(Date.now() - 120000).toISOString(),
completedAt: null,
url: null,
partialDataUrl: null,
...overrides,
},
}
}

test('renders success banner for completed operation', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(
mockBulkOperation({
status: 'COMPLETED',
completedAt: new Date(Date.now() - 60000).toISOString(),
url: 'https://example.com/results.jsonl',
}),
)

const output = mockAndCaptureOutput()
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})

expect(output.output()).toContain('Bulk operation succeeded:')
expect(output.output()).toContain('100 objects')
expect(output.output()).toContain(operationId)
expect(output.output()).toContain('Finished')
expect(output.output()).toContain('Download results')
})

test('renders info banner for running operation', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING', objectCount: 500}))

const output = mockAndCaptureOutput()
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})

expect(output.info()).toContain('Bulk operation in progress...')
expect(output.info()).toContain('500 objects')
expect(output.info()).toContain('Started')
})

test('renders error banner for failed operation', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(
mockBulkOperation({
status: 'FAILED',
errorCode: 'ACCESS_DENIED',
completedAt: new Date(Date.now() - 60000).toISOString(),
partialDataUrl: 'https://example.com/partial.jsonl',
}),
)

const output = mockAndCaptureOutput()
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})

expect(output.error()).toContain('Error: ACCESS_DENIED')
expect(output.error()).toContain('Finished')
expect(output.error()).toContain('Download partial results')
})

test('renders error banner when operation not found', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null})

const output = mockAndCaptureOutput()
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})

expect(output.error()).toContain('Bulk operation not found.')
expect(output.error()).toContain(operationId)
})

test('renders info banner for created operation', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CREATED', objectCount: 0}))

const output = mockAndCaptureOutput()
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})

expect(output.info()).toContain('Starting...')
})

test('renders info banner for canceled operation', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CANCELED'}))

const output = mockAndCaptureOutput()
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})

expect(output.info()).toContain('Bulk operation canceled.')
})

describe('time formatting', () => {
test('uses "Started" for running operations', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'}))

const output = mockAndCaptureOutput()
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})

expect(output.output()).toContain('Started')
})

test('uses "Finished" for completed operations', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(
mockBulkOperation({
status: 'COMPLETED',
completedAt: new Date(Date.now() - 60000).toISOString(),
}),
)

const output = mockAndCaptureOutput()
await getBulkOperationStatus({storeFqdn, operationId, remoteApp})

expect(output.output()).toContain('Finished')
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {BulkOperation} from './watch-bulk-operation.js'
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
import {
GetBulkOperationById,
GetBulkOperationByIdQuery,
} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
import {OrganizationApp} from '../../models/organization.js'
import {renderInfo, renderSuccess, renderError} from '@shopify/cli-kit/node/ui'
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
import {timeAgo} from '@shopify/cli-kit/common/string'
import {BugError} from '@shopify/cli-kit/node/error'

const API_VERSION = '2026-01'

interface GetBulkOperationStatusOptions {
storeFqdn: string
operationId: string
remoteApp: OrganizationApp
}

export async function getBulkOperationStatus(options: GetBulkOperationStatusOptions): Promise<void> {
const {storeFqdn, operationId, remoteApp} = options

const appSecret = remoteApp.apiSecretKeys[0]?.secret
if (!appSecret) throw new BugError('No API secret keys found for app')

const adminSession = await ensureAuthenticatedAdminAsApp(storeFqdn, remoteApp.apiKey, appSecret)

const response = await adminRequestDoc<GetBulkOperationByIdQuery, {id: string}>({
query: GetBulkOperationById,
session: adminSession,
variables: {id: operationId},
version: API_VERSION,
})

if (response.bulkOperation) {
renderBulkOperationStatus(response.bulkOperation)
} else {
renderError({
headline: 'Bulk operation not found.',
body: outputContent`ID: ${outputToken.yellow(operationId)}`.value,
})
}
}

function renderBulkOperationStatus(operation: BulkOperation): void {
const {id, status, createdAt, completedAt, url, partialDataUrl} = operation
const statusDescription = formatBulkOperationStatus(operation).value
const timeDifference = formatTimeDifference(createdAt, completedAt)
const operationInfo = outputContent`ID: ${outputToken.yellow(id)}\n${timeDifference}`.value

if (status === 'COMPLETED') {
const downloadLink = url ? outputToken.link('Download results', url) : ''
renderSuccess({headline: statusDescription, body: outputContent`${operationInfo}\n${downloadLink}`.value})
} else if (status === 'FAILED') {
const downloadLink = partialDataUrl ? outputToken.link('Download partial results', partialDataUrl) : ''
renderError({headline: statusDescription, body: outputContent`${operationInfo}\n${downloadLink}`.value})
} else {
renderInfo({headline: statusDescription, body: operationInfo})
}
}

function formatTimeDifference(createdAt: unknown, completedAt?: unknown): string {
const now = new Date()

if (completedAt) {
return `Finished ${timeAgo(new Date(String(completedAt)), now)}`
} else {
return `Started ${timeAgo(new Date(String(createdAt)), now)}`
}
}
41 changes: 41 additions & 0 deletions packages/cli-kit/src/public/common/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
linesToColumns,
normalizeDelimitedString,
pluralize,
timeAgo,
tryParseInt,
} from './string.js'
import {describe, expect, test} from 'vitest'
Expand Down Expand Up @@ -200,3 +201,43 @@ describe('normalizeDelimitedString', () => {
expect(result).toEqual('read_products,write_products')
})
})

describe('timeAgo', () => {
const second = 1000
const minute = 60 * second
const hour = 60 * minute
const day = 24 * hour
const now = new Date(0)

test('formats seconds (singular)', () => {
expect(timeAgo(new Date(now.getTime() - second), now)).toBe('1 second ago')
})

test('formats seconds (plural)', () => {
expect(timeAgo(new Date(now.getTime() - 30 * second), now)).toBe('30 seconds ago')
})

test('formats minutes (singular)', () => {
expect(timeAgo(new Date(now.getTime() - minute), now)).toBe('1 minute ago')
})

test('formats minutes (plural)', () => {
expect(timeAgo(new Date(now.getTime() - 3 * minute), now)).toBe('3 minutes ago')
})

test('formats hours (singular)', () => {
expect(timeAgo(new Date(now.getTime() - hour), now)).toBe('1 hour ago')
})

test('formats hours (plural)', () => {
expect(timeAgo(new Date(now.getTime() - 5 * hour), now)).toBe('5 hours ago')
})

test('formats days (singular)', () => {
expect(timeAgo(new Date(now.getTime() - day), now)).toBe('1 day ago')
})

test('formats days (plural)', () => {
expect(timeAgo(new Date(now.getTime() - 7 * day), now)).toBe('7 days ago')
})
})
Loading