From 374be176c133a7c829ecb05a2dd03c5ddc48059e Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:56:08 -0400 Subject: [PATCH 1/2] Add `shopify store bulk` commands (execute, status, cancel) Mirrors `app bulk` commands using user auth instead of app credentials. These commands don't require an app to be linked or installed on the target store. Co-Authored-By: Claude Opus 4.6 --- .../cli/commands/store/bulk/cancel.test.ts | 51 ++++ .../app/src/cli/commands/store/bulk/cancel.ts | 37 +++ .../cli/commands/store/bulk/execute.test.ts | 99 ++++++++ .../src/cli/commands/store/bulk/execute.ts | 38 +++ .../cli/commands/store/bulk/status.test.ts | 55 ++++ .../app/src/cli/commands/store/bulk/status.ts | 52 ++++ packages/app/src/cli/flags.ts | 52 ++++ packages/app/src/cli/index.ts | 6 + .../app/src/cli/services/graphql/common.ts | 18 ++ .../services/store-bulk-cancel-operation.ts | 74 ++++++ .../services/store-bulk-execute-operation.ts | 236 ++++++++++++++++++ .../services/store-bulk-operation-status.ts | 170 +++++++++++++ packages/cli/README.md | 95 +++++++ 13 files changed, 983 insertions(+) create mode 100644 packages/app/src/cli/commands/store/bulk/cancel.test.ts create mode 100644 packages/app/src/cli/commands/store/bulk/cancel.ts create mode 100644 packages/app/src/cli/commands/store/bulk/execute.test.ts create mode 100644 packages/app/src/cli/commands/store/bulk/execute.ts create mode 100644 packages/app/src/cli/commands/store/bulk/status.test.ts create mode 100644 packages/app/src/cli/commands/store/bulk/status.ts create mode 100644 packages/app/src/cli/services/store-bulk-cancel-operation.ts create mode 100644 packages/app/src/cli/services/store-bulk-execute-operation.ts create mode 100644 packages/app/src/cli/services/store-bulk-operation-status.ts diff --git a/packages/app/src/cli/commands/store/bulk/cancel.test.ts b/packages/app/src/cli/commands/store/bulk/cancel.test.ts new file mode 100644 index 00000000000..0377bf4ea5f --- /dev/null +++ b/packages/app/src/cli/commands/store/bulk/cancel.test.ts @@ -0,0 +1,51 @@ +import StoreBulkCancel from './cancel.js' +import {storeCancelBulkOperation} from '../../../services/store-bulk-cancel-operation.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../../services/store-bulk-cancel-operation.js') + +describe('store bulk cancel command', () => { + test('requires --store flag', async () => { + await expect( + StoreBulkCancel.run(['--id', '123'], import.meta.url), + ).rejects.toThrow() + + expect(storeCancelBulkOperation).not.toHaveBeenCalled() + }) + + test('requires --id flag', async () => { + await expect( + StoreBulkCancel.run(['--store', 'test-store.myshopify.com'], import.meta.url), + ).rejects.toThrow() + + expect(storeCancelBulkOperation).not.toHaveBeenCalled() + }) + + test('calls storeCancelBulkOperation with correct arguments', async () => { + vi.mocked(storeCancelBulkOperation).mockResolvedValue() + + await StoreBulkCancel.run( + ['--store', 'test-store.myshopify.com', '--id', '123'], + import.meta.url, + ) + + expect(storeCancelBulkOperation).toHaveBeenCalledWith({ + storeFqdn: 'test-store.myshopify.com', + operationId: 'gid://shopify/BulkOperation/123', + }) + }) + + test('accepts full GID format for --id', async () => { + vi.mocked(storeCancelBulkOperation).mockResolvedValue() + + await StoreBulkCancel.run( + ['--store', 'test-store.myshopify.com', '--id', 'gid://shopify/BulkOperation/456'], + import.meta.url, + ) + + expect(storeCancelBulkOperation).toHaveBeenCalledWith({ + storeFqdn: 'test-store.myshopify.com', + operationId: 'gid://shopify/BulkOperation/456', + }) + }) +}) diff --git a/packages/app/src/cli/commands/store/bulk/cancel.ts b/packages/app/src/cli/commands/store/bulk/cancel.ts new file mode 100644 index 00000000000..6671cab5494 --- /dev/null +++ b/packages/app/src/cli/commands/store/bulk/cancel.ts @@ -0,0 +1,37 @@ +import {storeCancelBulkOperation} from '../../../services/store-bulk-cancel-operation.js' +import {normalizeBulkOperationId} 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' +import BaseCommand from '@shopify/cli-kit/node/base-command' + +export default class StoreBulkCancel extends BaseCommand { + static summary = 'Cancel a bulk operation on a store.' + + static description = 'Cancels a running bulk operation by ID, authenticated as the current user.' + + static flags = { + ...globalFlags, + id: Flags.string({ + description: 'The bulk operation ID to cancel (numeric ID or full GID).', + env: 'SHOPIFY_FLAG_ID', + required: true, + }), + store: Flags.string({ + char: 's', + description: 'The myshopify.com domain of the store.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + required: true, + }), + } + + async run(): Promise { + const {flags} = await this.parse(StoreBulkCancel) + + await storeCancelBulkOperation({ + storeFqdn: flags.store, + operationId: normalizeBulkOperationId(flags.id), + }) + } +} diff --git a/packages/app/src/cli/commands/store/bulk/execute.test.ts b/packages/app/src/cli/commands/store/bulk/execute.test.ts new file mode 100644 index 00000000000..d454759e419 --- /dev/null +++ b/packages/app/src/cli/commands/store/bulk/execute.test.ts @@ -0,0 +1,99 @@ +import StoreBulkExecute from './execute.js' +import {storeExecuteBulkOperation} from '../../../services/store-bulk-execute-operation.js' +import {loadQuery} from '../../../utilities/execute-command-helpers.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../../services/store-bulk-execute-operation.js') +vi.mock('../../../utilities/execute-command-helpers.js') + +describe('store bulk execute command', () => { + test('requires --store flag', async () => { + vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }') + vi.mocked(storeExecuteBulkOperation).mockResolvedValue() + + await expect( + StoreBulkExecute.run(['--query', 'query { shop { name } }'], import.meta.url), + ).rejects.toThrow() + + expect(storeExecuteBulkOperation).not.toHaveBeenCalled() + }) + + test('calls storeExecuteBulkOperation with correct arguments', async () => { + vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }') + vi.mocked(storeExecuteBulkOperation).mockResolvedValue() + + await StoreBulkExecute.run( + ['--store', 'test-store.myshopify.com', '--query', 'query { shop { name } }'], + import.meta.url, + ) + + expect(loadQuery).toHaveBeenCalledWith( + expect.objectContaining({query: 'query { shop { name } }'}), + ) + expect(storeExecuteBulkOperation).toHaveBeenCalledWith( + expect.objectContaining({ + storeFqdn: 'test-store.myshopify.com', + query: 'query { shop { name } }', + watch: false, + }), + ) + }) + + test('passes version flag when provided', async () => { + vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }') + vi.mocked(storeExecuteBulkOperation).mockResolvedValue() + + await StoreBulkExecute.run( + ['--store', 'test-store.myshopify.com', '--query', 'query { shop { name } }', '--version', '2024-01'], + import.meta.url, + ) + + expect(storeExecuteBulkOperation).toHaveBeenCalledWith( + expect.objectContaining({ + version: '2024-01', + }), + ) + }) + + test('passes watch and output-file flags when provided', async () => { + vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }') + vi.mocked(storeExecuteBulkOperation).mockResolvedValue() + + await StoreBulkExecute.run( + [ + '--store', 'test-store.myshopify.com', + '--query', 'query { shop { name } }', + '--watch', + '--output-file', '/tmp/out.jsonl', + ], + import.meta.url, + ) + + expect(storeExecuteBulkOperation).toHaveBeenCalledWith( + expect.objectContaining({ + watch: true, + outputFile: '/tmp/out.jsonl', + }), + ) + }) + + test('passes variables flag when provided', async () => { + vi.mocked(loadQuery).mockResolvedValue('mutation { productCreate { product { id } } }') + vi.mocked(storeExecuteBulkOperation).mockResolvedValue() + + await StoreBulkExecute.run( + [ + '--store', 'test-store.myshopify.com', + '--query', 'mutation { productCreate { product { id } } }', + '--variables', '{"input": {"title": "test"}}', + ], + import.meta.url, + ) + + expect(storeExecuteBulkOperation).toHaveBeenCalledWith( + expect.objectContaining({ + variables: ['{"input": {"title": "test"}}'], + }), + ) + }) +}) diff --git a/packages/app/src/cli/commands/store/bulk/execute.ts b/packages/app/src/cli/commands/store/bulk/execute.ts new file mode 100644 index 00000000000..d83da17d89f --- /dev/null +++ b/packages/app/src/cli/commands/store/bulk/execute.ts @@ -0,0 +1,38 @@ +import {storeBulkOperationFlags} from '../../../flags.js' +import {storeExecuteBulkOperation} from '../../../services/store-bulk-execute-operation.js' +import {loadQuery} from '../../../utilities/execute-command-helpers.js' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import BaseCommand from '@shopify/cli-kit/node/base-command' + +export default class StoreBulkExecute extends BaseCommand { + static summary = 'Execute bulk operations against a store.' + + static descriptionWithMarkdown = `Executes an Admin API GraphQL query or mutation on the specified store as a bulk operation, authenticated as the current user. + + Unlike [\`app bulk execute\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute), this command does not require an app to be linked or installed on the target store. + + 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). + + Use [\`store bulk status\`](https://shopify.dev/docs/api/shopify-cli/store/store-bulk-status) to check the status of your bulk operations.` + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + ...storeBulkOperationFlags, + } + + async run(): Promise { + const {flags} = await this.parse(StoreBulkExecute) + const query = await loadQuery(flags) + await storeExecuteBulkOperation({ + storeFqdn: flags.store, + query, + variables: flags.variables, + variableFile: flags['variable-file'], + watch: flags.watch ?? false, + outputFile: flags['output-file'], + ...(flags.version && {version: flags.version}), + }) + } +} diff --git a/packages/app/src/cli/commands/store/bulk/status.test.ts b/packages/app/src/cli/commands/store/bulk/status.test.ts new file mode 100644 index 00000000000..06b3abdadb8 --- /dev/null +++ b/packages/app/src/cli/commands/store/bulk/status.test.ts @@ -0,0 +1,55 @@ +import StoreBulkStatus from './status.js' +import {storeGetBulkOperationStatus, storeListBulkOperations} from '../../../services/store-bulk-operation-status.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../../services/store-bulk-operation-status.js') + +describe('store bulk status command', () => { + test('requires --store flag', async () => { + await expect(StoreBulkStatus.run([], import.meta.url)).rejects.toThrow() + + expect(storeGetBulkOperationStatus).not.toHaveBeenCalled() + expect(storeListBulkOperations).not.toHaveBeenCalled() + }) + + test('calls storeGetBulkOperationStatus when --id is provided', async () => { + vi.mocked(storeGetBulkOperationStatus).mockResolvedValue() + + await StoreBulkStatus.run( + ['--store', 'test-store.myshopify.com', '--id', '123'], + import.meta.url, + ) + + expect(storeGetBulkOperationStatus).toHaveBeenCalledWith({ + storeFqdn: 'test-store.myshopify.com', + operationId: 'gid://shopify/BulkOperation/123', + }) + }) + + test('calls storeListBulkOperations when --id is not provided', async () => { + vi.mocked(storeListBulkOperations).mockResolvedValue() + + await StoreBulkStatus.run( + ['--store', 'test-store.myshopify.com'], + import.meta.url, + ) + + expect(storeListBulkOperations).toHaveBeenCalledWith({ + storeFqdn: 'test-store.myshopify.com', + }) + }) + + test('accepts full GID format for --id', async () => { + vi.mocked(storeGetBulkOperationStatus).mockResolvedValue() + + await StoreBulkStatus.run( + ['--store', 'test-store.myshopify.com', '--id', 'gid://shopify/BulkOperation/456'], + import.meta.url, + ) + + expect(storeGetBulkOperationStatus).toHaveBeenCalledWith({ + storeFqdn: 'test-store.myshopify.com', + operationId: 'gid://shopify/BulkOperation/456', + }) + }) +}) diff --git a/packages/app/src/cli/commands/store/bulk/status.ts b/packages/app/src/cli/commands/store/bulk/status.ts new file mode 100644 index 00000000000..44f50a5cb13 --- /dev/null +++ b/packages/app/src/cli/commands/store/bulk/status.ts @@ -0,0 +1,52 @@ +import { + storeGetBulkOperationStatus, + storeListBulkOperations, +} from '../../../services/store-bulk-operation-status.js' +import {normalizeBulkOperationId} 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' +import BaseCommand from '@shopify/cli-kit/node/base-command' + +export default class StoreBulkStatus extends BaseCommand { + static summary = 'Check the status of bulk operations on a store.' + + static descriptionWithMarkdown = `Check the status of a specific bulk operation by ID, or list all bulk operations on this store in the last 7 days. + + Unlike [\`app bulk status\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status), this command does not require an app to be linked or installed on the target store. + + Use [\`store bulk execute\`](https://shopify.dev/docs/api/shopify-cli/store/store-bulk-execute) to start a new bulk operation.` + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + id: Flags.string({ + description: + 'The bulk operation ID (numeric ID or full GID). If not provided, lists all bulk operations on this store in the last 7 days.', + env: 'SHOPIFY_FLAG_ID', + }), + store: Flags.string({ + char: 's', + description: 'The myshopify.com domain of the store.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + required: true, + }), + } + + async run(): Promise { + const {flags} = await this.parse(StoreBulkStatus) + + if (flags.id) { + await storeGetBulkOperationStatus({ + storeFqdn: flags.store, + operationId: normalizeBulkOperationId(flags.id), + }) + } else { + await storeListBulkOperations({ + storeFqdn: flags.store, + }) + } + } +} diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index 55d8525c5dc..4955fe2039f 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -129,6 +129,58 @@ export const storeOperationFlags = { }), } +export const storeBulkOperationFlags = { + query: Flags.string({ + char: 'q', + description: 'The GraphQL query or mutation to run as a bulk operation.', + env: 'SHOPIFY_FLAG_QUERY', + required: false, + exactlyOne: ['query', 'query-file'], + }), + 'query-file': Flags.string({ + description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + env: 'SHOPIFY_FLAG_QUERY_FILE', + parse: async (input) => resolvePath(input), + exactlyOne: ['query', 'query-file'], + }), + variables: Flags.string({ + char: 'v', + description: + 'The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.', + env: 'SHOPIFY_FLAG_VARIABLES', + multiple: true, + exclusive: ['variable-file'], + }), + 'variable-file': Flags.string({ + description: + "Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.", + env: 'SHOPIFY_FLAG_VARIABLE_FILE', + parse: async (input) => resolvePath(input), + exclusive: ['variables'], + }), + store: Flags.string({ + char: 's', + description: 'The myshopify.com domain of the store to execute against.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + required: true, + }), + watch: Flags.boolean({ + description: 'Wait for bulk operation results before exiting. Defaults to false.', + env: 'SHOPIFY_FLAG_WATCH', + }), + 'output-file': Flags.string({ + description: + 'The file path where results should be written if --watch is specified. If not specified, results will be written to STDOUT.', + env: 'SHOPIFY_FLAG_OUTPUT_FILE', + dependsOn: ['watch'], + }), + version: Flags.string({ + description: 'The API version to use for the bulk operation. If not specified, uses the latest stable version.', + env: 'SHOPIFY_FLAG_VERSION', + }), +} + export const operationFlags = { query: Flags.string({ char: 'q', diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index 6d0233bd313..ebeb4814f9c 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -38,6 +38,9 @@ import FunctionInfo from './commands/app/function/info.js' import ImportCustomDataDefinitions from './commands/app/import-custom-data-definitions.js' import OrganizationList from './commands/organization/list.js' import StoreExecute from './commands/store/execute.js' +import StoreBulkExecute from './commands/store/bulk/execute.js' +import StoreBulkStatus from './commands/store/bulk/status.js' +import StoreBulkCancel from './commands/store/bulk/cancel.js' import BaseCommand from '@shopify/cli-kit/node/base-command' /** @@ -80,6 +83,9 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'demo:watcher': DemoWatcher, 'organization:list': OrganizationList, 'store:execute': StoreExecute, + 'store:bulk:execute': StoreBulkExecute, + 'store:bulk:status': StoreBulkStatus, + 'store:bulk:cancel': StoreBulkCancel, } export const AppSensitiveMetadataHook = gatherSensitiveMetadata diff --git a/packages/app/src/cli/services/graphql/common.ts b/packages/app/src/cli/services/graphql/common.ts index 12d237f9e1d..763df163cc0 100644 --- a/packages/app/src/cli/services/graphql/common.ts +++ b/packages/app/src/cli/services/graphql/common.ts @@ -115,6 +115,24 @@ export function formatOperationInfo(options: { return items } +/** + * Creates formatted info list items for store GraphQL operations (no org/app context). + * + * @param options - The operation context information + * @returns Array of formatted strings for display + */ +export function formatStoreOperationInfo(options: {storeFqdn: string; version?: string}): string[] { + const {storeFqdn, version} = options + + const items = [`Store: ${storeFqdn}`] + + if (version) { + items.push(`API version: ${version}`) + } + + return items +} + /** * Checks if a GraphQL operation is a mutation. * diff --git a/packages/app/src/cli/services/store-bulk-cancel-operation.ts b/packages/app/src/cli/services/store-bulk-cancel-operation.ts new file mode 100644 index 00000000000..50ae49deedb --- /dev/null +++ b/packages/app/src/cli/services/store-bulk-cancel-operation.ts @@ -0,0 +1,74 @@ +import {renderBulkOperationUserErrors, formatBulkOperationCancellationResult} from './bulk-operations/format-bulk-operation-status.js' +import { + BulkOperationCancel, + BulkOperationCancelMutation, + BulkOperationCancelMutationVariables, +} from '../api/graphql/bulk-operations/generated/bulk-operation-cancel.js' +import {formatStoreOperationInfo} from './graphql/common.js' +import {renderInfo, renderError, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' + +const API_VERSION = '2026-01' + +interface StoreCancelBulkOperationOptions { + storeFqdn: string + operationId: string +} + +export async function storeCancelBulkOperation(options: StoreCancelBulkOperationOptions): Promise { + const {storeFqdn, operationId} = options + + renderInfo({ + headline: 'Canceling bulk operation.', + body: [ + { + list: { + items: [`ID: ${operationId}`, ...formatStoreOperationInfo({storeFqdn})], + }, + }, + ], + }) + + const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + + const response = await adminRequestDoc({ + query: BulkOperationCancel, + session: adminSession, + variables: {id: operationId}, + version: API_VERSION, + }) + + if (response.bulkOperationCancel?.userErrors?.length) { + renderBulkOperationUserErrors(response.bulkOperationCancel.userErrors, 'Failed to cancel bulk operation.') + return + } + + const operation = response.bulkOperationCancel?.bulkOperation + if (operation) { + const result = formatBulkOperationCancellationResult(operation) + const renderOptions = { + headline: result.headline, + ...(result.body && {body: result.body}), + ...(result.customSections && {customSections: result.customSections}), + } + + switch (result.renderType) { + case 'success': + renderSuccess(renderOptions) + break + case 'warning': + renderWarning(renderOptions) + break + case 'info': + renderInfo(renderOptions) + break + } + } else { + renderError({ + headline: 'Bulk operation not found or could not be canceled.', + body: outputContent`ID: ${outputToken.yellow(operationId)}`.value, + }) + } +} diff --git a/packages/app/src/cli/services/store-bulk-execute-operation.ts b/packages/app/src/cli/services/store-bulk-execute-operation.ts new file mode 100644 index 00000000000..82762dfb6f4 --- /dev/null +++ b/packages/app/src/cli/services/store-bulk-execute-operation.ts @@ -0,0 +1,236 @@ +import {runBulkOperationQuery} from './bulk-operations/run-query.js' +import {runBulkOperationMutation} from './bulk-operations/run-mutation.js' +import {watchBulkOperation, shortBulkOperationPoll, type BulkOperation} from './bulk-operations/watch-bulk-operation.js' +import {formatBulkOperationStatus} from './bulk-operations/format-bulk-operation-status.js' +import {downloadBulkOperationResults} from './bulk-operations/download-bulk-operation-results.js' +import {extractBulkOperationId} from './bulk-operations/bulk-operation-status.js' +import {BULK_OPERATIONS_MIN_API_VERSION} from './bulk-operations/constants.js' +import {resolveApiVersion, formatStoreOperationInfo, isMutation} from './graphql/common.js' +import { + renderSuccess, + renderInfo, + renderError, + renderWarning, + renderSingleTask, + TokenItem, +} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' +import {AbortError, BugError} from '@shopify/cli-kit/node/error' +import {AbortController} from '@shopify/cli-kit/node/abort' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs' + +interface StoreExecuteBulkOperationInput { + storeFqdn: string + query: string + variables?: string[] + variableFile?: string + watch?: boolean + outputFile?: string + version?: string +} + +async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise { + if (variables) { + return variables.join('\n') + } else if (variableFile) { + if (!(await fileExists(variableFile))) { + throw new AbortError( + outputContent`Variable file not found at ${outputToken.path( + variableFile, + )}. Please check the path and try again.`, + ) + } + return readFile(variableFile, {encoding: 'utf8'}) + } else { + return undefined + } +} + +export async function storeExecuteBulkOperation(input: StoreExecuteBulkOperationInput): Promise { + const {storeFqdn, query, variables, variableFile, outputFile, watch = false, version: userSpecifiedVersion} = input + + const {adminSession, version} = await renderSingleTask({ + title: outputContent`Authenticating`, + task: async () => { + const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + const version = await resolveApiVersion({ + adminSession, + userSpecifiedVersion, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }) + return {adminSession, version} + }, + renderOptions: {stdout: process.stderr}, + }) + + const variablesJsonl = await parseVariablesToJsonl(variables, variableFile) + + validateBulkOperationVariables(query, variablesJsonl) + + renderInfo({ + headline: 'Starting bulk operation.', + body: [ + { + list: { + items: formatStoreOperationInfo({storeFqdn, version}), + }, + }, + ], + }) + + const bulkOperationResponse = isMutation(query) + ? await runBulkOperationMutation({adminSession, query, variablesJsonl, version}) + : await runBulkOperationQuery({adminSession, query, version}) + + if (bulkOperationResponse?.userErrors?.length) { + renderError({ + headline: 'Error creating bulk operation.', + body: { + list: { + items: bulkOperationResponse.userErrors.map((error) => + error.field ? `${error.field.join('.')}: ${error.message}` : error.message, + ), + }, + }, + }) + return + } + + const createdOperation = bulkOperationResponse?.bulkOperation + if (createdOperation) { + if (watch) { + const abortController = new AbortController() + const operation = await watchBulkOperation(adminSession, createdOperation.id, abortController.signal, () => + abortController.abort(), + ) + + if (abortController.signal.aborted) { + renderInfo({ + headline: `Bulk operation ${operation.id} is still running in the background.`, + body: statusCommandHelpMessage(operation.id), + }) + } else { + await renderBulkOperationResult(operation, outputFile) + } + } else { + const operation = await shortBulkOperationPoll(adminSession, createdOperation.id) + const errorStatuses = ['FAILED', 'CANCELED', 'EXPIRED'] + if (errorStatuses.includes(operation.status)) { + await renderBulkOperationResult(operation, outputFile) + } else { + renderSuccess({ + headline: 'Bulk operation is running.', + body: statusCommandHelpMessage(operation.id), + customSections: [{body: [{list: {items: [outputContent`ID: ${outputToken.cyan(operation.id)}`.value]}}]}], + }) + } + } + } else { + renderWarning({ + headline: 'Bulk operation not created successfully.', + body: 'This is an unexpected error. Please try again later.', + }) + throw new BugError('Bulk operation response returned null with no error message.') + } +} + +async function renderBulkOperationResult(operation: BulkOperation, outputFile?: string): Promise { + const headline = formatBulkOperationStatus(operation).value + const items = [ + outputContent`ID: ${outputToken.cyan(operation.id)}`.value, + outputContent`Status: ${outputToken.yellow(operation.status)}`.value, + outputContent`Created at: ${outputToken.gray(String(operation.createdAt))}`.value, + ...(operation.completedAt + ? [outputContent`Completed at: ${outputToken.gray(String(operation.completedAt))}`.value] + : []), + ] + + const customSections = [{body: [{list: {items}}]}] + + switch (operation.status) { + case 'CREATED': + renderSuccess({ + headline: 'Bulk operation started.', + body: statusCommandHelpMessage(operation.id), + customSections, + }) + break + case 'RUNNING': + renderSuccess({ + headline: 'Bulk operation is running.', + body: statusCommandHelpMessage(operation.id), + customSections, + }) + break + case 'COMPLETED': + if (operation.url) { + const results = await downloadBulkOperationResults(operation.url) + const hasUserErrors = resultsContainUserErrors(results) + + if (outputFile) { + await writeFile(outputFile, results) + } else { + outputResult(results) + } + + if (hasUserErrors) { + renderWarning({ + headline: 'Bulk operation completed with errors.', + body: outputFile + ? `Results written to ${outputFile}. Check file for error details.` + : 'Check results for error details.', + customSections, + }) + } else { + renderSuccess({ + headline, + body: outputFile ? [`Results written to ${outputFile}`] : undefined, + customSections, + }) + } + } else { + renderSuccess({headline, customSections}) + } + break + default: + renderError({headline, customSections}) + break + } +} + +function resultsContainUserErrors(results: string): boolean { + const lines = results.trim().split('\n') + + return lines.some((line) => { + const parsed = JSON.parse(line) + if (!parsed.data) return false + const result = Object.values(parsed.data)[0] as {userErrors?: unknown[]} | undefined + return result?.userErrors !== undefined && result.userErrors.length > 0 + }) +} + +function validateBulkOperationVariables(graphqlOperation: string, variablesJsonl?: string): void { + if (isMutation(graphqlOperation) && !variablesJsonl) { + throw new AbortError( + outputContent`Bulk mutations require variables. Provide a JSONL file with ${outputToken.yellow( + '--variable-file', + )} or individual JSON objects with ${outputToken.yellow('--variables')}.`, + ) + } + + if (!isMutation(graphqlOperation) && variablesJsonl) { + throw new AbortError( + outputContent`The ${outputToken.yellow('--variables')} and ${outputToken.yellow( + '--variable-file', + )} flags can only be used with mutations, not queries.`, + ) + } +} + +function statusCommandHelpMessage(operationId: string): TokenItem { + return [ + 'Monitor its progress with:\n', + {command: `shopify store bulk status --id=${extractBulkOperationId(operationId)}`}, + ] +} diff --git a/packages/app/src/cli/services/store-bulk-operation-status.ts b/packages/app/src/cli/services/store-bulk-operation-status.ts new file mode 100644 index 00000000000..df41d773f81 --- /dev/null +++ b/packages/app/src/cli/services/store-bulk-operation-status.ts @@ -0,0 +1,170 @@ +import {BulkOperation} from './bulk-operations/watch-bulk-operation.js' +import {formatBulkOperationStatus} from './bulk-operations/format-bulk-operation-status.js' +import {BULK_OPERATIONS_MIN_API_VERSION} from './bulk-operations/constants.js' +import {extractBulkOperationId} from './bulk-operations/bulk-operation-status.js' +import { + GetBulkOperationById, + GetBulkOperationByIdQuery, +} from '../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' +import {formatStoreOperationInfo, resolveApiVersion} from './graphql/common.js' +import { + ListBulkOperations, + ListBulkOperationsQuery, + ListBulkOperationsQueryVariables, +} from '../api/graphql/bulk-operations/generated/list-bulk-operations.js' +import {renderInfo, renderSuccess, renderError, renderTable} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken, outputNewline} from '@shopify/cli-kit/node/output' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {timeAgo, formatDate} from '@shopify/cli-kit/common/string' +import colors from '@shopify/cli-kit/node/colors' + +interface StoreGetBulkOperationStatusOptions { + storeFqdn: string + operationId: string +} + +interface StoreListBulkOperationsOptions { + storeFqdn: string +} + +export async function storeGetBulkOperationStatus(options: StoreGetBulkOperationStatusOptions): Promise { + const {storeFqdn, operationId} = options + + renderInfo({ + headline: 'Checking bulk operation status.', + body: [ + { + list: { + items: formatStoreOperationInfo({storeFqdn}), + }, + }, + ], + }) + + const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + + const response = await adminRequestDoc({ + query: GetBulkOperationById, + session: adminSession, + variables: {id: operationId}, + version: await resolveApiVersion({ + adminSession, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }), + }) + + if (response.bulkOperation) { + renderBulkOperationStatus(response.bulkOperation) + } else { + renderError({ + headline: 'Bulk operation not found.', + body: outputContent`ID: ${outputToken.yellow(operationId)}`.value, + }) + } +} + +export async function storeListBulkOperations(options: StoreListBulkOperationsOptions): Promise { + const {storeFqdn} = options + + renderInfo({ + headline: 'Listing bulk operations.', + body: [ + { + list: { + items: formatStoreOperationInfo({storeFqdn}), + }, + }, + ], + }) + + const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + + const response = await adminRequestDoc({ + query: ListBulkOperations, + session: adminSession, + variables: { + query: `created_at:>=${sevenDaysAgo}`, + first: 100, + sortKey: 'CREATED_AT', + }, + version: await resolveApiVersion({ + adminSession, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }), + }) + + const operations = response.bulkOperations.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 : '' +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 8c59a782881..87d81fe6b19 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -74,6 +74,9 @@ * [`shopify plugins unlink [PLUGIN]`](#shopify-plugins-unlink-plugin) * [`shopify plugins update`](#shopify-plugins-update) * [`shopify search [query]`](#shopify-search-query) +* [`shopify store bulk cancel`](#shopify-store-bulk-cancel) +* [`shopify store bulk execute`](#shopify-store-bulk-execute) +* [`shopify store bulk status`](#shopify-store-bulk-status) * [`shopify store execute`](#shopify-store-execute) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) @@ -2065,6 +2068,98 @@ EXAMPLES shopify search "" ``` +## `shopify store bulk cancel` + +Cancel a bulk operation on a store. + +``` +USAGE + $ shopify store bulk cancel --id -s [--no-color] [--verbose] + +FLAGS + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store. + --id= (required) [env: SHOPIFY_FLAG_ID] The bulk operation ID to cancel (numeric ID or full GID). + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + Cancel a bulk operation on a store. + + Cancels a running bulk operation by ID, authenticated as the current user. +``` + +## `shopify store bulk execute` + +Execute bulk operations against a store. + +``` +USAGE + $ shopify store bulk execute -s [--no-color] [--output-file --watch] [-q ] [--query-file + ] [--variable-file | -v ...] [--verbose] [--version ] + +FLAGS + -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation to run as a bulk operation. + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to execute + against. + -v, --variables=... [env: SHOPIFY_FLAG_VARIABLES] The values for any GraphQL variables in your mutation, in + JSON format. Can be specified multiple times. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --output-file= [env: SHOPIFY_FLAG_OUTPUT_FILE] The file path where results should be written if --watch + is specified. If not specified, results will be written to STDOUT. + --query-file= [env: SHOPIFY_FLAG_QUERY_FILE] Path to a file containing the GraphQL query or mutation. + Can't be used with --query. + --variable-file= [env: SHOPIFY_FLAG_VARIABLE_FILE] Path to a file containing GraphQL variables in JSONL + format (one JSON object per line). Can't be used with --variables. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + --version= [env: SHOPIFY_FLAG_VERSION] The API version to use for the bulk operation. If not + specified, uses the latest stable version. + --watch [env: SHOPIFY_FLAG_WATCH] Wait for bulk operation results before exiting. Defaults to + false. + +DESCRIPTION + Execute bulk operations against a store. + + Executes an Admin API GraphQL query or mutation on the specified store as a bulk operation, authenticated as the + current user. + + Unlike "`app bulk execute`" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute), this command does not + require an app to be linked or installed on the target store. + + 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). + + Use "`store bulk status`" (https://shopify.dev/docs/api/shopify-cli/store/store-bulk-status) to check the status of + your bulk operations. +``` + +## `shopify store bulk status` + +Check the status of bulk operations on a store. + +``` +USAGE + $ shopify store bulk status -s [--id ] [--no-color] [--verbose] + +FLAGS + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store. + --id= [env: SHOPIFY_FLAG_ID] The bulk operation ID (numeric ID or full GID). If not provided, lists all + bulk operations on this store in the last 7 days. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + Check the status of bulk operations on a store. + + Check the status of a specific bulk operation by ID, or list all bulk operations on this store in the last 7 days. + + Unlike "`app bulk status`" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status), this command does not + require an app to be linked or installed on the target store. + + Use "`store bulk execute`" (https://shopify.dev/docs/api/shopify-cli/store/store-bulk-execute) to start a new bulk + operation. +``` + ## `shopify store execute` Execute GraphQL queries and mutations against a store. From 3f5f1507cb5b0fc5f20f277db336e1a330abd7cd Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:23:33 -0400 Subject: [PATCH 2/2] Fix lint errors, import order, regenerate docs and snapshots Fix prettier formatting in test files, import ordering in service files, regenerate dev docs and E2E snapshots. Co-Authored-By: Claude Opus 4.6 --- .../interfaces/auth-login.interface.ts | 12 + .../generated/generated_docs_data.json | 432 +++++++++++++++++- .../cli/commands/store/bulk/cancel.test.ts | 13 +- .../cli/commands/store/bulk/execute.test.ts | 26 +- .../cli/commands/store/bulk/status.test.ts | 10 +- .../app/src/cli/commands/store/bulk/status.ts | 5 +- .../services/store-bulk-cancel-operation.ts | 7 +- .../services/store-bulk-operation-status.ts | 2 +- packages/e2e/data/snapshots/commands.txt | 4 + 9 files changed, 473 insertions(+), 38 deletions(-) diff --git a/docs-shopify.dev/commands/interfaces/auth-login.interface.ts b/docs-shopify.dev/commands/interfaces/auth-login.interface.ts index a392b6c8ed2..1a897864f4d 100644 --- a/docs-shopify.dev/commands/interfaces/auth-login.interface.ts +++ b/docs-shopify.dev/commands/interfaces/auth-login.interface.ts @@ -5,4 +5,16 @@ export interface authlogin { * @environment SHOPIFY_FLAG_AUTH_ALIAS */ '--alias '?: string + + /** + * Start the login flow without polling. Prints the auth URL and exits immediately. + * @environment SHOPIFY_FLAG_AUTH_NO_POLLING + */ + '--no-polling'?: '' + + /** + * Resume a previously started login flow. + * @environment SHOPIFY_FLAG_AUTH_RESUME + */ + '--resume'?: '' } diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 8d43ba63afe..83c21aba30f 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -3451,9 +3451,27 @@ "description": "Alias of the session you want to login to.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-polling", + "value": "\"\"", + "description": "Start the login flow without polling. Prints the auth URL and exits immediately.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_NO_POLLING" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--resume", + "value": "\"\"", + "description": "Resume a previously started login flow.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_RESUME" } ], - "value": "export interface authlogin {\n /**\n * Alias of the session you want to login to.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--alias '?: string\n}" + "value": "export interface authlogin {\n /**\n * Alias of the session you want to login to.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--alias '?: string\n\n /**\n * Start the login flow without polling. Prints the auth URL and exits immediately.\n * @environment SHOPIFY_FLAG_AUTH_NO_POLLING\n */\n '--no-polling'?: ''\n\n /**\n * Resume a previously started login flow.\n * @environment SHOPIFY_FLAG_AUTH_RESUME\n */\n '--resume'?: ''\n}" } } } @@ -3483,6 +3501,28 @@ "category": "general commands", "related": [] }, + { + "name": "auth whoami", + "description": "Displays the currently logged-in Shopify account.", + "overviewPreviewDescription": "Displays the currently logged-in Shopify account.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "auth whoami", + "code": "shopify auth whoami", + "language": "bash" + } + ], + "title": "auth whoami" + } + }, + "definitions": [], + "category": "general commands", + "related": [] + }, { "name": "commands", "description": "List all shopify commands.", @@ -5765,6 +5805,396 @@ "category": "general commands", "related": [] }, + { + "name": "store bulk cancel", + "description": "Cancels a running bulk operation by ID, authenticated as the current user.", + "overviewPreviewDescription": "Cancel a bulk operation on a store.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "store bulk cancel", + "code": "shopify store bulk cancel [flags]", + "language": "bash" + } + ], + "title": "store bulk cancel" + } + }, + "definitions": [ + { + "title": "Flags", + "description": "The following flags are available for the `store bulk cancel` command:", + "type": "storebulkcancel", + "typeDefinitions": { + "storebulkcancel": { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-cancel.interface.ts", + "name": "storebulkcancel", + "description": "", + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--id ", + "value": "string", + "description": "The bulk operation ID to cancel (numeric ID or full GID).", + "environmentValue": "SHOPIFY_FLAG_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "\"\"", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "\"\"", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "The myshopify.com domain of the store.", + "environmentValue": "SHOPIFY_FLAG_STORE" + } + ], + "value": "export interface storebulkcancel {\n /**\n * The bulk operation ID to cancel (numeric ID or full GID).\n * @environment SHOPIFY_FLAG_ID\n */\n '--id ': string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + } + } + ], + "category": "store", + "related": [] + }, + { + "name": "store bulk execute", + "description": "Executes an Admin API GraphQL query or mutation on the specified store as a bulk operation, authenticated as the current user.\n\n Unlike [`app bulk execute`](/docs/api/shopify-cli/app/app-bulk-execute), this command does not require an app to be linked or installed on the target store.\n\n Bulk operations allow you to process large amounts of data asynchronously. Learn more about [bulk query operations](/docs/api/usage/bulk-operations/queries) and [bulk mutation operations](/docs/api/usage/bulk-operations/imports).\n\n Use [`store bulk status`](/docs/api/shopify-cli/store/store-bulk-status) to check the status of your bulk operations.", + "overviewPreviewDescription": "Execute bulk operations against a store.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "store bulk execute", + "code": "shopify store bulk execute [flags]", + "language": "bash" + } + ], + "title": "store bulk execute" + } + }, + "definitions": [ + { + "title": "Flags", + "description": "The following flags are available for the `store bulk execute` command:", + "type": "storebulkexecute", + "typeDefinitions": { + "storebulkexecute": { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "name": "storebulkexecute", + "description": "", + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "\"\"", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--output-file ", + "value": "string", + "description": "The file path where results should be written if --watch is specified. If not specified, results will be written to STDOUT.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_OUTPUT_FILE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--query-file ", + "value": "string", + "description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_QUERY_FILE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--variable-file ", + "value": "string", + "description": "Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VARIABLE_FILE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "\"\"", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--version ", + "value": "string", + "description": "The API version to use for the bulk operation. If not specified, uses the latest stable version.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERSION" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--watch", + "value": "\"\"", + "description": "Wait for bulk operation results before exiting. Defaults to false.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_WATCH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-q, --query ", + "value": "string", + "description": "The GraphQL query or mutation to run as a bulk operation.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_QUERY" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "The myshopify.com domain of the store to execute against.", + "environmentValue": "SHOPIFY_FLAG_STORE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-v, --variables ", + "value": "string", + "description": "The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VARIABLES" + } + ], + "value": "export interface storebulkexecute {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file path where results should be written if --watch is specified. If not specified, results will be written to STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation to run as a bulk operation.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the bulk operation. If not specified, uses the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n\n /**\n * Wait for bulk operation results before exiting. Defaults to false.\n * @environment SHOPIFY_FLAG_WATCH\n */\n '--watch'?: ''\n}" + } + } + } + ], + "category": "store", + "related": [] + }, + { + "name": "store bulk status", + "description": "Check the status of a specific bulk operation by ID, or list all bulk operations on this store in the last 7 days.\n\n Unlike [`app bulk status`](/docs/api/shopify-cli/app/app-bulk-status), this command does not require an app to be linked or installed on the target store.\n\n Use [`store bulk execute`](/docs/api/shopify-cli/store/store-bulk-execute) to start a new bulk operation.", + "overviewPreviewDescription": "Check the status of bulk operations on a store.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "store bulk status", + "code": "shopify store bulk status [flags]", + "language": "bash" + } + ], + "title": "store bulk status" + } + }, + "definitions": [ + { + "title": "Flags", + "description": "The following flags are available for the `store bulk status` command:", + "type": "storebulkstatus", + "typeDefinitions": { + "storebulkstatus": { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-status.interface.ts", + "name": "storebulkstatus", + "description": "", + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--id ", + "value": "string", + "description": "The bulk operation ID (numeric ID or full GID). If not provided, lists all bulk operations on this store in the last 7 days.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "\"\"", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "\"\"", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-bulk-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "The myshopify.com domain of the store.", + "environmentValue": "SHOPIFY_FLAG_STORE" + } + ], + "value": "export interface storebulkstatus {\n /**\n * The bulk operation ID (numeric ID or full GID). If not provided, lists all bulk operations on this store in the last 7 days.\n * @environment SHOPIFY_FLAG_ID\n */\n '--id '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + } + } + ], + "category": "store", + "related": [] + }, + { + "name": "store execute", + "description": "Executes an Admin API GraphQL query or mutation on the specified store, authenticated as the current user.\n\n Unlike [`app execute`](/docs/api/shopify-cli/app/app-execute), this command does not require an app to be linked or installed on the target store.", + "overviewPreviewDescription": "Execute GraphQL queries and mutations against a store.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "store execute", + "code": "shopify store execute [flags]", + "language": "bash" + } + ], + "title": "store execute" + } + }, + "definitions": [ + { + "title": "Flags", + "description": "The following flags are available for the `store execute` command:", + "type": "storeexecute", + "typeDefinitions": { + "storeexecute": { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "name": "storeexecute", + "description": "", + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "\"\"", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--output-file ", + "value": "string", + "description": "The file name where results should be written, instead of STDOUT.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_OUTPUT_FILE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--query-file ", + "value": "string", + "description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_QUERY_FILE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--variable-file ", + "value": "string", + "description": "Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VARIABLE_FILE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "\"\"", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--version ", + "value": "string", + "description": "The API version to use for the query or mutation. Defaults to the latest stable version.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERSION" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-q, --query ", + "value": "string", + "description": "The GraphQL query or mutation, as a string.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_QUERY" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "The myshopify.com domain of the store to execute against.", + "environmentValue": "SHOPIFY_FLAG_STORE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-v, --variables ", + "value": "string", + "description": "The values for any GraphQL variables in your query or mutation, in JSON format.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VARIABLES" + } + ], + "value": "export interface storeexecute {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + } + } + } + ], + "category": "store", + "related": [] + }, { "name": "theme check", "description": "Calls and runs [Theme Check](/docs/themes/tools/theme-check) to analyze your theme code for errors and to ensure that it follows theme and Liquid best practices. [Learn more about the checks that Theme Check runs.](/docs/themes/tools/theme-check/checks)", diff --git a/packages/app/src/cli/commands/store/bulk/cancel.test.ts b/packages/app/src/cli/commands/store/bulk/cancel.test.ts index 0377bf4ea5f..dcefd34730e 100644 --- a/packages/app/src/cli/commands/store/bulk/cancel.test.ts +++ b/packages/app/src/cli/commands/store/bulk/cancel.test.ts @@ -6,17 +6,13 @@ vi.mock('../../../services/store-bulk-cancel-operation.js') describe('store bulk cancel command', () => { test('requires --store flag', async () => { - await expect( - StoreBulkCancel.run(['--id', '123'], import.meta.url), - ).rejects.toThrow() + await expect(StoreBulkCancel.run(['--id', '123'], import.meta.url)).rejects.toThrow() expect(storeCancelBulkOperation).not.toHaveBeenCalled() }) test('requires --id flag', async () => { - await expect( - StoreBulkCancel.run(['--store', 'test-store.myshopify.com'], import.meta.url), - ).rejects.toThrow() + await expect(StoreBulkCancel.run(['--store', 'test-store.myshopify.com'], import.meta.url)).rejects.toThrow() expect(storeCancelBulkOperation).not.toHaveBeenCalled() }) @@ -24,10 +20,7 @@ describe('store bulk cancel command', () => { test('calls storeCancelBulkOperation with correct arguments', async () => { vi.mocked(storeCancelBulkOperation).mockResolvedValue() - await StoreBulkCancel.run( - ['--store', 'test-store.myshopify.com', '--id', '123'], - import.meta.url, - ) + await StoreBulkCancel.run(['--store', 'test-store.myshopify.com', '--id', '123'], import.meta.url) expect(storeCancelBulkOperation).toHaveBeenCalledWith({ storeFqdn: 'test-store.myshopify.com', diff --git a/packages/app/src/cli/commands/store/bulk/execute.test.ts b/packages/app/src/cli/commands/store/bulk/execute.test.ts index d454759e419..9c2fd4a20a7 100644 --- a/packages/app/src/cli/commands/store/bulk/execute.test.ts +++ b/packages/app/src/cli/commands/store/bulk/execute.test.ts @@ -11,9 +11,7 @@ describe('store bulk execute command', () => { vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }') vi.mocked(storeExecuteBulkOperation).mockResolvedValue() - await expect( - StoreBulkExecute.run(['--query', 'query { shop { name } }'], import.meta.url), - ).rejects.toThrow() + await expect(StoreBulkExecute.run(['--query', 'query { shop { name } }'], import.meta.url)).rejects.toThrow() expect(storeExecuteBulkOperation).not.toHaveBeenCalled() }) @@ -27,9 +25,7 @@ describe('store bulk execute command', () => { import.meta.url, ) - expect(loadQuery).toHaveBeenCalledWith( - expect.objectContaining({query: 'query { shop { name } }'}), - ) + expect(loadQuery).toHaveBeenCalledWith(expect.objectContaining({query: 'query { shop { name } }'})) expect(storeExecuteBulkOperation).toHaveBeenCalledWith( expect.objectContaining({ storeFqdn: 'test-store.myshopify.com', @@ -61,10 +57,13 @@ describe('store bulk execute command', () => { await StoreBulkExecute.run( [ - '--store', 'test-store.myshopify.com', - '--query', 'query { shop { name } }', + '--store', + 'test-store.myshopify.com', + '--query', + 'query { shop { name } }', '--watch', - '--output-file', '/tmp/out.jsonl', + '--output-file', + '/tmp/out.jsonl', ], import.meta.url, ) @@ -83,9 +82,12 @@ describe('store bulk execute command', () => { await StoreBulkExecute.run( [ - '--store', 'test-store.myshopify.com', - '--query', 'mutation { productCreate { product { id } } }', - '--variables', '{"input": {"title": "test"}}', + '--store', + 'test-store.myshopify.com', + '--query', + 'mutation { productCreate { product { id } } }', + '--variables', + '{"input": {"title": "test"}}', ], import.meta.url, ) diff --git a/packages/app/src/cli/commands/store/bulk/status.test.ts b/packages/app/src/cli/commands/store/bulk/status.test.ts index 06b3abdadb8..f9b0bbe1d80 100644 --- a/packages/app/src/cli/commands/store/bulk/status.test.ts +++ b/packages/app/src/cli/commands/store/bulk/status.test.ts @@ -15,10 +15,7 @@ describe('store bulk status command', () => { test('calls storeGetBulkOperationStatus when --id is provided', async () => { vi.mocked(storeGetBulkOperationStatus).mockResolvedValue() - await StoreBulkStatus.run( - ['--store', 'test-store.myshopify.com', '--id', '123'], - import.meta.url, - ) + await StoreBulkStatus.run(['--store', 'test-store.myshopify.com', '--id', '123'], import.meta.url) expect(storeGetBulkOperationStatus).toHaveBeenCalledWith({ storeFqdn: 'test-store.myshopify.com', @@ -29,10 +26,7 @@ describe('store bulk status command', () => { test('calls storeListBulkOperations when --id is not provided', async () => { vi.mocked(storeListBulkOperations).mockResolvedValue() - await StoreBulkStatus.run( - ['--store', 'test-store.myshopify.com'], - import.meta.url, - ) + await StoreBulkStatus.run(['--store', 'test-store.myshopify.com'], import.meta.url) expect(storeListBulkOperations).toHaveBeenCalledWith({ storeFqdn: 'test-store.myshopify.com', diff --git a/packages/app/src/cli/commands/store/bulk/status.ts b/packages/app/src/cli/commands/store/bulk/status.ts index 44f50a5cb13..e42a193faae 100644 --- a/packages/app/src/cli/commands/store/bulk/status.ts +++ b/packages/app/src/cli/commands/store/bulk/status.ts @@ -1,7 +1,4 @@ -import { - storeGetBulkOperationStatus, - storeListBulkOperations, -} from '../../../services/store-bulk-operation-status.js' +import {storeGetBulkOperationStatus, storeListBulkOperations} from '../../../services/store-bulk-operation-status.js' import {normalizeBulkOperationId} from '../../../services/bulk-operations/bulk-operation-status.js' import {Flags} from '@oclif/core' import {globalFlags} from '@shopify/cli-kit/node/cli' diff --git a/packages/app/src/cli/services/store-bulk-cancel-operation.ts b/packages/app/src/cli/services/store-bulk-cancel-operation.ts index 50ae49deedb..f2ac926ed24 100644 --- a/packages/app/src/cli/services/store-bulk-cancel-operation.ts +++ b/packages/app/src/cli/services/store-bulk-cancel-operation.ts @@ -1,10 +1,13 @@ -import {renderBulkOperationUserErrors, formatBulkOperationCancellationResult} from './bulk-operations/format-bulk-operation-status.js' +import { + renderBulkOperationUserErrors, + formatBulkOperationCancellationResult, +} from './bulk-operations/format-bulk-operation-status.js' +import {formatStoreOperationInfo} from './graphql/common.js' import { BulkOperationCancel, BulkOperationCancelMutation, BulkOperationCancelMutationVariables, } from '../api/graphql/bulk-operations/generated/bulk-operation-cancel.js' -import {formatStoreOperationInfo} from './graphql/common.js' import {renderInfo, renderError, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' diff --git a/packages/app/src/cli/services/store-bulk-operation-status.ts b/packages/app/src/cli/services/store-bulk-operation-status.ts index df41d773f81..7a045d8bb17 100644 --- a/packages/app/src/cli/services/store-bulk-operation-status.ts +++ b/packages/app/src/cli/services/store-bulk-operation-status.ts @@ -2,11 +2,11 @@ import {BulkOperation} from './bulk-operations/watch-bulk-operation.js' import {formatBulkOperationStatus} from './bulk-operations/format-bulk-operation-status.js' import {BULK_OPERATIONS_MIN_API_VERSION} from './bulk-operations/constants.js' import {extractBulkOperationId} from './bulk-operations/bulk-operation-status.js' +import {formatStoreOperationInfo, resolveApiVersion} from './graphql/common.js' import { GetBulkOperationById, GetBulkOperationByIdQuery, } from '../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' -import {formatStoreOperationInfo, resolveApiVersion} from './graphql/common.js' import { ListBulkOperations, ListBulkOperationsQuery, diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 4f1e6bba103..880ceb00772 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -90,6 +90,10 @@ │ └─ update ├─ search ├─ store +│ ├─ bulk +│ │ ├─ cancel +│ │ ├─ execute +│ │ └─ status │ └─ execute ├─ theme │ ├─ check