diff --git a/.changeset/app-bulk-status-json.md b/.changeset/app-bulk-status-json.md new file mode 100644 index 00000000000..089cd563f66 --- /dev/null +++ b/.changeset/app-bulk-status-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output to app bulk status. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 8585bf8b9e2..3c454479b9a 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -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", @@ -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 '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: 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 '?: 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 '?: 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 '?: 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 '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: 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 '?: 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 '?: 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 '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appconfiglink": { diff --git a/packages/app/src/cli/commands/app/bulk/output.test.ts b/packages/app/src/cli/commands/app/bulk/output.test.ts new file mode 100644 index 00000000000..d3bd76637e6 --- /dev/null +++ b/packages/app/src/cli/commands/app/bulk/output.test.ts @@ -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()), + resolveApiVersion: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/session', async (importOriginal) => ({ + ...(await importOriginal()), + ensureAuthenticatedAdminAsApp: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/api/bulk-operations', async (importOriginal) => ({ + ...(await importOriginal()), + fetchBulkOperationById: vi.fn(), + fetchRecentBulkOperations: vi.fn(), +})) + +function bulkOperation(overrides: Partial = {}): 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') + }) +}) diff --git a/packages/app/src/cli/commands/app/bulk/status.test.ts b/packages/app/src/cli/commands/app/bulk/status.test.ts index c4fc061f7d2..2c8f37fef8d 100644 --- a/packages/app/src/cli/commands/app/bulk/status.test.ts +++ b/packages/app/src/cli/commands/app/bulk/status.test.ts @@ -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() @@ -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 () => { @@ -48,7 +53,6 @@ describe('app bulk status command', () => { }), ) expect(getBulkOperationStatus).toHaveBeenCalledWith({ - organization, storeFqdn: 'shop.myshopify.com', operationId: 'gid://shopify/BulkOperation/123', remoteApp, @@ -67,7 +71,6 @@ describe('app bulk status command', () => { }), ) expect(listBulkOperations).toHaveBeenCalledWith({ - organization, storeFqdn: 'shop.myshopify.com', remoteApp, }) diff --git a/packages/app/src/cli/commands/app/bulk/status.ts b/packages/app/src/cli/commands/app/bulk/status.ts index b8a8d0690b8..a20a63ae5ea 100644 --- a/packages/app/src/cli/commands/app/bulk/status.ts +++ b/packages/app/src/cli/commands/app/bulk/status.ts @@ -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' @@ -20,6 +23,7 @@ export default class BulkStatus extends AppLinkedCommand { static flags = { ...globalFlags, + ...jsonFlag, ...appFlags, id: Flags.string({ description: @@ -34,25 +38,29 @@ export default class BulkStatus extends AppLinkedCommand { }), } + static get jsonOutputSchema() { + return bulkOperationStatusJsonOutputSchema + } + async run(): Promise { 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} } 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 index e6ea52eb522..1e649d1e9bb 100644 --- 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 @@ -1,4 +1,6 @@ -import {getBulkOperationStatus, listBulkOperations} from './bulk-operation-status.js' +import {renderBulkOperationStatusResult} from './status-result.js' +import {logBulkOperationStart} from './progress.js' +import {getBulkOperationStatus as getStatus, listBulkOperations as listOperations} from './bulk-operation-status.js' import {OrganizationApp, Organization, OrganizationSource} from '../../models/organization.js' import {resolveApiVersion} from '../graphql/common.js' import {BULK_OPERATIONS_MIN_API_VERSION, type BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' @@ -17,6 +19,8 @@ vi.mock('../graphql/common.js', async () => { } }) +const originalExitCode = process.exitCode + const storeFqdn = 'test-store.myshopify.com' const operationId = 'gid://shopify/BulkOperation/123' const mockOrganization: Organization = { @@ -62,6 +66,20 @@ describe('getBulkOperationStatus', () => { } } + test('returns domain data without rendering terminal output', async () => { + const response = mockBulkOperation() + vi.mocked(adminRequestDoc).mockResolvedValue(response) + const output = mockAndCaptureOutput() + + await expect(getStatus({storeFqdn, operationId, remoteApp})).resolves.toEqual({ + store: storeFqdn, + apiVersion: '2026-01', + operationId, + operation: response.bulkOperation, + }) + expect(output.output()).toBe('') + }) + test('renders success banner for completed operation', async () => { vi.mocked(adminRequestDoc).mockResolvedValue( mockBulkOperation({ @@ -72,7 +90,7 @@ describe('getBulkOperationStatus', () => { ) const output = mockAndCaptureOutput() - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) expect(output.output()).toContain('Bulk operation succeeded:') expect(output.output()).toContain('100 objects') @@ -85,7 +103,7 @@ describe('getBulkOperationStatus', () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING', objectCount: 500})) const output = mockAndCaptureOutput() - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) expect(output.info()).toContain('Checking bulk operation status.') expect(output.info()).toContain('Bulk operation in progress') @@ -104,7 +122,7 @@ describe('getBulkOperationStatus', () => { ) const output = mockAndCaptureOutput() - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) expect(output.error()).toContain('Error: ACCESS_DENIED') expect(output.error()).toContain('Finished') @@ -115,8 +133,9 @@ describe('getBulkOperationStatus', () => { vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null}) const output = mockAndCaptureOutput() - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + expect(process.exitCode).toBe(originalExitCode) expect(output.error()).toContain('Bulk operation not found.') expect(output.error()).toContain(operationId) }) @@ -125,7 +144,7 @@ describe('getBulkOperationStatus', () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CREATED', objectCount: 0})) const output = mockAndCaptureOutput() - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) expect(output.info()).toContain('Starting') }) @@ -134,7 +153,7 @@ describe('getBulkOperationStatus', () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CANCELED'})) const output = mockAndCaptureOutput() - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) expect(output.info()).toContain('Bulk operation canceled.') }) @@ -142,7 +161,7 @@ describe('getBulkOperationStatus', () => { test('calls resolveApiVersion with minimum API version constant', async () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'})) - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) expect(resolveApiVersion).toHaveBeenCalledWith({ adminSession: {token: 'test-token', storeFqdn}, @@ -154,7 +173,7 @@ describe('getBulkOperationStatus', () => { vi.mocked(resolveApiVersion).mockResolvedValue('test-api-version') vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'})) - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) expect(adminRequestDoc).toHaveBeenCalledWith( expect.objectContaining({ @@ -167,7 +186,7 @@ describe('getBulkOperationStatus', () => { vi.mocked(resolveApiVersion).mockResolvedValue('test-api-version') vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'})) - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) expect(adminRequestDoc).toHaveBeenCalledWith( expect.objectContaining({ @@ -181,7 +200,7 @@ describe('getBulkOperationStatus', () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'})) const output = mockAndCaptureOutput() - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) expect(output.output()).toContain('Started') }) @@ -195,7 +214,7 @@ describe('getBulkOperationStatus', () => { ) const output = mockAndCaptureOutput() - await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + await getAndPresentBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) expect(output.output()).toContain('Finished') }) @@ -243,7 +262,7 @@ describe('listBulkOperations', () => { ) const output = mockAndCaptureOutput() - await listBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) + await listAndPresentBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) const outputLinesWithoutTrailingWhitespace = output .output() @@ -281,7 +300,7 @@ describe('listBulkOperations', () => { ) const output = mockAndCaptureOutput() - await listBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) + await listAndPresentBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) expect(output.output()).toContain('1.2M') expect(output.output()).toContain('5.5K') @@ -301,7 +320,7 @@ describe('listBulkOperations', () => { ) const output = mockAndCaptureOutput() - await listBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) + await listAndPresentBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) expect(output.output()).toContain('download') expect(output.output()).toContain('partial.jsonl') @@ -318,7 +337,7 @@ describe('listBulkOperations', () => { ) const output = mockAndCaptureOutput() - await listBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) + await listAndPresentBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) expect(output.output()).toContain('download') expect(output.output()).toContain('results.jsonl') @@ -328,7 +347,7 @@ describe('listBulkOperations', () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperationsList([])) const output = mockAndCaptureOutput() - await listBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) + await listAndPresentBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) expect(output.info()).toContain('Listing bulk operations.') expect(output.info()).toContain('No bulk operations found in the last 7 days.') @@ -337,7 +356,7 @@ describe('listBulkOperations', () => { test('calls resolveApiVersion with minimum API version constant', async () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperationsList([])) - await listBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) + await listAndPresentBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) expect(resolveApiVersion).toHaveBeenCalledWith({ adminSession: {token: 'test-token', storeFqdn}, @@ -349,7 +368,7 @@ describe('listBulkOperations', () => { vi.mocked(resolveApiVersion).mockResolvedValue('test-api-version') vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperationsList([])) - await listBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) + await listAndPresentBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) expect(adminRequestDoc).toHaveBeenCalledWith( expect.objectContaining({ @@ -362,7 +381,7 @@ describe('listBulkOperations', () => { vi.mocked(resolveApiVersion).mockResolvedValue('test-api-version') vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperationsList([])) - await listBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) + await listAndPresentBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) expect(adminRequestDoc).toHaveBeenCalledWith( expect.objectContaining({ @@ -371,3 +390,30 @@ describe('listBulkOperations', () => { ) }) }) + +async function getAndPresentBulkOperationStatus(input: Parameters[0] & {organization: Organization}) { + logBulkOperationStart('Checking bulk operation status.', input, 'text') + const result = await getStatus(input) + renderBulkOperationStatusResult(result, 'text') + return result +} + +async function listAndPresentBulkOperations( + input: Parameters[0] & {organization: Organization}, +) { + logBulkOperationStart('Listing bulk operations.', input, 'text') + const result = await listOperations(input) + renderBulkOperationStatusResult(result, 'text') + return result +} + +test('the list service returns an empty collection without printing it', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperations: {nodes: []}}) + const output = mockAndCaptureOutput() + await expect(listOperations({storeFqdn, remoteApp})).resolves.toEqual({ + store: storeFqdn, + apiVersion: '2026-01', + operations: [], + }) + expect(output.output()).toBe('') +}) 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 index df750616803..15d400d55de 100644 --- a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts +++ b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts @@ -1,167 +1,46 @@ -import {formatOperationInfo, resolveApiVersion} from '../graphql/common.js' -import {OrganizationApp, Organization} from '../../models/organization.js' +import {resolveApiVersion} from '../graphql/common.js' +import {OrganizationApp} from '../../models/organization.js' import { BULK_OPERATIONS_MIN_API_VERSION, fetchBulkOperationById, fetchRecentBulkOperations, - formatBulkOperationStatus, - extractBulkOperationId, - type BulkOperation, } from '@shopify/cli-kit/node/api/bulk-operations' -import {renderInfo, renderSuccess, renderError, renderTable} from '@shopify/cli-kit/node/ui' -import {outputContent, outputToken, outputNewline} from '@shopify/cli-kit/node/output' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' -import {timeAgo, formatDate} from '@shopify/cli-kit/common/string' import {BugError} from '@shopify/cli-kit/node/error' -import colors from '@shopify/cli-kit/node/colors' - -interface GetBulkOperationStatusOptions { - organization: Organization - storeFqdn: string - operationId: string - remoteApp: OrganizationApp -} +import type {BulkOperationStatusResult} from './types.js' interface ListBulkOperationsOptions { - organization: Organization storeFqdn: string remoteApp: OrganizationApp } -export async function getBulkOperationStatus(options: GetBulkOperationStatusOptions): Promise { - const {organization, storeFqdn, operationId, remoteApp} = options - - renderInfo({ - headline: 'Checking bulk operation status.', - body: [ - { - list: { - items: formatOperationInfo({organization, remoteApp, storeFqdn}), - }, - }, - ], - }) - - 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) +interface GetBulkOperationStatusOptions extends ListBulkOperationsOptions { + operationId: string +} - const operation = await fetchBulkOperationById({ - adminSession, - operationId, - version: await resolveApiVersion({ - adminSession, - minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, - }), - }) +export async function getBulkOperationStatus( + options: GetBulkOperationStatusOptions, +): Promise { + const {storeFqdn, operationId, remoteApp} = options + const {adminSession, version} = await prepareStatusContext(storeFqdn, remoteApp) + const operation = await fetchBulkOperationById({adminSession, operationId, version}) - if (operation) { - renderBulkOperationStatus(operation) - } else { - renderError({ - headline: 'Bulk operation not found.', - body: outputContent`ID: ${outputToken.yellow(operationId)}`.value, - }) - } + return {store: storeFqdn, apiVersion: version, operationId, operation: operation ?? null} } -export async function listBulkOperations(options: ListBulkOperationsOptions): Promise { - const {organization, storeFqdn, remoteApp} = options +export async function listBulkOperations(options: ListBulkOperationsOptions): Promise { + const {storeFqdn, remoteApp} = options + const {adminSession, version} = await prepareStatusContext(storeFqdn, remoteApp) + const operations = await fetchRecentBulkOperations({adminSession, version}) - renderInfo({ - headline: 'Listing bulk operations.', - body: [ - { - list: { - items: formatOperationInfo({organization, remoteApp, storeFqdn}), - }, - }, - ], - }) + return {store: storeFqdn, apiVersion: version, operations} +} +async function prepareStatusContext(storeFqdn: string, remoteApp: OrganizationApp) { 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 nodes = await fetchRecentBulkOperations({ - adminSession, - version: await resolveApiVersion({ - adminSession, - minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, - }), - }) - - const operations = nodes.map((operation) => ({ - id: extractBulkOperationId(operation.id), - status: formatStatus(operation.status), - count: formatCount(operation.objectCount as number), - dateCreated: formatDate(new Date(String(operation.createdAt))), - dateFinished: operation.completedAt ? formatDate(new Date(String(operation.completedAt))) : '', - results: downloadLink(operation.url ?? operation.partialDataUrl), - })) - - outputNewline() - - if (operations.length === 0) { - renderInfo({body: 'No bulk operations found in the last 7 days.'}) - } else { - renderTable({ - rows: operations, - columns: { - id: {header: 'ID', color: 'yellow'}, - status: {header: 'STATUS'}, - count: {header: 'COUNT'}, - dateCreated: {header: 'DATE CREATED', color: 'cyan'}, - dateFinished: {header: 'DATE FINISHED', color: 'cyan'}, - results: {header: 'RESULTS'}, - }, - }) - } - - outputNewline() -} - -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)}` - } -} - -function formatStatus(status: string): string { - if (status === 'COMPLETED') return colors.green(status) - if (status === 'FAILED') return colors.red(status) - return colors.dim(status) -} - -function formatCount(count: number): string { - if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M` - if (count >= 1000) return `${(count / 1000).toFixed(1)}K` - return String(count) -} - -function downloadLink(downloadUrl: string | null | undefined): string { - return downloadUrl ? outputContent`${outputToken.link('download', downloadUrl)}`.value : '' + const version = await resolveApiVersion({adminSession, minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION}) + return {adminSession, version} } diff --git a/packages/app/src/cli/services/bulk-operations/result.test.ts b/packages/app/src/cli/services/bulk-operations/result.test.ts new file mode 100644 index 00000000000..6a22e838bae --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/result.test.ts @@ -0,0 +1,16 @@ +import {testBulkOperation} from './bulk-operation.test-data.js' +import {renderBulkOperationStatusResult} from './status-result.js' +import {expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('@shopify/cli-kit/node/ui') + +test('does not invent a list operation type or claim completeness for a capped list', () => { + const output = mockAndCaptureOutput() + const {type: _type, ...operation} = testBulkOperation() + renderBulkOperationStatusResult({operations: Array.from({length: 100}, () => operation)}, 'json') + const result = JSON.parse(output.output()) + expect(result.operations[0]).not.toHaveProperty('type') + expect(result.operations[0].gid).toBe(operation.id) + expect(result.pageInfo).toEqual({hasNextPage: null}) +}) diff --git a/packages/app/src/cli/services/bulk-operations/status-result.ts b/packages/app/src/cli/services/bulk-operations/status-result.ts new file mode 100644 index 00000000000..43e446dd62f --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/status-result.ts @@ -0,0 +1,114 @@ +import {bulkOperationStatusJsonOutputSchema, type BulkOperationStatusResult} from './types.js' +import {bulkOperationJsonContext, toBulkOperationJson} from './json.js' +import { + formatBulkOperationStatus, + extractBulkOperationId, + type BulkOperation, +} from '@shopify/cli-kit/node/api/bulk-operations' +import {renderInfo, renderSuccess, renderError, renderTable} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken, outputNewline, outputResult} from '@shopify/cli-kit/node/output' +import {timeAgo, formatDate} from '@shopify/cli-kit/common/string' +import colors from '@shopify/cli-kit/node/colors' + +export function renderBulkOperationStatusResult(result: BulkOperationStatusResult, format: 'text' | 'json'): void { + if (format === 'json') { + outputResult( + bulkOperationStatusJsonOutputSchema.encode({ + ...bulkOperationJsonContext(result), + ...('operation' in result + ? { + operationGid: result.operationId, + operation: result.operation ? toBulkOperationJson(result.operation) : null, + } + : { + operations: result.operations.map(toBulkOperationJson), + pageInfo: {hasNextPage: result.operations.length < 100 ? false : null}, + }), + }), + ) + return + } + if ('operation' in result) { + if (result.operation) { + renderBulkOperationStatus(result.operation) + } else { + renderError({ + headline: 'Bulk operation not found.', + body: outputContent`ID: ${outputToken.yellow(result.operationId)}`.value, + }) + } + return + } + + const operations = result.operations.map((operation) => ({ + id: extractBulkOperationId(operation.id), + status: formatStatus(operation.status), + count: formatCount(operation.objectCount as number), + dateCreated: formatDate(new Date(String(operation.createdAt))), + dateFinished: operation.completedAt ? formatDate(new Date(String(operation.completedAt))) : '', + results: downloadLink(operation.url ?? operation.partialDataUrl), + })) + + outputNewline() + + if (operations.length === 0) { + renderInfo({body: 'No bulk operations found in the last 7 days.'}) + } else { + renderTable({ + rows: operations, + columns: { + id: {header: 'ID', color: 'yellow'}, + status: {header: 'STATUS'}, + count: {header: 'COUNT'}, + dateCreated: {header: 'DATE CREATED', color: 'cyan'}, + dateFinished: {header: 'DATE FINISHED', color: 'cyan'}, + results: {header: 'RESULTS'}, + }, + }) + } + + outputNewline() +} + +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)}` + } +} + +function formatStatus(status: string): string { + if (status === 'COMPLETED') return colors.green(status) + if (status === 'FAILED') return colors.red(status) + return colors.dim(status) +} + +function formatCount(count: number): string { + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M` + if (count >= 1000) return `${(count / 1000).toFixed(1)}K` + return String(count) +} + +function downloadLink(downloadUrl: string | null | undefined): string { + return downloadUrl ? outputContent`${outputToken.link('download', downloadUrl)}`.value : '' +} diff --git a/packages/app/src/cli/services/bulk-operations/types.test.ts b/packages/app/src/cli/services/bulk-operations/types.test.ts new file mode 100644 index 00000000000..09a10fb0bce --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/types.test.ts @@ -0,0 +1,16 @@ +import {bulkOperationStatusJsonOutputSchema} from './types.js' +import {toBulkOperationJson} from './json.js' +import {testBulkOperation} from './bulk-operation.test-data.js' +import {expect, test} from 'vitest' + +test('status result rejects unselected fields and lists without pagination metadata', () => { + const context = {storeDomain: 'shop.myshopify.com', apiVersion: '2026-01'} + const value = { + ...context, + operationGid: 'gid://shopify/BulkOperation/123', + operation: toBulkOperationJson(testBulkOperation()), + } + expect(() => bulkOperationStatusJsonOutputSchema.validate({...value, token: 'secret'})).toThrow() + expect(() => bulkOperationStatusJsonOutputSchema.validate({...context, operations: []})).toThrow() + expect(bulkOperationStatusJsonOutputSchema.jsonSchema.definitions?.BulkOperation).toBeDefined() +}) diff --git a/packages/app/src/cli/services/bulk-operations/types.ts b/packages/app/src/cli/services/bulk-operations/types.ts new file mode 100644 index 00000000000..1ca99816af9 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/types.ts @@ -0,0 +1,35 @@ +import {BulkOperationGidSchema, BulkOperationSchema, BulkOperationContextSchema} from './common.js' +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' +import type {BulkOperationContext} from './common.js' +import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' + +const ListedBulkOperationSchema = BulkOperationSchema.omit({type: true}) + +const GetBulkOperationStatusResultSchema = BulkOperationContextSchema.extend({ + operationGid: BulkOperationGidSchema, + operation: BulkOperationSchema.nullable(), +}) + +const ListBulkOperationsResultSchema = BulkOperationContextSchema.extend({ + operations: zod.array(ListedBulkOperationSchema), + pageInfo: zod + .object({ + hasNextPage: zod.boolean().nullable().describe('Unknown when the API result reaches the fetch limit.'), + }) + .strict(), +}) + +export const bulkOperationStatusJsonOutputSchema = defineJsonOutputSchema({ + name: 'BulkOperationStatusResult', + schema: zod.union([GetBulkOperationStatusResultSchema, ListBulkOperationsResultSchema]), + definitions: { + GetBulkOperationStatusResult: GetBulkOperationStatusResultSchema, + ListBulkOperationsResult: ListBulkOperationsResultSchema, + BulkOperation: BulkOperationSchema, + ListedBulkOperation: ListedBulkOperationSchema, + }, +}) + +export type BulkOperationStatusResult = BulkOperationContext & + ({operationId: string; operation: BulkOperation | null} | {operations: Omit[]}) diff --git a/packages/cli/README.md b/packages/cli/README.md index 0fcda30b8f6..360bbd1005e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -347,14 +347,18 @@ Check the status of bulk operations. ``` USAGE - $ shopify app bulk status [--auth-alias ] [--client-id | -c ] [--id ] [--json-schema] - [--no-color] [--no-input] [--path ] [--reset | ] [-s ] [--verbose] + $ shopify app bulk status [--auth-alias ] [--client-id | -c ] [--id ] [-j] + [--json-schema] [--no-color] [--no-input] [--path ] [--reset | ] [-s ] [--verbose] FLAGS -c, --config= The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -s, --store= The store domain. Must be an existing dev store. [env: SHOPIFY_FLAG_STORE] @@ -407,6 +411,228 @@ DESCRIPTION (https://shopify.dev/docs/api/usage/bulk-operations/imports). Use "`bulk execute`" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) to start a new bulk operation. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `BulkOperationStatusResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/GetBulkOperationStatusResult" + }, + { + "$ref": "#/definitions/ListBulkOperationsResult" + } + ], + "title": "BulkOperationStatusResult", + "definitions": { + "GetBulkOperationStatusResult": { + "type": "object", + "properties": { + "storeDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^[^.]+\\.myshopify\\.com$" + }, + { + "type": "null" + } + ] + }, + "apiVersion": { + "type": [ + "string", + "null" + ], + "description": "The API version selected for the request." + }, + "operationGid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/BulkOperation\\/[^/]+$", + "description": "The Shopify global ID of a bulk operation." + }, + "operation": { + "anyOf": [ + { + "$ref": "#/definitions/BulkOperation" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "storeDomain", + "apiVersion", + "operationGid", + "operation" + ], + "additionalProperties": false + }, + "ListBulkOperationsResult": { + "type": "object", + "properties": { + "storeDomain": { + "$ref": "#/definitions/GetBulkOperationStatusResult/properties/storeDomain" + }, + "apiVersion": { + "$ref": "#/definitions/GetBulkOperationStatusResult/properties/apiVersion" + }, + "operations": { + "type": "array", + "items": { + "$ref": "#/definitions/ListedBulkOperation" + } + }, + "pageInfo": { + "type": "object", + "properties": { + "hasNextPage": { + "type": [ + "boolean", + "null" + ], + "description": "Unknown when the API result reaches the fetch limit." + } + }, + "required": [ + "hasNextPage" + ], + "additionalProperties": false + } + }, + "required": [ + "storeDomain", + "apiVersion", + "operations", + "pageInfo" + ], + "additionalProperties": false + }, + "BulkOperation": { + "type": "object", + "properties": { + "gid": { + "$ref": "#/definitions/GetBulkOperationStatusResult/properties/operationGid" + }, + "type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "errorCode": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "completedAt": { + "anyOf": [ + { + "$ref": "#/definitions/BulkOperation/properties/createdAt" + }, + { + "type": "null" + } + ], + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "objectCount": { + "type": "string", + "pattern": "^\\d+$", + "description": "A nonnegative decimal count, without loss of integer precision." + }, + "url": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "partialDataUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "gid", + "type", + "status", + "errorCode", + "createdAt", + "completedAt", + "objectCount", + "url", + "partialDataUrl" + ], + "additionalProperties": false + }, + "ListedBulkOperation": { + "type": "object", + "properties": { + "gid": { + "$ref": "#/definitions/GetBulkOperationStatusResult/properties/operationGid" + }, + "status": { + "$ref": "#/definitions/BulkOperation/properties/status" + }, + "errorCode": { + "$ref": "#/definitions/BulkOperation/properties/errorCode" + }, + "createdAt": { + "$ref": "#/definitions/BulkOperation/properties/createdAt" + }, + "completedAt": { + "$ref": "#/definitions/BulkOperation/properties/completedAt" + }, + "objectCount": { + "$ref": "#/definitions/BulkOperation/properties/objectCount" + }, + "url": { + "$ref": "#/definitions/BulkOperation/properties/url" + }, + "partialDataUrl": { + "$ref": "#/definitions/BulkOperation/properties/partialDataUrl" + } + }, + "required": [ + "gid", + "status", + "errorCode", + "createdAt", + "completedAt", + "objectCount", + "url", + "partialDataUrl" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app config link` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9a714accbc2..004cc18efea 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -407,7 +407,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Check the status of a specific bulk operation by ID, or list all bulk operations belonging to this app on this store in the last 7 days.\n\n Bulk operations allow you to process large amounts of data asynchronously. Learn more about \"bulk query operations\" (https://shopify.dev/docs/api/usage/bulk-operations/queries) and \"bulk mutation operations\" (https://shopify.dev/docs/api/usage/bulk-operations/imports).\n\n Use \"`bulk execute`\" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) to start a new bulk operation.", + "description": "Check the status of a specific bulk operation by ID, or list all bulk operations belonging to this app on this store in the last 7 days.\n\n Bulk operations allow you to process large amounts of data asynchronously. Learn more about \"bulk query operations\" (https://shopify.dev/docs/api/usage/bulk-operations/queries) and \"bulk mutation operations\" (https://shopify.dev/docs/api/usage/bulk-operations/imports).\n\n Use \"`bulk execute`\" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) to start a new bulk operation.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `BulkOperationStatusResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/GetBulkOperationStatusResult\"\n },\n {\n \"$ref\": \"#/definitions/ListBulkOperationsResult\"\n }\n ],\n \"title\": \"BulkOperationStatusResult\",\n \"definitions\": {\n \"GetBulkOperationStatusResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[^.]+\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"apiVersion\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"The API version selected for the request.\"\n },\n \"operationGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/BulkOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify global ID of a bulk operation.\"\n },\n \"operation\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/BulkOperation\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"storeDomain\",\n \"apiVersion\",\n \"operationGid\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n \"ListBulkOperationsResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"storeDomain\": {\n \"$ref\": \"#/definitions/GetBulkOperationStatusResult/properties/storeDomain\"\n },\n \"apiVersion\": {\n \"$ref\": \"#/definitions/GetBulkOperationStatusResult/properties/apiVersion\"\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ListedBulkOperation\"\n }\n },\n \"pageInfo\": {\n \"type\": \"object\",\n \"properties\": {\n \"hasNextPage\": {\n \"type\": [\n \"boolean\",\n \"null\"\n ],\n \"description\": \"Unknown when the API result reaches the fetch limit.\"\n }\n },\n \"required\": [\n \"hasNextPage\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"storeDomain\",\n \"apiVersion\",\n \"operations\",\n \"pageInfo\"\n ],\n \"additionalProperties\": false\n },\n \"BulkOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"$ref\": \"#/definitions/GetBulkOperationStatusResult/properties/operationGid\"\n },\n \"type\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\"\n },\n \"errorCode\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"createdAt\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\",\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"completedAt\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/BulkOperation/properties/createdAt\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"objectCount\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"A nonnegative decimal count, without loss of integer precision.\"\n },\n \"url\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"partialDataUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"gid\",\n \"type\",\n \"status\",\n \"errorCode\",\n \"createdAt\",\n \"completedAt\",\n \"objectCount\",\n \"url\",\n \"partialDataUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ListedBulkOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"$ref\": \"#/definitions/GetBulkOperationStatusResult/properties/operationGid\"\n },\n \"status\": {\n \"$ref\": \"#/definitions/BulkOperation/properties/status\"\n },\n \"errorCode\": {\n \"$ref\": \"#/definitions/BulkOperation/properties/errorCode\"\n },\n \"createdAt\": {\n \"$ref\": \"#/definitions/BulkOperation/properties/createdAt\"\n },\n \"completedAt\": {\n \"$ref\": \"#/definitions/BulkOperation/properties/completedAt\"\n },\n \"objectCount\": {\n \"$ref\": \"#/definitions/BulkOperation/properties/objectCount\"\n },\n \"url\": {\n \"$ref\": \"#/definitions/BulkOperation/properties/url\"\n },\n \"partialDataUrl\": {\n \"$ref\": \"#/definitions/BulkOperation/properties/partialDataUrl\"\n }\n },\n \"required\": [\n \"gid\",\n \"status\",\n \"errorCode\",\n \"createdAt\",\n \"completedAt\",\n \"objectCount\",\n \"url\",\n \"partialDataUrl\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Check the status of a specific bulk operation by ID, or list all bulk operations belonging to this app on this store in the last 7 days.\n\n Bulk operations allow you to process large amounts of data asynchronously. Learn more about [bulk query operations](https://shopify.dev/docs/api/usage/bulk-operations/queries) and [bulk mutation operations](https://shopify.dev/docs/api/usage/bulk-operations/imports).\n\n Use [`bulk execute`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) to start a new bulk operation.", "flags": { "auth-alias": { @@ -448,6 +448,15 @@ "name": "id", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index f961d13c8e5..fc0d957f138 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -5,7 +5,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/build.ts', 'packages/app/src/cli/commands/app/bulk/cancel.ts', 'packages/app/src/cli/commands/app/bulk/execute.ts', - 'packages/app/src/cli/commands/app/bulk/status.ts', 'packages/app/src/cli/commands/app/config/link.ts', 'packages/app/src/cli/commands/app/config/pull.ts', 'packages/app/src/cli/commands/app/config/use.ts',