Skip to content

Commit 7373ab2

Browse files
nickwesselmanclaude
andcommitted
Add shopify store execute command
Adds a new `store execute` command that executes Admin API GraphQL operations authenticated as the current user (via `ensureAuthenticatedAdmin`) rather than as an app. This doesn't require an app to be linked or installed — only a `--store` flag (required). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 088b8e3 commit 7373ab2

9 files changed

Lines changed: 414 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import StoreExecute from './execute.js'
2+
import {storeExecuteOperation} from '../../services/store-execute-operation.js'
3+
import {loadQuery} from '../../utilities/execute-command-helpers.js'
4+
import {describe, expect, test, vi} from 'vitest'
5+
6+
vi.mock('../../services/store-execute-operation.js')
7+
vi.mock('../../utilities/execute-command-helpers.js')
8+
9+
describe('store execute command', () => {
10+
test('requires --store flag', async () => {
11+
vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }')
12+
vi.mocked(storeExecuteOperation).mockResolvedValue()
13+
14+
await expect(
15+
StoreExecute.run(['--query', 'query { shop { name } }'], import.meta.url),
16+
).rejects.toThrow()
17+
18+
expect(storeExecuteOperation).not.toHaveBeenCalled()
19+
})
20+
21+
test('calls storeExecuteOperation with correct arguments', async () => {
22+
vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }')
23+
vi.mocked(storeExecuteOperation).mockResolvedValue()
24+
25+
await StoreExecute.run(
26+
['--store', 'test-store.myshopify.com', '--query', 'query { shop { name } }'],
27+
import.meta.url,
28+
)
29+
30+
expect(loadQuery).toHaveBeenCalledWith(
31+
expect.objectContaining({query: 'query { shop { name } }'}),
32+
)
33+
expect(storeExecuteOperation).toHaveBeenCalledWith(
34+
expect.objectContaining({
35+
storeFqdn: 'test-store.myshopify.com',
36+
query: 'query { shop { name } }',
37+
}),
38+
)
39+
})
40+
41+
test('passes version flag when provided', async () => {
42+
vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }')
43+
vi.mocked(storeExecuteOperation).mockResolvedValue()
44+
45+
await StoreExecute.run(
46+
['--store', 'test-store.myshopify.com', '--query', 'query { shop { name } }', '--version', '2024-01'],
47+
import.meta.url,
48+
)
49+
50+
expect(storeExecuteOperation).toHaveBeenCalledWith(
51+
expect.objectContaining({
52+
version: '2024-01',
53+
}),
54+
)
55+
})
56+
57+
test('passes output-file flag when provided', async () => {
58+
vi.mocked(loadQuery).mockResolvedValue('query { shop { name } }')
59+
vi.mocked(storeExecuteOperation).mockResolvedValue()
60+
61+
await StoreExecute.run(
62+
['--store', 'test-store.myshopify.com', '--query', 'query { shop { name } }', '--output-file', '/tmp/out.json'],
63+
import.meta.url,
64+
)
65+
66+
expect(storeExecuteOperation).toHaveBeenCalledWith(
67+
expect.objectContaining({
68+
outputFile: '/tmp/out.json',
69+
}),
70+
)
71+
})
72+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {storeOperationFlags} from '../../flags.js'
2+
import {storeExecuteOperation} from '../../services/store-execute-operation.js'
3+
import {loadQuery} from '../../utilities/execute-command-helpers.js'
4+
import {globalFlags} from '@shopify/cli-kit/node/cli'
5+
import BaseCommand from '@shopify/cli-kit/node/base-command'
6+
7+
export default class StoreExecute extends BaseCommand {
8+
static summary = 'Execute GraphQL queries and mutations against a store.'
9+
10+
static descriptionWithMarkdown = `Executes an Admin API GraphQL query or mutation on the specified store, authenticated as the current user.
11+
12+
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.`
13+
14+
static description = this.descriptionWithoutMarkdown()
15+
16+
static flags = {
17+
...globalFlags,
18+
...storeOperationFlags,
19+
}
20+
21+
async run(): Promise<void> {
22+
const {flags} = await this.parse(StoreExecute)
23+
const query = await loadQuery(flags)
24+
await storeExecuteOperation({
25+
storeFqdn: flags.store,
26+
query,
27+
variables: flags.variables,
28+
variableFile: flags['variable-file'],
29+
outputFile: flags['output-file'],
30+
...(flags.version && {version: flags.version}),
31+
})
32+
}
33+
}

packages/app/src/cli/flags.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,49 @@ export const bulkOperationFlags = {
8686
}),
8787
}
8888

