From bce54f62338bf305adf8bd8574a8e0ed877b5da8 Mon Sep 17 00:00:00 2001 From: Jordan Verasamy Date: Tue, 25 Nov 2025 13:02:49 -0800 Subject: [PATCH] Implement `shopify app bulk status` subcommand Allows users to check the status of a bulk operation by ID. --- .../generated/get-bulk-operation-by-id.ts | 2 + .../queries/get-bulk-operation-by-id.graphql | 1 + .../app/src/cli/commands/app/bulk/status.ts | 57 +++++++ packages/app/src/cli/index.ts | 2 + .../bulk-operation-status.test.ts | 152 ++++++++++++++++++ .../bulk-operations/bulk-operation-status.ts | 73 +++++++++ .../cli-kit/src/public/common/string.test.ts | 41 +++++ packages/cli-kit/src/public/common/string.ts | 25 +++ packages/cli/oclif.manifest.json | 96 +++++++++++ 9 files changed, 449 insertions(+) create mode 100644 packages/app/src/cli/commands/app/bulk/status.ts create mode 100644 packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts create mode 100644 packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts b/packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts index 5aa93868015..6dea7556368 100644 --- a/packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts @@ -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 @@ -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'}}, diff --git a/packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql b/packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql index 39135618241..f925b656df6 100644 --- a/packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql +++ b/packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql @@ -5,6 +5,7 @@ query GetBulkOperationById($id: ID!) { errorCode id objectCount + partialDataUrl status url } diff --git a/packages/app/src/cli/commands/app/bulk/status.ts b/packages/app/src/cli/commands/app/bulk/status.ts new file mode 100644 index 00000000000..a8af5e95811 --- /dev/null +++ b/packages/app/src/cli/commands/app/bulk/status.ts @@ -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 { + 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} + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index 594741f4da9..2d32a4510cc 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -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' @@ -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, diff --git a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts new file mode 100644 index 00000000000..4251c3ff9de --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts @@ -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>, + ): 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') + }) + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts new file mode 100644 index 00000000000..dc2f50d661c --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts @@ -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 { + 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({ + 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)}` + } +} diff --git a/packages/cli-kit/src/public/common/string.test.ts b/packages/cli-kit/src/public/common/string.test.ts index 1a249cb26ff..4ec1868592e 100644 --- a/packages/cli-kit/src/public/common/string.test.ts +++ b/packages/cli-kit/src/public/common/string.test.ts @@ -6,6 +6,7 @@ import { linesToColumns, normalizeDelimitedString, pluralize, + timeAgo, tryParseInt, } from './string.js' import {describe, expect, test} from 'vitest' @@ -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') + }) +}) diff --git a/packages/cli-kit/src/public/common/string.ts b/packages/cli-kit/src/public/common/string.ts index ca6e6bfe7fc..a1ba8fa62ec 100644 --- a/packages/cli-kit/src/public/common/string.ts +++ b/packages/cli-kit/src/public/common/string.ts @@ -418,3 +418,28 @@ export function normalizeDelimitedString(delimitedString?: string, delimiter = ' return uniqueSortedItems.join(delimiter) } + +/** + * Given two dates, it returns a human-readable string representing the time elapsed between them. + * + * @param from - Start date. + * @param to - End date. + * @returns A string like "5 minutes ago" or "2 days ago". + */ +export function timeAgo(from: Date, to: Date): string { + const seconds = Math.floor((to.getTime() - from.getTime()) / 1000) + if (seconds < 60) return `${formatTimeUnit(seconds, 'second')} ago` + + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${formatTimeUnit(minutes, 'minute')} ago` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${formatTimeUnit(hours, 'hour')} ago` + + const days = Math.floor(hours / 24) + return `${formatTimeUnit(days, 'day')} ago` +} + +function formatTimeUnit(count: number, unit: string): string { + return `${count} ${unit}${count === 1 ? '' : 's'}` +} diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 8876864fe9b..88c77c64647 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -86,6 +86,102 @@ "strict": true, "summary": "Build the app, including extensions." }, + "app:bulk:status": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Check the status of a bulk operation by ID.", + "flags": { + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "id": { + "description": "The bulk operation ID.", + "env": "SHOPIFY_FLAG_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "id", + "required": true, + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "store": { + "char": "s", + "description": "The store domain. Must be an existing dev store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "app:bulk:status", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Check the status of a bulk operation." + }, "app:config:link": { "aliases": [ ],