diff --git a/.changeset/pretty-brooms-heal.md b/.changeset/pretty-brooms-heal.md new file mode 100644 index 00000000000..e342e6f6718 --- /dev/null +++ b/.changeset/pretty-brooms-heal.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Added `shopify app execute` for convenient execution of queries and mutations against the Admin API. diff --git a/docs-shopify.dev/commands/app-execute.doc.ts b/docs-shopify.dev/commands/app-execute.doc.ts new file mode 100644 index 00000000000..7423ba44467 --- /dev/null +++ b/docs-shopify.dev/commands/app-execute.doc.ts @@ -0,0 +1,34 @@ +// This is an autogenerated file. Don't edit this file manually. +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' + +const data: ReferenceEntityTemplateSchema = { + name: 'app execute', + description: `Executes an Admin API GraphQL query or mutation on the specified dev store.`, + overviewPreviewDescription: `Execute GraphQL queries and mutations.`, + type: 'command', + isVisualComponent: false, + defaultExample: { + codeblock: { + tabs: [ + { + title: 'app execute', + code: './examples/app-execute.example.sh', + language: 'bash', + }, + ], + title: 'app execute', + }, + }, + definitions: [ + { + title: 'Flags', + description: 'The following flags are available for the `app execute` command:', + type: 'appexecute', + }, + ], + category: 'app', + related: [ + ], +} + +export default data \ No newline at end of file diff --git a/docs-shopify.dev/commands/examples/app-execute.example.sh b/docs-shopify.dev/commands/examples/app-execute.example.sh new file mode 100644 index 00000000000..5139767a4c7 --- /dev/null +++ b/docs-shopify.dev/commands/examples/app-execute.example.sh @@ -0,0 +1 @@ +shopify app execute [flags] \ No newline at end of file diff --git a/docs-shopify.dev/commands/interfaces/app-execute.interface.ts b/docs-shopify.dev/commands/interfaces/app-execute.interface.ts new file mode 100644 index 00000000000..b3c70e7d80b --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/app-execute.interface.ts @@ -0,0 +1,68 @@ +// This is an autogenerated file. Don't edit this file manually. +export interface appexecute { + /** + * The Client ID of your app. + * @environment SHOPIFY_FLAG_CLIENT_ID + */ + '--client-id '?: string + + /** + * The name of the app configuration. + * @environment SHOPIFY_FLAG_APP_CONFIG + */ + '-c, --config '?: string + + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * The file name where results should be written, instead of STDOUT. + * @environment SHOPIFY_FLAG_OUTPUT_FILE + */ + '--output-file '?: string + + /** + * The path to your app directory. + * @environment SHOPIFY_FLAG_PATH + */ + '--path '?: string + + /** + * The GraphQL query or mutation, as a string. + * @environment SHOPIFY_FLAG_QUERY + */ + '-q, --query '?: string + + /** + * Reset all your settings. + * @environment SHOPIFY_FLAG_RESET + */ + '--reset'?: '' + + /** + * The myshopify.com domain of the store to execute against. The app must be installed on the store. If not specified, you will be prompted to select a store. + * @environment SHOPIFY_FLAG_STORE + */ + '-s, --store '?: string + + /** + * The values for any GraphQL variables in your query or mutation, in JSON format. + * @environment SHOPIFY_FLAG_VARIABLES + */ + '-v, --variables '?: string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' + + /** + * The API version to use for the query or mutation. Defaults to the latest stable version. + * @environment SHOPIFY_FLAG_VERSION + */ + '--version '?: string +} diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 8add8fbbd74..382f2cedb57 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -998,6 +998,143 @@ "category": "app", "related": [] }, + { + "name": "app execute", + "description": "Executes an Admin API GraphQL query or mutation on the specified dev store.", + "overviewPreviewDescription": "Execute GraphQL queries and mutations.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "app execute", + "code": "shopify app execute [flags]", + "language": "bash" + } + ], + "title": "app execute" + } + }, + "definitions": [ + { + "title": "Flags", + "description": "The following flags are available for the `app execute` command:", + "type": "appexecute", + "typeDefinitions": { + "appexecute": { + "filePath": "docs-shopify.dev/commands/interfaces/app-execute.interface.ts", + "name": "appexecute", + "description": "", + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--client-id ", + "value": "string", + "description": "The Client ID of your app.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-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/app-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/app-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path to your app directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--reset", + "value": "\"\"", + "description": "Reset all your settings.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_RESET" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-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/app-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/app-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --config ", + "value": "string", + "description": "The name of the app configuration.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-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/app-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "The myshopify.com domain of the store to execute against. The app must be installed on the store. If not specified, you will be prompted to select a store.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-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 appexecute {\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 * 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 path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: 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 * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The myshopify.com domain of the store to execute against. The app must be installed on the store. If not specified, you will be prompted to select a store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: 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": "app", + "related": [] + }, { "name": "app function build", "description": "Compiles the function in your current directory to WebAssembly (Wasm) for testing purposes.", diff --git a/packages/app/src/cli/commands/app/bulk/execute.ts b/packages/app/src/cli/commands/app/bulk/execute.ts index c1bd9b1a6a1..07e0ed00e4c 100644 --- a/packages/app/src/cli/commands/app/bulk/execute.ts +++ b/packages/app/src/cli/commands/app/bulk/execute.ts @@ -1,16 +1,14 @@ import {appFlags, bulkOperationFlags} from '../../../flags.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' -import {linkedAppContext} from '../../../services/app-context.js' -import {storeContext} from '../../../services/store-context.js' import {executeBulkOperation} from '../../../services/bulk-operations/execute-bulk-operation.js' +import {prepareExecuteContext} from '../../../utilities/execute-command-helpers.js' import {globalFlags} from '@shopify/cli-kit/node/cli' -import {readStdinString} from '@shopify/cli-kit/node/system' -import {AbortError} from '@shopify/cli-kit/node/error' export default class BulkExecute extends AppLinkedCommand { static summary = 'Execute bulk operations.' - static description = 'Execute bulk operations against the Shopify Admin API.' + static description = + 'Executes an Admin API GraphQL query or mutation on the specified dev store, as a bulk operation.' static hidden = true @@ -23,26 +21,7 @@ export default class BulkExecute extends AppLinkedCommand { async run(): Promise { const {flags} = await this.parse(BulkExecute) - const query = flags.query ?? (await readStdinString()) - if (!query) { - throw new AbortError( - 'No query provided. Use the --query flag or pipe input via stdin.', - 'Example: echo "query { ... }" | shopify app bulk execute', - ) - } - - const appContextResult = await linkedAppContext({ - directory: flags.path, - clientId: flags['client-id'], - forceRelink: flags.reset, - userProvidedConfigName: flags.config, - }) - - const store = await storeContext({ - appContextResult, - storeFqdn: flags.store, - forceReselectStore: flags.reset, - }) + const {query, appContextResult, store} = await prepareExecuteContext(flags, 'bulk execute') await executeBulkOperation({ remoteApp: appContextResult.remoteApp, diff --git a/packages/app/src/cli/commands/app/bulk/status.ts b/packages/app/src/cli/commands/app/bulk/status.ts index 710cc524ca3..fd3ee4fb726 100644 --- a/packages/app/src/cli/commands/app/bulk/status.ts +++ b/packages/app/src/cli/commands/app/bulk/status.ts @@ -1,7 +1,6 @@ import {appFlags} from '../../../flags.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' -import {linkedAppContext} from '../../../services/app-context.js' -import {storeContext} from '../../../services/store-context.js' +import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js' import {getBulkOperationStatus, listBulkOperations} from '../../../services/bulk-operations/bulk-operation-status.js' import {Flags} from '@oclif/core' import {globalFlags} from '@shopify/cli-kit/node/cli' @@ -33,18 +32,7 @@ export default class BulkStatus extends AppLinkedCommand { async run(): Promise { const {flags} = await this.parse(BulkStatus) - const appContextResult = await linkedAppContext({ - directory: flags.path, - clientId: flags['client-id'], - forceRelink: flags.reset, - userProvidedConfigName: flags.config, - }) - - const store = await storeContext({ - appContextResult, - storeFqdn: flags.store, - forceReselectStore: flags.reset, - }) + const {appContextResult, store} = await prepareAppStoreContext(flags) if (flags.id) { await getBulkOperationStatus({ diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts new file mode 100644 index 00000000000..37fb1ffcd84 --- /dev/null +++ b/packages/app/src/cli/commands/app/execute.ts @@ -0,0 +1,34 @@ +import {appFlags, operationFlags} from '../../flags.js' +import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' +import {executeOperation} from '../../services/execute-operation.js' +import {prepareExecuteContext} from '../../utilities/execute-command-helpers.js' +import {globalFlags} from '@shopify/cli-kit/node/cli' + +export default class Execute extends AppLinkedCommand { + static summary = 'Execute GraphQL queries and mutations.' + + static description = 'Executes an Admin API GraphQL query or mutation on the specified dev store.' + + static flags = { + ...globalFlags, + ...appFlags, + ...operationFlags, + } + + async run(): Promise { + const {flags} = await this.parse(Execute) + + const {query, appContextResult, store} = await prepareExecuteContext(flags, 'execute') + + await executeOperation({ + remoteApp: appContextResult.remoteApp, + storeFqdn: store.shopDomain, + query, + variables: flags.variables, + outputFile: flags['output-file'], + ...(flags.version && {version: flags.version}), + }) + + return {app: appContextResult.app} + } +} diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index 273a0320e9f..85b59691745 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -77,3 +77,32 @@ export const bulkOperationFlags = { env: 'SHOPIFY_FLAG_VERSION', }), } + +export const operationFlags = { + query: Flags.string({ + char: 'q', + description: 'The GraphQL query or mutation, as a string.', + env: 'SHOPIFY_FLAG_QUERY', + required: false, + }), + variables: Flags.string({ + char: 'v', + description: 'The values for any GraphQL variables in your query or mutation, in JSON format.', + env: 'SHOPIFY_FLAG_VARIABLES', + }), + store: Flags.string({ + char: 's', + description: + 'The myshopify.com domain of the store to execute against. The app must be installed on the store. If not specified, you will be prompted to select a store.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + }), + version: Flags.string({ + description: 'The API version to use for the query or mutation. Defaults to the latest stable version.', + env: 'SHOPIFY_FLAG_VERSION', + }), + 'output-file': Flags.string({ + description: 'The file name where results should be written, instead of STDOUT.', + env: 'SHOPIFY_FLAG_OUTPUT_FILE', + }), +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index a47f6252ef0..a40d683c418 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -10,7 +10,8 @@ import Logs from './commands/app/logs.js' import Sources from './commands/app/app-logs/sources.js' import EnvPull from './commands/app/env/pull.js' import EnvShow from './commands/app/env/show.js' -import Execute from './commands/app/bulk/execute.js' +import BulkExecute from './commands/app/bulk/execute.js' +import Execute from './commands/app/execute.js' import FunctionBuild from './commands/app/function/build.js' import FunctionReplay from './commands/app/function/replay.js' import FunctionRun from './commands/app/function/run.js' @@ -53,7 +54,8 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:config:pull': ConfigPull, 'app:env:pull': EnvPull, 'app:env:show': EnvShow, - 'app:bulk:execute': Execute, + 'app:execute': Execute, + 'app:bulk:execute': BulkExecute, 'app:generate:schema': GenerateSchema, 'app:function:build': FunctionBuild, 'app:function:replay': FunctionReplay, diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts index 380b14b30cf..c0dd52143ba 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts @@ -3,6 +3,7 @@ import {runBulkOperationQuery} from './run-query.js' import {runBulkOperationMutation} from './run-mutation.js' import {watchBulkOperation} from './watch-bulk-operation.js' import {downloadBulkOperationResults} from './download-bulk-operation-results.js' +import {validateApiVersion} from '../graphql/common.js' import {BulkOperationRunQueryMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js' import {BulkOperationRunMutationMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js' import {OrganizationApp} from '../../models/organization.js' @@ -17,6 +18,13 @@ vi.mock('./run-query.js') vi.mock('./run-mutation.js') vi.mock('./watch-bulk-operation.js') vi.mock('./download-bulk-operation-results.js') +vi.mock('../graphql/common.js', async () => { + const actual = await vi.importActual('../graphql/common.js') + return { + ...actual, + validateApiVersion: vi.fn(), + } +}) vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/session', async () => { @@ -26,13 +34,6 @@ vi.mock('@shopify/cli-kit/node/session', async () => { ensureAuthenticatedAdminAsApp: vi.fn(), } }) -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - supportedApiVersions: vi.fn(() => Promise.resolve(['2025-01', '2025-04', '2025-07', '2025-10'])), - } -}) describe('executeBulkOperation', () => { const mockRemoteApp = { @@ -197,57 +198,6 @@ describe('executeBulkOperation', () => { expect(renderSuccess).not.toHaveBeenCalled() }) - test('throws GraphQL syntax error when given malformed GraphQL document', async () => { - const malformedQuery = '{ products { edges { node { id } }' - - await expect( - executeBulkOperation({ - remoteApp: mockRemoteApp, - storeFqdn, - query: malformedQuery, - }), - ).rejects.toThrow('Syntax Error') - - expect(runBulkOperationQuery).not.toHaveBeenCalled() - expect(runBulkOperationMutation).not.toHaveBeenCalled() - }) - - test('throws error when GraphQL document contains multiple operation definitions', async () => { - const multipleOperations = - 'mutation { productUpdate(input: {}) { product { id } } } mutation { productDelete(input: {}) { deletedProductId } }' - - await expect( - executeBulkOperation({ - remoteApp: mockRemoteApp, - storeFqdn, - query: multipleOperations, - }), - ).rejects.toThrow('Multiple operations are not supported') - - expect(runBulkOperationQuery).not.toHaveBeenCalled() - expect(runBulkOperationMutation).not.toHaveBeenCalled() - }) - - test('throws error when GraphQL document contains no operation definitions', async () => { - const noOperations = ` - fragment ProductFields on Product { - id - title - } - ` - - await expect( - executeBulkOperation({ - remoteApp: mockRemoteApp, - storeFqdn, - query: noOperations, - }), - ).rejects.toThrow('must contain exactly one operation definition') - - expect(runBulkOperationQuery).not.toHaveBeenCalled() - expect(runBulkOperationMutation).not.toHaveBeenCalled() - }) - test('reads variables from file when variableFile is provided', async () => { await inTemporaryDirectory(async (tmpDir) => { const variableFilePath = joinPath(tmpDir, 'variables.jsonl') @@ -513,69 +463,53 @@ describe('executeBulkOperation', () => { ).rejects.toThrow('Bulk operation response returned null with no error message.') expect(renderWarning).toHaveBeenCalledWith({ - headline: 'Bulk operation not created succesfully.', + headline: 'Bulk operation not created successfully.', body: 'This is an unexpected error. Please try again later.', }) expect(renderSuccess).not.toHaveBeenCalled() }) - test('allows executing bulk operations against unstable', async () => { + test('validates API version when provided', async () => { const query = '{ products { edges { node { id } } } }' + const version = '2025-01' const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { bulkOperation: createdBulkOperation, userErrors: [], } vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) + vi.mocked(validateApiVersion).mockResolvedValue() await executeBulkOperation({ remoteApp: mockRemoteApp, storeFqdn, query, - version: 'unstable', + version, }) + expect(validateApiVersion).toHaveBeenCalledWith(mockAdminSession, version) expect(runBulkOperationQuery).toHaveBeenCalledWith({ adminSession: mockAdminSession, query, - version: 'unstable', + version, }) }) - test('allows executing bulk operations against a specific stable version', async () => { + test('does not validate version when not provided', async () => { const query = '{ products { edges { node { id } } } }' const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { bulkOperation: createdBulkOperation, userErrors: [], } vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) + vi.mocked(validateApiVersion).mockClear() await executeBulkOperation({ remoteApp: mockRemoteApp, storeFqdn, query, - version: '2025-01', }) - expect(runBulkOperationQuery).toHaveBeenCalledWith({ - adminSession: mockAdminSession, - query, - version: '2025-01', - }) - }) - - test('throws error when an API version is specified but is not supported', async () => { - const query = '{ products { edges { node { id } } } }' - - await expect( - executeBulkOperation({ - remoteApp: mockRemoteApp, - storeFqdn, - query, - version: '2099-12', - }), - ).rejects.toThrow('Invalid API version') - - expect(runBulkOperationQuery).not.toHaveBeenCalled() + expect(validateApiVersion).not.toHaveBeenCalled() }) }) diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts index 0d65f17b1d7..75a5dc432fc 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts @@ -3,11 +3,10 @@ import {runBulkOperationMutation} from './run-mutation.js' import {watchBulkOperation, type BulkOperation} from './watch-bulk-operation.js' import {formatBulkOperationStatus} from './format-bulk-operation-status.js' import {downloadBulkOperationResults} from './download-bulk-operation-results.js' +import {createAdminSessionAsApp, validateSingleOperation, validateApiVersion} from '../graphql/common.js' import {OrganizationApp} from '../../models/organization.js' import {renderSuccess, renderInfo, renderError, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui' import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' -import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' -import {supportedApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {AbortController} from '@shopify/cli-kit/node/abort' import {parse} from 'graphql' @@ -44,10 +43,7 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise { const {remoteApp, storeFqdn, query, variables, variableFile, outputFile, watch = false, version} = input - 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 adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) if (version) await validateApiVersion(adminSession, version) @@ -109,7 +105,7 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr } } else { renderWarning({ - headline: 'Bulk operation not created succesfully.', + 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.') @@ -145,8 +141,8 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?: await writeFile(outputFile, results) renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections}) } else { - outputResult(results) renderSuccess({headline, customSections}) + outputResult(results) } } else { renderSuccess({headline, customSections}) @@ -159,14 +155,7 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?: } function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): void { - const document = parse(graphqlOperation) - const operationDefinitions = document.definitions.filter((def) => def.kind === 'OperationDefinition') - - if (operationDefinitions.length !== 1) { - throw new AbortError( - 'GraphQL document must contain exactly one operation definition. Multiple operations are not supported.', - ) - } + validateSingleOperation(graphqlOperation) if (!isMutation(graphqlOperation) && variablesJsonl) { throw new AbortError( @@ -186,15 +175,3 @@ function isMutation(graphqlOperation: string): boolean { const operation = document.definitions.find((def) => def.kind === 'OperationDefinition') return operation?.kind === 'OperationDefinition' && operation.operation === 'mutation' } - -async function validateApiVersion(adminSession: {token: string; storeFqdn: string}, version: string): Promise { - if (version === 'unstable') return - - const supportedVersions = await supportedApiVersions(adminSession) - if (supportedVersions.includes(version)) return - - const firstLine = outputContent`Invalid API version: ${version}`.value - const secondLine = outputContent`Supported versions: ${supportedVersions.join(', ')}`.value - - throw new AbortError(`${firstLine}\n${secondLine}`) -} diff --git a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts index 2988b54b0c9..9af33fe2b71 100644 --- a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts @@ -38,6 +38,7 @@ export async function watchBulkOperation( } }, onAbort, + renderOptions: {stdout: process.stderr}, }) } diff --git a/packages/app/src/cli/services/dev/fetch.test.ts b/packages/app/src/cli/services/dev/fetch.test.ts index 73e9c40cef3..1dee7e85dda 100644 --- a/packages/app/src/cli/services/dev/fetch.test.ts +++ b/packages/app/src/cli/services/dev/fetch.test.ts @@ -121,7 +121,12 @@ describe('fetchStore', () => { const got = fetchStore(ORG1, 'domain1', developerPlatformClient) // Then - await expect(got).rejects.toThrow(new AbortError(`Could not find Store for domain domain1 in Organization org1.`)) + await expect(got).rejects.toThrow( + new AbortError( + `Could not find store for domain domain1 in organization org1.`, + `Ensure you've provided the correct store domain, that the store is a dev store, and that you have access to the store.`, + ), + ) }) }) diff --git a/packages/app/src/cli/services/dev/fetch.ts b/packages/app/src/cli/services/dev/fetch.ts index 94d8bd436e5..ea07d3b40f0 100644 --- a/packages/app/src/cli/services/dev/fetch.ts +++ b/packages/app/src/cli/services/dev/fetch.ts @@ -125,7 +125,11 @@ export async function fetchStore( ): Promise { const store = await developerPlatformClient.storeByDomain(org.id, storeFqdn) - if (!store) throw new AbortError(`Could not find Store for domain ${storeFqdn} in Organization ${org.businessName}.`) + if (!store) + throw new AbortError( + `Could not find store for domain ${storeFqdn} in organization ${org.businessName}.`, + `Ensure you've provided the correct store domain, that the store is a dev store, and that you have access to the store.`, + ) return store } diff --git a/packages/app/src/cli/services/execute-operation.test.ts b/packages/app/src/cli/services/execute-operation.test.ts new file mode 100644 index 00000000000..fdbb538a4a7 --- /dev/null +++ b/packages/app/src/cli/services/execute-operation.test.ts @@ -0,0 +1,251 @@ +import {executeOperation} from './execute-operation.js' +import {createAdminSessionAsApp, validateApiVersion} from './graphql/common.js' +import {OrganizationApp} from '../models/organization.js' +import {renderSuccess, renderError, renderSingleTask} from '@shopify/cli-kit/node/ui' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {ClientError} from 'graphql-request' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' + +vi.mock('./graphql/common.js') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('@shopify/cli-kit/node/fs') + +describe('executeOperation', () => { + const mockRemoteApp = { + apiKey: 'test-app-client-id', + apiSecretKeys: [{secret: 'test-api-secret'}], + title: 'Test App', + } as OrganizationApp + + const storeFqdn = 'test-store.myshopify.com' + const mockAdminSession = {token: 'test-token', storeFqdn} + + beforeEach(() => { + vi.mocked(createAdminSessionAsApp).mockResolvedValue(mockAdminSession) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + return task(() => {}) + }) + }) + + afterEach(() => { + mockAndCaptureOutput().clear() + }) + + test('executes GraphQL operation successfully', async () => { + const query = 'query { shop { name } }' + const mockResult = {data: {shop: {name: 'Test Shop'}}} + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + + await executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + }) + + expect(createAdminSessionAsApp).toHaveBeenCalledWith(mockRemoteApp, storeFqdn) + expect(adminRequestDoc).toHaveBeenCalledWith({ + // parsed GraphQL document + query: expect.any(Object), + session: mockAdminSession, + variables: undefined, + version: undefined, + responseOptions: {handleErrors: false}, + }) + }) + + test('passes variables correctly when provided', async () => { + const query = 'mutation UpdateProduct($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' + const variables = '{"input":{"id":"gid://shopify/Product/123","title":"Updated"}}' + const mockResult = {data: {productUpdate: {product: {id: 'gid://shopify/Product/123'}}}} + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + + await executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + variables, + }) + + expect(adminRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + variables: JSON.parse(variables), + }), + ) + }) + + test('throws AbortError when variables contain invalid JSON', async () => { + const query = 'query { shop { name } }' + const invalidVariables = '{invalid json}' + + await expect( + executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + variables: invalidVariables, + }), + ).rejects.toThrow('Invalid JSON') + + expect(adminRequestDoc).not.toHaveBeenCalled() + }) + + test('uses specified API version when provided', async () => { + const query = 'query { shop { name } }' + const version = '2024-01' + const mockResult = {data: {shop: {name: 'Test Shop'}}} + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + vi.mocked(validateApiVersion).mockResolvedValue() + + await executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + version, + }) + + expect(validateApiVersion).toHaveBeenCalledWith(mockAdminSession, version) + expect(adminRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + version, + }), + ) + }) + + test('does not validate version when not provided', async () => { + const query = 'query { shop { name } }' + const mockResult = {data: {shop: {name: 'Test Shop'}}} + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + vi.mocked(validateApiVersion).mockClear() + + await executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + }) + + expect(validateApiVersion).not.toHaveBeenCalled() + }) + + test('writes formatted JSON results to stdout by default', async () => { + const query = 'query { shop { name } }' + const mockResult = {data: {shop: {name: 'Test Shop'}}} + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + + const mockOutput = mockAndCaptureOutput() + + await executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + }) + + const expectedOutput = JSON.stringify(mockResult, null, 2) + expect(mockOutput.info()).toContain(expectedOutput) + expect(writeFile).not.toHaveBeenCalled() + }) + + test('writes results to file when outputFile is provided', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputFile = joinPath(tmpDir, 'results.json') + const query = 'query { shop { name } }' + const mockResult = {data: {shop: {name: 'Test Shop'}}} + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + + await executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + outputFile, + }) + + const expectedContent = JSON.stringify(mockResult, null, 2) + expect(writeFile).toHaveBeenCalledWith(outputFile, expectedContent) + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining(outputFile), + }), + ) + }) + }) + + test('renders success message after successful execution', async () => { + const query = 'query { shop { name } }' + const mockResult = {data: {shop: {name: 'Test Shop'}}} + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + + await executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + }) + + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + headline: 'Operation succeeded.', + }), + ) + }) + + test('throws when API request fails', async () => { + const query = 'query { shop { name } }' + const apiError = new Error('API request failed') + vi.mocked(adminRequestDoc).mockRejectedValue(apiError) + + await expect( + executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + }), + ).rejects.toThrow('API request failed') + }) + + test('handles GraphQL errors in response', async () => { + const query = 'query { shop { name } }' + const mockResult = { + data: null, + errors: [{message: 'Field "name" not found'}], + } + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + + await executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + }) + + // Should still format and output the result with errors + const mockOutput = mockAndCaptureOutput() + const expectedOutput = JSON.stringify(mockResult, null, 2) + expect(mockOutput.info()).toContain(expectedOutput) + }) + + test('handles ClientError from GraphQL validation failures', async () => { + const query = 'query { invalidField }' + const graphqlErrors = [ + {message: 'Field "invalidField" doesn\'t exist on type "QueryRoot"', locations: [{line: 1, column: 9}]}, + ] + const clientError = new ClientError({errors: graphqlErrors} as any, {query: '', variables: {}}) + // Set response property that our code accesses + ;(clientError as any).response = {errors: graphqlErrors} + + vi.mocked(adminRequestDoc).mockRejectedValue(clientError) + + await executeOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + }) + + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({ + headline: 'GraphQL operation failed.', + body: expect.stringContaining('invalidField'), + }), + ) + }) +}) diff --git a/packages/app/src/cli/services/execute-operation.ts b/packages/app/src/cli/services/execute-operation.ts new file mode 100644 index 00000000000..6c8c04da1c3 --- /dev/null +++ b/packages/app/src/cli/services/execute-operation.ts @@ -0,0 +1,106 @@ +import {createAdminSessionAsApp, validateSingleOperation, validateApiVersion} from './graphql/common.js' +import {OrganizationApp} from '../models/organization.js' +import {renderSuccess, renderError, renderInfo, renderSingleTask} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {ClientError} from 'graphql-request' +import {parse} from 'graphql' +import {writeFile} from '@shopify/cli-kit/node/fs' + +interface ExecuteOperationInput { + remoteApp: OrganizationApp + storeFqdn: string + query: string + variables?: string + outputFile?: string + version?: string +} + +async function parseVariables(variables?: string): Promise<{[key: string]: unknown} | undefined> { + if (!variables) return undefined + + try { + return JSON.parse(variables) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + throw new AbortError( + outputContent`Invalid JSON in ${outputToken.yellow('--variables')} flag: ${errorMessage}`, + 'Please provide valid JSON format.', + ) + } +} + +export async function executeOperation(input: ExecuteOperationInput): Promise { + const {remoteApp, storeFqdn, query, variables, version, outputFile} = input + + renderInfo({ + headline: 'Executing GraphQL operation.', + body: [ + { + list: { + items: [ + `App: ${remoteApp.title}`, + `Store: ${storeFqdn}`, + `API version: ${version ?? 'default (latest stable)'}`, + ], + }, + }, + ], + }) + + const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) + + if (version) await validateApiVersion(adminSession, version) + + const parsedVariables = await parseVariables(variables) + + validateSingleOperation(query) + + try { + const result = await renderSingleTask({ + title: outputContent`Executing GraphQL operation`, + task: async () => { + return adminRequestDoc({ + query: parse(query), + session: adminSession, + variables: parsedVariables, + version, + responseOptions: {handleErrors: false}, + }) + }, + renderOptions: {stdout: process.stderr}, + }) + + const resultString = JSON.stringify(result, null, 2) + + if (outputFile) { + await writeFile(outputFile, resultString) + renderSuccess({ + headline: 'Operation succeeded.', + body: `Results written to ${outputFile}`, + }) + } else { + renderSuccess({ + headline: 'Operation succeeded.', + }) + outputResult(resultString) + } + } catch (error) { + if (error instanceof ClientError) { + // GraphQL errors from user's query - render as error + const errorResult = { + errors: error.response.errors, + } + const errorString = JSON.stringify(errorResult, null, 2) + + renderError({ + headline: 'GraphQL operation failed.', + body: errorString, + }) + return + } + // Network/system errors - let them propagate + throw error + } +} diff --git a/packages/app/src/cli/services/graphql/common.test.ts b/packages/app/src/cli/services/graphql/common.test.ts new file mode 100644 index 00000000000..8e4fc1118f9 --- /dev/null +++ b/packages/app/src/cli/services/graphql/common.test.ts @@ -0,0 +1,133 @@ +import {createAdminSessionAsApp, validateSingleOperation, validateApiVersion} from './common.js' +import {OrganizationApp} from '../../models/organization.js' +import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' +import {supportedApiVersions} from '@shopify/cli-kit/node/api/admin' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/session', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/session') + return { + ...actual, + ensureAuthenticatedAdminAsApp: vi.fn(), + } +}) + +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + supportedApiVersions: vi.fn(), + } +}) + +describe('createAdminSessionAsApp', () => { + const mockRemoteApp = { + apiKey: 'test-api-key', + apiSecretKeys: [{secret: 'test-api-secret'}], + title: 'Test App', + } as OrganizationApp + + const storeFqdn = 'test-store.myshopify.com' + const mockAdminSession = {token: 'test-token', storeFqdn} + + beforeEach(() => { + vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession) + }) + + test('creates admin session with app credentials', async () => { + const session = await createAdminSessionAsApp(mockRemoteApp, storeFqdn) + + expect(ensureAuthenticatedAdminAsApp).toHaveBeenCalledWith( + storeFqdn, + mockRemoteApp.apiKey, + mockRemoteApp.apiSecretKeys[0]!.secret, + ) + expect(session).toEqual(mockAdminSession) + }) + + test('throws BugError when app has no API secret keys', async () => { + const appWithoutSecret = { + ...mockRemoteApp, + apiSecretKeys: [], + } as OrganizationApp + + await expect(createAdminSessionAsApp(appWithoutSecret, storeFqdn)).rejects.toThrow( + 'No API secret keys found for app', + ) + + expect(ensureAuthenticatedAdminAsApp).not.toHaveBeenCalled() + }) +}) + +describe('validateSingleOperation', () => { + test('accepts valid query operation', () => { + const query = 'query { shop { name } }' + + expect(() => validateSingleOperation(query)).not.toThrow() + }) + + test('accepts valid mutation operation', () => { + const mutation = 'mutation { productUpdate(input: {}) { product { id } } }' + + expect(() => validateSingleOperation(mutation)).not.toThrow() + }) + + test('accepts query with shorthand syntax', () => { + const query = '{ shop { name } }' + + expect(() => validateSingleOperation(query)).not.toThrow() + }) + + test('throws on malformed GraphQL syntax', () => { + const malformedQuery = '{ shop { name }' + + expect(() => validateSingleOperation(malformedQuery)).toThrow('Syntax Error') + }) + + test('throws when GraphQL document contains multiple operations', () => { + // eslint-disable-next-line @shopify/cli/no-inline-graphql + const multipleOperations = ` + query GetShop { shop { name } } + mutation UpdateProduct { productUpdate(input: {}) { product { id } } } + ` + + expect(() => validateSingleOperation(multipleOperations)).toThrow('must contain exactly one operation definition') + }) + + test('throws when GraphQL document contains no operations', () => { + const fragmentOnly = ` + fragment ProductFields on Product { + id + title + } + ` + + expect(() => validateSingleOperation(fragmentOnly)).toThrow('must contain exactly one operation definition') + }) +}) + +describe('validateApiVersion', () => { + const mockAdminSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} + + test('allows unstable version without validation', async () => { + await expect(validateApiVersion(mockAdminSession, 'unstable')).resolves.not.toThrow() + + expect(supportedApiVersions).not.toHaveBeenCalled() + }) + + test('allows supported API version', async () => { + vi.mocked(supportedApiVersions).mockResolvedValue(['2024-01', '2024-04', '2024-07']) + + await expect(validateApiVersion(mockAdminSession, '2024-04')).resolves.not.toThrow() + + expect(supportedApiVersions).toHaveBeenCalledWith(mockAdminSession) + }) + + test('throws error when API version is not supported', async () => { + vi.mocked(supportedApiVersions).mockResolvedValue(['2024-01', '2024-04', '2024-07']) + + await expect(validateApiVersion(mockAdminSession, '2023-01')).rejects.toThrow('Invalid API version: 2023-01') + + expect(supportedApiVersions).toHaveBeenCalledWith(mockAdminSession) + }) +}) diff --git a/packages/app/src/cli/services/graphql/common.ts b/packages/app/src/cli/services/graphql/common.ts new file mode 100644 index 00000000000..24474cf3f05 --- /dev/null +++ b/packages/app/src/cli/services/graphql/common.ts @@ -0,0 +1,69 @@ +import {OrganizationApp} from '../../models/organization.js' +import {ensureAuthenticatedAdminAsApp, AdminSession} from '@shopify/cli-kit/node/session' +import {AbortError, BugError} from '@shopify/cli-kit/node/error' +import {outputContent} from '@shopify/cli-kit/node/output' +import {supportedApiVersions} from '@shopify/cli-kit/node/api/admin' +import {parse} from 'graphql' + +/** + * Creates an Admin API session authenticated as an app using client credentials. + * + * @param remoteApp - The organization app containing API credentials. + * @param storeFqdn - The fully qualified domain name of the store. + * @returns Admin session for making authenticated API requests. + */ +export async function createAdminSessionAsApp(remoteApp: OrganizationApp, storeFqdn: string): Promise { + const appSecret = remoteApp.apiSecretKeys[0]?.secret + if (!appSecret) throw new BugError('No API secret keys found for app') + + return ensureAuthenticatedAdminAsApp(storeFqdn, remoteApp.apiKey, appSecret) +} + +/** + * Validates that a GraphQL document contains exactly one operation definition. + * + * @param graphqlOperation - The GraphQL query or mutation string to validate. + * @throws AbortError if the document doesn't contain exactly one operation or has syntax errors. + */ +export function validateSingleOperation(graphqlOperation: string): void { + let document + try { + document = parse(graphqlOperation) + } catch (error) { + if (error instanceof Error) { + throw new AbortError(`Invalid GraphQL syntax: ${error.message}`) + } + throw error + } + + const operationDefinitions = document.definitions.filter((def) => def.kind === 'OperationDefinition') + + if (operationDefinitions.length !== 1) { + throw new AbortError( + 'GraphQL document must contain exactly one operation definition. Multiple operations are not supported.', + ) + } +} + +/** + * Validates that the specified API version is supported by the store. + * The 'unstable' version is always allowed without validation. + * + * @param adminSession - Admin session containing store credentials. + * @param version - The API version to validate. + * @throws AbortError if the version is not supported by the store. + */ +export async function validateApiVersion( + adminSession: {token: string; storeFqdn: string}, + version: string, +): Promise { + if (version === 'unstable') return + + const supportedVersions = await supportedApiVersions(adminSession) + if (supportedVersions.includes(version)) return + + const firstLine = outputContent`Invalid API version: ${version}`.value + const secondLine = outputContent`Supported versions: ${supportedVersions.join(', ')}`.value + + throw new AbortError(`${firstLine}\n${secondLine}`) +} diff --git a/packages/app/src/cli/utilities/execute-command-helpers.test.ts b/packages/app/src/cli/utilities/execute-command-helpers.test.ts new file mode 100644 index 00000000000..5705c59daee --- /dev/null +++ b/packages/app/src/cli/utilities/execute-command-helpers.test.ts @@ -0,0 +1,173 @@ +import {prepareAppStoreContext, prepareExecuteContext} from './execute-command-helpers.js' +import {linkedAppContext} from '../services/app-context.js' +import {storeContext} from '../services/store-context.js' +import {readStdinString} from '@shopify/cli-kit/node/system' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('../services/app-context.js') +vi.mock('../services/store-context.js') +vi.mock('@shopify/cli-kit/node/system') + +describe('prepareAppStoreContext', () => { + const mockFlags = { + path: '/test/path', + 'client-id': 'test-client-id', + reset: false, + config: 'test-config', + store: 'test-store.myshopify.com', + } + + const mockAppContextResult = { + app: {title: 'Test App'}, + remoteApp: {apiKey: 'test-key'}, + developerPlatformClient: {}, + organization: {id: 'org-123'}, + specifications: [], + } + + const mockStore = { + shopId: '123', + shopDomain: 'test-store.myshopify.com', + shopName: 'Test Store', + } + + beforeEach(() => { + vi.mocked(linkedAppContext).mockResolvedValue(mockAppContextResult as any) + vi.mocked(storeContext).mockResolvedValue(mockStore as any) + }) + + test('calls linkedAppContext with correct parameters', async () => { + await prepareAppStoreContext(mockFlags) + + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: mockFlags.path, + clientId: mockFlags['client-id'], + forceRelink: mockFlags.reset, + userProvidedConfigName: mockFlags.config, + }) + }) + + test('calls storeContext with correct parameters', async () => { + await prepareAppStoreContext(mockFlags) + + expect(storeContext).toHaveBeenCalledWith({ + appContextResult: mockAppContextResult, + storeFqdn: mockFlags.store, + forceReselectStore: mockFlags.reset, + }) + }) + + test('returns app context and store', async () => { + const result = await prepareAppStoreContext(mockFlags) + + expect(result).toEqual({ + appContextResult: mockAppContextResult, + store: mockStore, + }) + }) + + test('handles optional client-id and config flags', async () => { + const flagsWithoutOptionals = { + path: '/test/path', + reset: false, + } + + await prepareAppStoreContext(flagsWithoutOptionals) + + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: flagsWithoutOptionals.path, + clientId: undefined, + forceRelink: false, + userProvidedConfigName: undefined, + }) + }) +}) + +describe('prepareExecuteContext', () => { + const mockFlags = { + path: '/test/path', + 'client-id': 'test-client-id', + reset: false, + config: 'test-config', + store: 'test-store.myshopify.com', + query: 'query { shop { name } }', + } + + const mockAppContextResult = { + app: {title: 'Test App'}, + remoteApp: {apiKey: 'test-key'}, + developerPlatformClient: {}, + organization: {id: 'org-123'}, + specifications: [], + } + + const mockStore = { + shopId: '123', + shopDomain: 'test-store.myshopify.com', + shopName: 'Test Store', + } + + beforeEach(() => { + vi.mocked(linkedAppContext).mockResolvedValue(mockAppContextResult as any) + vi.mocked(storeContext).mockResolvedValue(mockStore as any) + vi.mocked(readStdinString).mockResolvedValue('') + }) + + test('uses query from flags when provided', async () => { + const result = await prepareExecuteContext(mockFlags) + + expect(result.query).toBe(mockFlags.query) + expect(readStdinString).not.toHaveBeenCalled() + }) + + test('reads query from stdin when flag not provided', async () => { + const stdinQuery = 'query { products { edges { node { id } } } }' + vi.mocked(readStdinString).mockResolvedValue(stdinQuery) + + const flagsWithoutQuery = {...mockFlags, query: undefined} + const result = await prepareExecuteContext(flagsWithoutQuery) + + expect(readStdinString).toHaveBeenCalled() + expect(result.query).toBe(stdinQuery) + }) + + test('throws AbortError when no query provided via flag or stdin', async () => { + vi.mocked(readStdinString).mockResolvedValue('') + + const flagsWithoutQuery = {...mockFlags, query: undefined} + + await expect(prepareExecuteContext(flagsWithoutQuery)).rejects.toThrow('No query provided') + }) + + test('includes command name in error message', async () => { + vi.mocked(readStdinString).mockResolvedValue('') + + const flagsWithoutQuery = {...mockFlags, query: undefined} + + try { + await prepareExecuteContext(flagsWithoutQuery, 'bulk execute') + expect.fail('Should have thrown an error') + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error: any) { + expect(error.message).toContain('No query provided') + expect(error.tryMessage).toMatch(/shopify app bulk execute/) + } + }) + + test('returns query, app context, and store', async () => { + const result = await prepareExecuteContext(mockFlags) + + expect(result).toEqual({ + query: mockFlags.query, + appContextResult: mockAppContextResult, + store: mockStore, + }) + }) + + test('delegates to prepareAppStoreContext for context setup', async () => { + await prepareExecuteContext(mockFlags) + + expect(linkedAppContext).toHaveBeenCalled() + expect(storeContext).toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/utilities/execute-command-helpers.ts b/packages/app/src/cli/utilities/execute-command-helpers.ts new file mode 100644 index 00000000000..4c40d542d1f --- /dev/null +++ b/packages/app/src/cli/utilities/execute-command-helpers.ts @@ -0,0 +1,75 @@ +import {linkedAppContext, LoadedAppContextOutput} from '../services/app-context.js' +import {storeContext} from '../services/store-context.js' +import {OrganizationStore} from '../models/organization.js' +import {readStdinString} from '@shopify/cli-kit/node/system' +import {AbortError} from '@shopify/cli-kit/node/error' + +interface AppStoreContextFlags { + path: string + 'client-id'?: string + reset: boolean + config?: string + store?: string +} + +interface AppStoreContext { + appContextResult: LoadedAppContextOutput + store: OrganizationStore +} + +interface ExecuteCommandFlags extends AppStoreContextFlags { + query?: string +} + +interface ExecuteContext extends AppStoreContext { + query: string +} + +/** + * Prepares the app and store context for commands. + * Sets up app linking and store selection without query handling. + * + * @param flags - Command flags containing configuration options. + * @returns Context object containing app context and store information. + */ +export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promise { + const appContextResult = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, + }) + + const store = await storeContext({ + appContextResult, + storeFqdn: flags.store, + forceReselectStore: flags.reset, + }) + + return {appContextResult, store} +} + +/** + * Prepares the execution context for GraphQL operations. + * Handles query input from flag or stdin, and sets up app and store contexts. + * + * @param flags - Command flags containing configuration options. + * @param commandName - Name of the command for error messages (e.g., 'execute', 'bulk execute'). + * @returns Context object containing query, app context, and store information. + */ +export async function prepareExecuteContext( + flags: ExecuteCommandFlags, + commandName = 'execute', +): Promise { + const query = flags.query ?? (await readStdinString()) + if (!query) { + throw new AbortError( + 'No query provided. Use the --query flag or pipe input via stdin.', + `Example: echo "query { ... }" | shopify app ${commandName}`, + ) + } + + const {appContextResult, store} = await prepareAppStoreContext(flags) + + return {query, appContextResult, store} +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 1d2645fe570..e3724c7aca7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9,6 +9,7 @@ * [`shopify app dev clean`](#shopify-app-dev-clean) * [`shopify app env pull`](#shopify-app-env-pull) * [`shopify app env show`](#shopify-app-env-show) +* [`shopify app execute`](#shopify-app-execute) * [`shopify app function build`](#shopify-app-function-build) * [`shopify app function info`](#shopify-app-function-info) * [`shopify app function replay`](#shopify-app-function-replay) @@ -351,6 +352,35 @@ DESCRIPTION Displays environment variables that can be used to deploy apps and app extensions. ``` +## `shopify app execute` + +Execute GraphQL queries and mutations. + +``` +USAGE + $ shopify app execute [--client-id | -c ] [--no-color] [--output-file ] [--path ] + [-q ] [--reset | ] [-s ] [-v ] [--verbose] [--version ] + +FLAGS + -c, --config= The name of the app configuration. + -q, --query= The GraphQL query or mutation, as a string. + -s, --store= The myshopify.com domain of the store to execute against. The app must be installed on the + store. If not specified, you will be prompted to select a store. + -v, --variables= The values for any GraphQL variables in your query or mutation, in JSON format. + --client-id= The Client ID of your app. + --no-color Disable color output. + --output-file= The file name where results should be written, instead of STDOUT. + --path= The path to your app directory. + --reset Reset all your settings. + --verbose Increase the verbosity of the output. + --version= The API version to use for the query or mutation. Defaults to the latest stable version. + +DESCRIPTION + Execute GraphQL queries and mutations. + + Executes an Admin API GraphQL query or mutation on the specified dev store. +``` + ## `shopify app function build` Compile a function to wasm. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9c7f19ca148..da5f1d81b81 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -92,7 +92,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Execute bulk operations against the Shopify Admin API.", + "description": "Executes an Admin API GraphQL query or mutation on the specified dev store, as a bulk operation.", "flags": { "client-id": { "description": "The Client ID of your app.", @@ -1124,6 +1124,127 @@ "strict": true, "summary": "Display app and extensions environment variables." }, + "app:execute": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Executes an Admin API GraphQL query or mutation on the specified dev store.", + "flags": { + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "output-file": { + "description": "The file name where results should be written, instead of STDOUT.", + "env": "SHOPIFY_FLAG_OUTPUT_FILE", + "hasDynamicHelp": false, + "multiple": false, + "name": "output-file", + "type": "option" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "query": { + "char": "q", + "description": "The GraphQL query or mutation, as a string.", + "env": "SHOPIFY_FLAG_QUERY", + "hasDynamicHelp": false, + "multiple": false, + "name": "query", + "required": false, + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store to execute against. The app must be installed on the store. If not specified, you will be prompted to select a store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "type": "option" + }, + "variables": { + "char": "v", + "description": "The values for any GraphQL variables in your query or mutation, in JSON format.", + "env": "SHOPIFY_FLAG_VARIABLES", + "hasDynamicHelp": false, + "multiple": false, + "name": "variables", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + }, + "version": { + "description": "The API version to use for the query or mutation. Defaults to the latest stable version.", + "env": "SHOPIFY_FLAG_VERSION", + "hasDynamicHelp": false, + "multiple": false, + "name": "version", + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "app:execute", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Execute GraphQL queries and mutations." + }, "app:function:build": { "aliases": [ ], diff --git a/packages/features/snapshots/commands.txt b/packages/features/snapshots/commands.txt index 27611017577..604f5ac81d5 100644 --- a/packages/features/snapshots/commands.txt +++ b/packages/features/snapshots/commands.txt @@ -10,6 +10,7 @@ │ ├─ env │ │ ├─ pull │ │ └─ show +│ ├─ execute │ ├─ function │ │ ├─ build │ │ ├─ info