89+
export const storeOperationFlags = {
90+
query: Flags.string({
91+
char: 'q',
92+
description: 'The GraphQL query or mutation, as a string.',
93+
env: 'SHOPIFY_FLAG_QUERY',
94+
required: false,
95+
exactlyOne: ['query', 'query-file'],
96+
}),
97+
'query-file': Flags.string({
98+
description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
99+
env: 'SHOPIFY_FLAG_QUERY_FILE',
100+
parse: async (input) => resolvePath(input),
101+
exactlyOne: ['query', 'query-file'],
102+
}),
103+
variables: Flags.string({
104+
char: 'v',
105+
description: 'The values for any GraphQL variables in your query or mutation, in JSON format.',
106+
env: 'SHOPIFY_FLAG_VARIABLES',
107+
exclusive: ['variable-file'],
108+
}),
109+
'variable-file': Flags.string({
110+
description: "Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.",
111+
env: 'SHOPIFY_FLAG_VARIABLE_FILE',
112+
parse: async (input) => resolvePath(input),
113+
exclusive: ['variables'],
114+
}),
115+
store: Flags.string({
116+
char: 's',
117+
description: 'The myshopify.com domain of the store to execute against.',
118+
env: 'SHOPIFY_FLAG_STORE',
119+
parse: async (input) => normalizeStoreFqdn(input),
120+
required: true,
121+
}),
122+
version: Flags.string({
123+
description: 'The API version to use for the query or mutation. Defaults to the latest stable version.',
124+
env: 'SHOPIFY_FLAG_VERSION',
125+
}),
126+
'output-file': Flags.string({
127+
description: 'The file name where results should be written, instead of STDOUT.',
128+
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
129+
}),
130+
}
131+
89132
export const operationFlags = {
90133
query: Flags.string({
91134
char: 'q',

packages/app/src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import AppUnlinkedCommand from './utilities/app-unlinked-command.js'
3737
import FunctionInfo from './commands/app/function/info.js'
3838
import ImportCustomDataDefinitions from './commands/app/import-custom-data-definitions.js'
3939
import OrganizationList from './commands/organization/list.js'
40+
import StoreExecute from './commands/store/execute.js'
4041
import BaseCommand from '@shopify/cli-kit/node/base-command'
4142

4243
/**
@@ -78,6 +79,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin
7879
'webhook:trigger': WebhookTriggerDeprecated,
7980
'demo:watcher': DemoWatcher,
8081
'organization:list': OrganizationList,
82+
'store:execute': StoreExecute,
8183
}
8284

8385
export const AppSensitiveMetadataHook = gatherSensitiveMetadata
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {storeExecuteOperation} from './store-execute-operation.js'
2+
import {resolveApiVersion} from './graphql/common.js'
3+
import {runGraphQLExecution} from './execute-operation.js'
4+
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
5+
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
6+
import {describe, test, expect, vi, beforeEach} from 'vitest'
7+
8+
vi.mock('./graphql/common.js')
9+
vi.mock('./execute-operation.js')
10+
vi.mock('@shopify/cli-kit/node/ui')
11+
vi.mock('@shopify/cli-kit/node/session')
12+
13+
describe('storeExecuteOperation', () => {
14+
const storeFqdn = 'test-store.myshopify.com'
15+
const mockAdminSession = {token: 'user-token', storeFqdn}
16+
17+
beforeEach(() => {
18+
vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockAdminSession)
19+
vi.mocked(resolveApiVersion).mockResolvedValue('2024-07')
20+
vi.mocked(renderSingleTask).mockImplementation(async ({task}) => {
21+
return task(() => {})
22+
})
23+
vi.mocked(runGraphQLExecution).mockResolvedValue(undefined)
24+
})
25+
26+
test('authenticates as user via ensureAuthenticatedAdmin', async () => {
27+
await storeExecuteOperation({
28+
storeFqdn,
29+
query: 'query { shop { name } }',
30+
})
31+
32+
expect(ensureAuthenticatedAdmin).toHaveBeenCalledWith(storeFqdn)
33+
})
34+
35+
test('resolves API version', async () => {
36+
await storeExecuteOperation({
37+
storeFqdn,
38+
query: 'query { shop { name } }',
39+
})
40+
41+
expect(resolveApiVersion).toHaveBeenCalledWith({adminSession: mockAdminSession})
42+
})
43+
44+
test('passes user-specified version to resolveApiVersion', async () => {
45+
await storeExecuteOperation({
46+
storeFqdn,
47+
query: 'query { shop { name } }',
48+
version: '2024-01',
49+
})
50+
51+
expect(resolveApiVersion).toHaveBeenCalledWith({
52+
adminSession: mockAdminSession,
53+
userSpecifiedVersion: '2024-01',
54+
})
55+
})
56+
57+
test('delegates to runGraphQLExecution with correct args', async () => {
58+
await storeExecuteOperation({
59+
storeFqdn,
60+
query: 'query { shop { name } }',
61+
variables: '{"key":"value"}',
62+
variableFile: '/path/to/vars.json',
63+
outputFile: '/path/to/output.json',
64+
})
65+
66+
expect(runGraphQLExecution).toHaveBeenCalledWith({
67+
adminSession: mockAdminSession,
68+
query: 'query { shop { name } }',
69+
variables: '{"key":"value"}',
70+
variableFile: '/path/to/vars.json',
71+
outputFile: '/path/to/output.json',
72+
version: '2024-07',
73+
})
74+
})
75+
76+
test('passes undefined optional fields when not provided', async () => {
77+
await storeExecuteOperation({
78+
storeFqdn,
79+
query: 'query { shop { name } }',
80+
})
81+
82+
expect(runGraphQLExecution).toHaveBeenCalledWith({
83+
adminSession: mockAdminSession,
84+
query: 'query { shop { name } }',
85+
variables: undefined,
86+
variableFile: undefined,
87+
outputFile: undefined,
88+
version: '2024-07',
89+
})
90+
})
91+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {resolveApiVersion} from './graphql/common.js'
2+
import {runGraphQLExecution} from './execute-operation.js'
3+
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
4+
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
5+
import {outputContent} from '@shopify/cli-kit/node/output'
6+
7+
interface StoreExecuteOperationInput {
8+
storeFqdn: string
9+
query: string
10+
variables?: string
11+
variableFile?: string
12+
outputFile?: string
13+
version?: string
14+
}
15+
16+
export async function storeExecuteOperation(input: StoreExecuteOperationInput): Promise<void> {
17+
const {storeFqdn, query, variables, variableFile, outputFile, version: userSpecifiedVersion} = input
18+
19+
const adminSession = await ensureAuthenticatedAdmin(storeFqdn)
20+
21+
const version = await renderSingleTask({
22+
title: outputContent`Resolving API version`,
23+
task: async (): Promise<string> => {
24+
return resolveApiVersion({adminSession, userSpecifiedVersion})
25+
},
26+
renderOptions: {stdout: process.stderr},
27+
})
28+
29+
await runGraphQLExecution({adminSession, query, variables, variableFile, outputFile, version})
30+
}

packages/cli/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
* [`shopify plugins unlink [PLUGIN]`](#shopify-plugins-unlink-plugin)
7474
* [`shopify plugins update`](#shopify-plugins-update)
7575
* [`shopify search [query]`](#shopify-search-query)
76+
* [`shopify store execute`](#shopify-store-execute)
7677
* [`shopify theme check`](#shopify-theme-check)
7778
* [`shopify theme console`](#shopify-theme-console)
7879
* [`shopify theme delete`](#shopify-theme-delete)
@@ -2047,6 +2048,41 @@ EXAMPLES
20472048
shopify search "<a search query separated by spaces>"
20482049
```
20492050

2051+
## `shopify store execute`
2052+
2053+
Execute GraphQL queries and mutations against a store.
2054+
2055+
```
2056+
USAGE
2057+
$ shopify store execute -s <value> [--no-color] [--output-file <value>] [-q <value>] [--query-file <value>]
2058+
[--variable-file <value> | -v <value>] [--verbose] [--version <value>]
2059+
2060+
FLAGS
2061+
-q, --query=<value> [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string.
2062+
-s, --store=<value> (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to execute
2063+
against.
2064+
-v, --variables=<value> [env: SHOPIFY_FLAG_VARIABLES] The values for any GraphQL variables in your query or
2065+
mutation, in JSON format.
2066+
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
2067+
--output-file=<value> [env: SHOPIFY_FLAG_OUTPUT_FILE] The file name where results should be written, instead of
2068+
STDOUT.
2069+
--query-file=<value> [env: SHOPIFY_FLAG_QUERY_FILE] Path to a file containing the GraphQL query or mutation.
2070+
Can't be used with --query.
2071+
--variable-file=<value> [env: SHOPIFY_FLAG_VARIABLE_FILE] Path to a file containing GraphQL variables in JSON
2072+
format. Can't be used with --variables.
2073+
--verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output.
2074+
--version=<value> [env: SHOPIFY_FLAG_VERSION] The API version to use for the query or mutation. Defaults to
2075+
the latest stable version.
2076+
2077+
DESCRIPTION
2078+
Execute GraphQL queries and mutations against a store.
2079+
2080+
Executes an Admin API GraphQL query or mutation on the specified store, authenticated as the current user.
2081+
2082+
Unlike "`app execute`" (https://shopify.dev/docs/api/shopify-cli/app/app-execute), this command does not require an
2083+
app to be linked or installed on the target store.
2084+
```
2085+
20502086
## `shopify theme check`
20512087

20522088
Validate the theme.

0 commit comments

Comments
 (0)