diff --git a/packages/app/src/cli/commands/store/execute.test.ts b/packages/app/src/cli/commands/store/execute.test.ts new file mode 100644 index 0000000000..5434e729a5 --- /dev/null +++ b/packages/app/src/cli/commands/store/execute.test.ts @@ -0,0 +1,68 @@ +import StoreExecute from './execute.js' +import {storeExecuteOperation} from '../../services/store-execute-operation.js' +import {loadQuery} from '../../utilities/execute-command-helpers.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/store-execute-operation.js') +vi.mock('../../utilities/execute-command-helpers.js') + +describe('store execute command', () => { + test('requires --store flag', async () => { + vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }') + vi.mocked(storeExecuteOperation).mockResolvedValue() + + await expect(StoreExecute.run(['--query', 'query { shop { name } }'], import.meta.url)).rejects.toThrow() + + expect(storeExecuteOperation).not.toHaveBeenCalled() + }) + + test('calls storeExecuteOperation with correct arguments', async () => { + vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }') + vi.mocked(storeExecuteOperation).mockResolvedValue() + + await StoreExecute.run( + ['--store', 'test-store.myshopify.com', '--query', 'query { shop { name } }'], + import.meta.url, + ) + + expect(loadQuery).toHaveBeenCalledWith(expect.objectContaining({query: 'query { shop { name } }'})) + expect(storeExecuteOperation).toHaveBeenCalledWith( + expect.objectContaining({ + storeFqdn: 'test-store.myshopify.com', + query: 'query { shop { name } }', + }), + ) + }) + + test('passes version flag when provided', async () => { + vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }') + vi.mocked(storeExecuteOperation).mockResolvedValue() + + await StoreExecute.run( + ['--store', 'test-store.myshopify.com', '--query', 'query { shop { name } }', '--version', '2024-01'], + import.meta.url, + ) + + expect(storeExecuteOperation).toHaveBeenCalledWith( + expect.objectContaining({ + version: '2024-01', + }), + ) + }) + + test('passes output-file flag when provided', async () => { + vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }') + vi.mocked(storeExecuteOperation).mockResolvedValue() + + await StoreExecute.run( + ['--store', 'test-store.myshopify.com', '--query', 'query { shop { name } }', '--output-file', '/tmp/out.json'], + import.meta.url, + ) + + expect(storeExecuteOperation).toHaveBeenCalledWith( + expect.objectContaining({ + outputFile: '/tmp/out.json', + }), + ) + }) +}) diff --git a/packages/app/src/cli/commands/store/execute.ts b/packages/app/src/cli/commands/store/execute.ts new file mode 100644 index 0000000000..11ab5b739d --- /dev/null +++ b/packages/app/src/cli/commands/store/execute.ts @@ -0,0 +1,33 @@ +import {storeOperationFlags} from '../../flags.js' +import {storeExecuteOperation} from '../../services/store-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 StoreExecute extends BaseCommand { + static summary = 'Execute GraphQL queries and mutations against a store.' + + static descriptionWithMarkdown = `Executes an Admin API GraphQL query or mutation on the specified store, authenticated as the current user. + + Unlike [\`app execute\`](https://shopify.dev/docs/api/shopify-cli/app/app-execute), this command does not require an app to be linked or installed on the target store.` + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + ...storeOperationFlags, + } + + async run(): Promise { + const {flags} = await this.parse(StoreExecute) + const query = await loadQuery(flags) + await storeExecuteOperation({ + storeFqdn: flags.store, + query, + variables: flags.variables, + variableFile: flags['variable-file'], + outputFile: flags['output-file'], + ...(flags.version && {version: flags.version}), + }) + } +} diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index d9a9776380..55d8525c5d 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -86,6 +86,49 @@ export const bulkOperationFlags = { }), } +export const storeOperationFlags = { + query: Flags.string({ + char: 'q', + description: 'The GraphQL query or mutation, as a string.', + 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 query or mutation, in JSON format.', + env: 'SHOPIFY_FLAG_VARIABLES', + exclusive: ['variable-file'], + }), + 'variable-file': Flags.string({ + description: "Path to a file containing GraphQL variables in JSON format. 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, + }), + 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', + }), +} + 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 77fcd5076c..ba206c0b4d 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -37,6 +37,7 @@ import AppUnlinkedCommand from './utilities/app-unlinked-command.js' 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 BaseCommand from '@shopify/cli-kit/node/base-command' /** @@ -78,6 +79,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'webhook:trigger': WebhookTriggerDeprecated, 'demo:watcher': DemoWatcher, 'organization:list': OrganizationList, + 'store:execute': StoreExecute, } export const AppSensitiveMetadataHook = gatherSensitiveMetadata diff --git a/packages/app/src/cli/services/store-execute-operation.test.ts b/packages/app/src/cli/services/store-execute-operation.test.ts new file mode 100644 index 0000000000..6f43cdc89c --- /dev/null +++ b/packages/app/src/cli/services/store-execute-operation.test.ts @@ -0,0 +1,91 @@ +import {storeExecuteOperation} from './store-execute-operation.js' +import {resolveApiVersion} from './graphql/common.js' +import {runGraphQLExecution} from './execute-operation.js' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('./graphql/common.js') +vi.mock('./execute-operation.js') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/session') + +describe('storeExecuteOperation', () => { + const storeFqdn = 'test-store.myshopify.com' + const mockAdminSession = {token: 'user-token', storeFqdn} + + beforeEach(() => { + vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockAdminSession) + vi.mocked(resolveApiVersion).mockResolvedValue('2024-07') + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + return task(() => {}) + }) + vi.mocked(runGraphQLExecution).mockResolvedValue(undefined) + }) + + test('authenticates as user via ensureAuthenticatedAdmin', async () => { + await storeExecuteOperation({ + storeFqdn, + query: 'query { shop { name } }', + }) + + expect(ensureAuthenticatedAdmin).toHaveBeenCalledWith(storeFqdn) + }) + + test('resolves API version', async () => { + await storeExecuteOperation({ + storeFqdn, + query: 'query { shop { name } }', + }) + + expect(resolveApiVersion).toHaveBeenCalledWith({adminSession: mockAdminSession}) + }) + + test('passes user-specified version to resolveApiVersion', async () => { + await storeExecuteOperation({ + storeFqdn, + query: 'query { shop { name } }', + version: '2024-01', + }) + + expect(resolveApiVersion).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + userSpecifiedVersion: '2024-01', + }) + }) + + test('delegates to runGraphQLExecution with correct args', async () => { + await storeExecuteOperation({ + storeFqdn, + query: 'query { shop { name } }', + variables: '{"key":"value"}', + variableFile: '/path/to/vars.json', + outputFile: '/path/to/output.json', + }) + + expect(runGraphQLExecution).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query: 'query { shop { name } }', + variables: '{"key":"value"}', + variableFile: '/path/to/vars.json', + outputFile: '/path/to/output.json', + version: '2024-07', + }) + }) + + test('passes undefined optional fields when not provided', async () => { + await storeExecuteOperation({ + storeFqdn, + query: 'query { shop { name } }', + }) + + expect(runGraphQLExecution).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query: 'query { shop { name } }', + variables: undefined, + variableFile: undefined, + outputFile: undefined, + version: '2024-07', + }) + }) +}) diff --git a/packages/app/src/cli/services/store-execute-operation.ts b/packages/app/src/cli/services/store-execute-operation.ts new file mode 100644 index 0000000000..ca305671c8 --- /dev/null +++ b/packages/app/src/cli/services/store-execute-operation.ts @@ -0,0 +1,30 @@ +import {resolveApiVersion} from './graphql/common.js' +import {runGraphQLExecution} from './execute-operation.js' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {outputContent} from '@shopify/cli-kit/node/output' + +interface StoreExecuteOperationInput { + storeFqdn: string + query: string + variables?: string + variableFile?: string + outputFile?: string + version?: string +} + +export async function storeExecuteOperation(input: StoreExecuteOperationInput): Promise { + const {storeFqdn, query, variables, variableFile, outputFile, version: userSpecifiedVersion} = input + + const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + + const version = await renderSingleTask({ + title: outputContent`Resolving API version`, + task: async (): Promise => { + return resolveApiVersion({adminSession, userSpecifiedVersion}) + }, + renderOptions: {stdout: process.stderr}, + }) + + await runGraphQLExecution({adminSession, query, variables, variableFile, outputFile, version}) +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 9a19a126c0..e9a9e2f798 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -74,6 +74,7 @@ * [`shopify plugins unlink [PLUGIN]`](#shopify-plugins-unlink-plugin) * [`shopify plugins update`](#shopify-plugins-update) * [`shopify search [query]`](#shopify-search-query) +* [`shopify store execute`](#shopify-store-execute) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) * [`shopify theme delete`](#shopify-theme-delete) @@ -2063,6 +2064,41 @@ EXAMPLES shopify search "" ``` +## `shopify store execute` + +Execute GraphQL queries and mutations against a store. + +``` +USAGE + $ shopify store execute -s [--no-color] [--output-file ] [-q ] [--query-file ] + [--variable-file | -v ] [--verbose] [--version ] + +FLAGS + -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string. + -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 query or + mutation, in JSON format. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --output-file= [env: SHOPIFY_FLAG_OUTPUT_FILE] The file name where results should be written, instead of + 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 JSON + format. 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 query or mutation. Defaults to + the latest stable version. + +DESCRIPTION + Execute GraphQL queries and mutations against a store. + + Executes an Admin API GraphQL query or mutation on the specified store, authenticated as the current user. + + Unlike "`app execute`" (https://shopify.dev/docs/api/shopify-cli/app/app-execute), this command does not require an + app to be linked or installed on the target store. +``` + ## `shopify theme check` Validate the theme. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3e1200d270..3eba082973 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5757,6 +5757,110 @@ "strict": true, "usage": "search [query]" }, + "store:execute": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Executes an Admin API GraphQL query or mutation on the specified store, authenticated as the current user.\n\n Unlike \"`app execute`\" (https://shopify.dev/docs/api/shopify-cli/app/app-execute), this command does not require an app to be linked or installed on the target store.", + "descriptionWithMarkdown": "Executes an Admin API GraphQL query or mutation on the specified store, authenticated as the current user.\n\n Unlike [`app execute`](https://shopify.dev/docs/api/shopify-cli/app/app-execute), this command does not require an app to be linked or installed on the target store.", + "enableJsonFlag": false, + "flags": { + "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" + }, + "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" + }, + "query-file": { + "description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + "env": "SHOPIFY_FLAG_QUERY_FILE", + "hasDynamicHelp": false, + "multiple": false, + "name": "query-file", + "type": "option" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store to execute against.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "required": true, + "type": "option" + }, + "variable-file": { + "description": "Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.", + "env": "SHOPIFY_FLAG_VARIABLE_FILE", + "exclusive": [ + "variables" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "variable-file", + "type": "option" + }, + "variables": { + "char": "v", + "description": "The values for any GraphQL variables in your query or mutation, in JSON format.", + "env": "SHOPIFY_FLAG_VARIABLES", + "exclusive": [ + "variable-file" + ], + "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": "store:execute", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Execute GraphQL queries and mutations against a store." + }, "theme:check": { "aliases": [ ], diff --git a/packages/cli/package.json b/packages/cli/package.json index 21235a532d..06b0ed9998 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -123,6 +123,9 @@ "description": "View the available UI kit components.", "hidden": true }, + "store": { + "description": "Interact with Shopify stores." + }, "plugins": { "hidden": true } diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 6eacde1f84..c6778b234f 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -89,6 +89,8 @@ │ ├─ unlink │ └─ update ├─ search +├─ store +│ └─ execute ├─ theme │ ├─ check │ ├─ console