diff --git a/.changeset/store-execute-graphiql.md b/.changeset/store-execute-graphiql.md
new file mode 100644
index 0000000000..742853f63b
--- /dev/null
+++ b/.changeset/store-execute-graphiql.md
@@ -0,0 +1,7 @@
+---
+'@shopify/cli-kit': minor
+'@shopify/app': minor
+'@shopify/store': minor
+---
+
+Open an interactive GraphiQL UI when running `shopify store execute` without `--query` or `--query-file`. The GraphiQL session uses the access token previously set up via `shopify store auth`, points at the same Admin GraphQL endpoint as the non-interactive mode, and respects `--allow-mutations` (mutations are blocked by default). The GraphiQL HTTP server has moved into `@shopify/cli-kit/node/graphiql/server` so both `shopify app dev` and `shopify store execute` can reuse it; behavior of `shopify app dev` is unchanged.
diff --git a/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx b/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx
index 0a2a2476dc..3cc7a75b8a 100644
--- a/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx
+++ b/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx
@@ -230,9 +230,7 @@ export function graphiqlTemplate({
{}} icon={DisabledIcon}>
-
- The server has been stopped. Restart dev from the CLI.
-
+ The server has been stopped. Restart it from the CLI.
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 54f76ab351..53b685823a 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -2141,27 +2141,32 @@ EXAMPLES
## `shopify store execute`
-Execute GraphQL queries and mutations on a store.
+Execute GraphQL queries and mutations on a store, or open an interactive GraphiQL UI.
```
USAGE
- $ shopify store execute -s [--allow-mutations] [-j] [--no-color] [--output-file ] [-q ]
- [--query-file ] [--variable-file | -v ] [--verbose] [--version ]
+ $ shopify store execute -s [--allow-mutations] [--graphiql-port ] [-j] [--no-color] [--no-open]
+ [--output-file ] [-q | --query-file ] [--variable-file | -v ] [--verbose]
+ [--version ]
FLAGS
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output.
- -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string.
+ -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string. Omit to open
+ GraphiQL.
-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.
--allow-mutations [env: SHOPIFY_FLAG_ALLOW_MUTATIONS] Allow GraphQL mutations to run against the target
store.
+ --graphiql-port= [env: SHOPIFY_FLAG_GRAPHIQL_PORT] Local port for the GraphiQL server when no --query or
+ --query-file is provided.
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
+ --no-open [env: SHOPIFY_FLAG_NO_OPEN] Do not open the GraphiQL URL in the browser automatically.
--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.
+ Can't be used with --query. Omit to open GraphiQL.
--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.
@@ -2169,13 +2174,17 @@ FLAGS
the latest stable version.
DESCRIPTION
- Execute GraphQL queries and mutations on a store.
+ Execute GraphQL queries and mutations on a store, or open an interactive GraphiQL UI.
Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.
Run `shopify store auth` first to create stored auth for the store.
- Mutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data.
+ When neither `--query` nor `--query-file` is provided, opens a local GraphiQL UI in the browser pointed at the store.
+ Use `--graphiql-port` to choose the port and `--no-open` to keep the browser closed.
+
+ Mutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data; the same flag
+ controls whether the GraphiQL UI is allowed to issue mutations.
EXAMPLES
$ shopify store execute --store shop.myshopify.com --query "query { shop { name } }"
@@ -2185,6 +2194,8 @@ EXAMPLES
$ shopify store execute --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations
$ shopify store execute --store shop.myshopify.com --query "query { shop { name } }" --json
+
+ $ shopify store execute --store shop.myshopify.com
```
## `shopify theme check`
diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json
index fb2180a152..e5d262ffc3 100644
--- a/packages/cli/oclif.manifest.json
+++ b/packages/cli/oclif.manifest.json
@@ -5868,13 +5868,14 @@
"args": {
},
"customPluginName": "@shopify/store",
- "description": "Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.\n\nRun `shopify store auth` first to create stored auth for the store.\n\nMutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data.",
- "descriptionWithMarkdown": "Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.\n\nRun `shopify store auth` first to create stored auth for the store.\n\nMutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data.",
+ "description": "Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.\n\nRun `shopify store auth` first to create stored auth for the store.\n\nWhen neither `--query` nor `--query-file` is provided, opens a local GraphiQL UI in the browser pointed at the store. Use `--graphiql-port` to choose the port and `--no-open` to keep the browser closed.\n\nMutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data; the same flag controls whether the GraphiQL UI is allowed to issue mutations.",
+ "descriptionWithMarkdown": "Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.\n\nRun `shopify store auth` first to create stored auth for the store.\n\nWhen neither `--query` nor `--query-file` is provided, opens a local GraphiQL UI in the browser pointed at the store. Use `--graphiql-port` to choose the port and `--no-open` to keep the browser closed.\n\nMutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data; the same flag controls whether the GraphiQL UI is allowed to issue mutations.",
"examples": [
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\"",
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables '{\"id\":\"gid://shopify/Product/1\"}'",
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"mutation { shop { id } }\" --allow-mutations",
- "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\" --json"
+ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\" --json",
+ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com"
],
"flags": {
"allow-mutations": {
@@ -5884,6 +5885,14 @@
"name": "allow-mutations",
"type": "boolean"
},
+ "graphiql-port": {
+ "description": "Local port for the GraphiQL server when no --query or --query-file is provided.",
+ "env": "SHOPIFY_FLAG_GRAPHIQL_PORT",
+ "hasDynamicHelp": false,
+ "multiple": false,
+ "name": "graphiql-port",
+ "type": "option"
+ },
"json": {
"allowNo": false,
"char": "j",
@@ -5901,6 +5910,13 @@
"name": "no-color",
"type": "boolean"
},
+ "no-open": {
+ "allowNo": false,
+ "description": "Do not open the GraphiQL URL in the browser automatically.",
+ "env": "SHOPIFY_FLAG_NO_OPEN",
+ "name": "no-open",
+ "type": "boolean"
+ },
"output-file": {
"description": "The file name where results should be written, instead of STDOUT.",
"env": "SHOPIFY_FLAG_OUTPUT_FILE",
@@ -5911,8 +5927,11 @@
},
"query": {
"char": "q",
- "description": "The GraphQL query or mutation, as a string.",
+ "description": "The GraphQL query or mutation, as a string. Omit to open GraphiQL.",
"env": "SHOPIFY_FLAG_QUERY",
+ "exclusive": [
+ "query-file"
+ ],
"hasDynamicHelp": false,
"multiple": false,
"name": "query",
@@ -5920,8 +5939,11 @@
"type": "option"
},
"query-file": {
- "description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
+ "description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query. Omit to open GraphiQL.",
"env": "SHOPIFY_FLAG_QUERY_FILE",
+ "exclusive": [
+ "query"
+ ],
"hasDynamicHelp": false,
"multiple": false,
"name": "query-file",
@@ -5985,7 +6007,7 @@
"pluginName": "@shopify/cli",
"pluginType": "core",
"strict": true,
- "summary": "Execute GraphQL queries and mutations on a store."
+ "summary": "Execute GraphQL queries and mutations on a store, or open an interactive GraphiQL UI."
},
"theme:check": {
"aliases": [
diff --git a/packages/store/src/cli/commands/store/execute.test.ts b/packages/store/src/cli/commands/store/execute.test.ts
index d4b7b8b333..9307f6053e 100644
--- a/packages/store/src/cli/commands/store/execute.test.ts
+++ b/packages/store/src/cli/commands/store/execute.test.ts
@@ -1,14 +1,17 @@
import StoreExecute from './execute.js'
import {executeStoreOperation} from '../../services/store/execute/index.js'
+import {openStoreGraphiQL} from '../../services/store/execute/graphiql.js'
import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js'
import {beforeEach, describe, expect, test, vi} from 'vitest'
vi.mock('../../services/store/execute/index.js')
vi.mock('../../services/store/execute/result.js')
+vi.mock('../../services/store/execute/graphiql.js')
describe('store execute command', () => {
beforeEach(() => {
vi.mocked(executeStoreOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}})
+ vi.mocked(openStoreGraphiQL).mockResolvedValue()
})
test('passes the inline query through to the service and writes the result', async () => {
@@ -52,5 +55,64 @@ describe('store execute command', () => {
expect(StoreExecute.flags['variable-file']).toBeDefined()
expect(StoreExecute.flags['allow-mutations']).toBeDefined()
expect(StoreExecute.flags.json).toBeDefined()
+ expect(StoreExecute.flags['graphiql-port']).toBeDefined()
+ expect(StoreExecute.flags['no-open']).toBeDefined()
+ })
+
+ describe('GraphiQL mode (no --query / --query-file)', () => {
+ test('opens GraphiQL with --allow-mutations false by default', async () => {
+ await StoreExecute.run(['--store', 'shop.myshopify.com'])
+
+ expect(openStoreGraphiQL).toHaveBeenCalledWith({
+ store: 'shop.myshopify.com',
+ port: undefined,
+ open: true,
+ allowMutations: false,
+ query: undefined,
+ variables: undefined,
+ apiVersion: undefined,
+ })
+ expect(executeStoreOperation).not.toHaveBeenCalled()
+ expect(writeOrOutputStoreExecuteResult).not.toHaveBeenCalled()
+ })
+
+ test('forwards --variables and --version as prefilled values for GraphiQL', async () => {
+ await StoreExecute.run([
+ '--store',
+ 'shop.myshopify.com',
+ '--variables',
+ '{"id":"gid://shopify/Product/1"}',
+ '--version',
+ '2024-10',
+ ])
+
+ expect(openStoreGraphiQL).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variables: '{"id":"gid://shopify/Product/1"}',
+ apiVersion: '2024-10',
+ }),
+ )
+ })
+
+ test('respects --graphiql-port and --no-open', async () => {
+ await StoreExecute.run(['--store', 'shop.myshopify.com', '--graphiql-port', '9123', '--no-open'])
+
+ expect(openStoreGraphiQL).toHaveBeenCalledWith(
+ expect.objectContaining({
+ port: 9123,
+ open: false,
+ }),
+ )
+ })
+
+ test('forwards --allow-mutations to the GraphiQL session', async () => {
+ await StoreExecute.run(['--store', 'shop.myshopify.com', '--allow-mutations'])
+
+ expect(openStoreGraphiQL).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowMutations: true,
+ }),
+ )
+ })
})
})
diff --git a/packages/store/src/cli/commands/store/execute.ts b/packages/store/src/cli/commands/store/execute.ts
index e3f0bd3fc3..2eac11d709 100644
--- a/packages/store/src/cli/commands/store/execute.ts
+++ b/packages/store/src/cli/commands/store/execute.ts
@@ -1,19 +1,23 @@
import {executeStoreOperation} from '../../services/store/execute/index.js'
+import {openStoreGraphiQL} from '../../services/store/execute/graphiql.js'
import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js'
import StoreCommand from '../../utilities/store-command.js'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
+import {outputWarn} from '@shopify/cli-kit/node/output'
import {resolvePath} from '@shopify/cli-kit/node/path'
import {Flags} from '@oclif/core'
export default class StoreExecute extends StoreCommand {
- static summary = 'Execute GraphQL queries and mutations on a store.'
+ static summary = 'Execute GraphQL queries and mutations on a store, or open an interactive GraphiQL UI.'
static descriptionWithMarkdown = `Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.
Run \`shopify store auth\` first to create stored auth for the store.
-Mutations are disabled by default. Re-run with \`--allow-mutations\` if you intend to modify store data.`
+When neither \`--query\` nor \`--query-file\` is provided, opens a local GraphiQL UI in the browser pointed at the store. Use \`--graphiql-port\` to choose the port and \`--no-open\` to keep the browser closed.
+
+Mutations are disabled by default. Re-run with \`--allow-mutations\` if you intend to modify store data; the same flag controls whether the GraphiQL UI is allowed to issue mutations.`
static description = this.descriptionWithoutMarkdown()
@@ -22,6 +26,7 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte
`<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables '{"id":"gid://shopify/Product/1"}'`,
'<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations',
'<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }" --json',
+ '<%= config.bin %> <%= command.id %> --store shop.myshopify.com',
]
static flags = {
@@ -29,16 +34,17 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte
...jsonFlag,
query: Flags.string({
char: 'q',
- description: 'The GraphQL query or mutation, as a string.',
+ description: 'The GraphQL query or mutation, as a string. Omit to open GraphiQL.',
env: 'SHOPIFY_FLAG_QUERY',
required: false,
- exactlyOne: ['query', 'query-file'],
+ exclusive: ['query-file'],
}),
'query-file': Flags.string({
- description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
+ description:
+ "Path to a file containing the GraphQL query or mutation. Can't be used with --query. Omit to open GraphiQL.",
env: 'SHOPIFY_FLAG_QUERY_FILE',
parse: async (input) => resolvePath(input),
- exactlyOne: ['query', 'query-file'],
+ exclusive: ['query'],
}),
variables: Flags.string({
char: 'v',
@@ -73,11 +79,40 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte
env: 'SHOPIFY_FLAG_ALLOW_MUTATIONS',
default: false,
}),
+ 'graphiql-port': Flags.integer({
+ description: 'Local port for the GraphiQL server when no --query or --query-file is provided.',
+ env: 'SHOPIFY_FLAG_GRAPHIQL_PORT',
+ }),
+ 'no-open': Flags.boolean({
+ description: 'Do not open the GraphiQL URL in the browser automatically.',
+ env: 'SHOPIFY_FLAG_NO_OPEN',
+ default: false,
+ }),
}
public async run(): Promise {
const {flags} = await this.parse(StoreExecute)
+ if (!flags.query && !flags['query-file']) {
+ if (flags['output-file']) {
+ outputWarn('--output-file is ignored when opening GraphiQL.')
+ }
+ if (flags.json) {
+ outputWarn('--json is ignored when opening GraphiQL.')
+ }
+
+ await openStoreGraphiQL({
+ store: flags.store,
+ port: flags['graphiql-port'],
+ open: !flags['no-open'],
+ allowMutations: flags['allow-mutations'],
+ query: flags.query,
+ variables: flags.variables,
+ apiVersion: flags.version,
+ })
+ return
+ }
+
const result = await executeStoreOperation({
store: flags.store,
query: flags.query,
diff --git a/packages/store/src/cli/services/store/execute/graphiql.test.ts b/packages/store/src/cli/services/store/execute/graphiql.test.ts
new file mode 100644
index 0000000000..f95e2d10a9
--- /dev/null
+++ b/packages/store/src/cli/services/store/execute/graphiql.test.ts
@@ -0,0 +1,168 @@
+import {openStoreGraphiQL} from './graphiql.js'
+import {loadStoredStoreSession} from '../auth/session-lifecycle.js'
+import {AbortController} from '@shopify/cli-kit/node/abort'
+import {setupGraphiQLServer, TokenProvider} from '@shopify/cli-kit/node/graphiql/server'
+import {openURL} from '@shopify/cli-kit/node/system'
+import {getAvailableTCPPort} from '@shopify/cli-kit/node/tcp'
+import {beforeEach, describe, expect, test, vi} from 'vitest'
+
+vi.mock('@shopify/cli-kit/node/graphiql/server')
+vi.mock('@shopify/cli-kit/node/tcp')
+vi.mock('@shopify/cli-kit/node/system')
+vi.mock('../auth/session-lifecycle.js')
+
+const mockedSetup = vi.mocked(setupGraphiQLServer)
+const mockedOpenURL = vi.mocked(openURL)
+const mockedGetPort = vi.mocked(getAvailableTCPPort)
+const mockedLoadSession = vi.mocked(loadStoredStoreSession)
+
+function fakeServer() {
+ const server = {close: vi.fn()}
+ mockedSetup.mockReturnValueOnce(server as unknown as ReturnType)
+ return server
+}
+
+function abortAfter(controller: AbortController) {
+ setImmediate(() => controller.abort())
+ return controller.signal
+}
+
+describe('openStoreGraphiQL', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockedGetPort.mockResolvedValue(4567)
+ mockedOpenURL.mockResolvedValue(true)
+ mockedLoadSession.mockResolvedValue({
+ store: 'shop.myshopify.com',
+ accessToken: 'stored-token',
+ } as unknown as Awaited>)
+ })
+
+ test('forwards configuration to setupGraphiQLServer', async () => {
+ fakeServer()
+ const controller = new AbortController()
+
+ await openStoreGraphiQL({
+ store: 'shop.myshopify.com',
+ port: 4567,
+ open: false,
+ allowMutations: false,
+ abortSignal: abortAfter(controller),
+ })
+
+ expect(mockedGetPort).toHaveBeenCalledWith(4567)
+ expect(mockedSetup).toHaveBeenCalledWith(
+ expect.objectContaining({
+ port: 4567,
+ storeFqdn: 'shop.myshopify.com',
+ protectMutations: true,
+ }),
+ )
+ })
+
+ test('protectMutations follows --allow-mutations', async () => {
+ fakeServer()
+ const controller = new AbortController()
+
+ await openStoreGraphiQL({
+ store: 'shop.myshopify.com',
+ open: false,
+ allowMutations: true,
+ abortSignal: abortAfter(controller),
+ })
+
+ expect(mockedSetup).toHaveBeenCalledWith(
+ expect.objectContaining({
+ protectMutations: false,
+ }),
+ )
+ })
+
+ test('uses a TokenProvider backed by loadStoredStoreSession', async () => {
+ fakeServer()
+ const controller = new AbortController()
+
+ await openStoreGraphiQL({
+ store: 'shop.myshopify.com',
+ open: false,
+ abortSignal: abortAfter(controller),
+ })
+
+ const tokenProvider = mockedSetup.mock.calls[0]![0].tokenProvider as TokenProvider
+ expect(await tokenProvider.getToken()).toBe('stored-token')
+ expect(mockedLoadSession).toHaveBeenCalledWith('shop.myshopify.com')
+ })
+
+ test('opens the URL in the browser by default', async () => {
+ fakeServer()
+ const controller = new AbortController()
+
+ await openStoreGraphiQL({
+ store: 'shop.myshopify.com',
+ abortSignal: abortAfter(controller),
+ })
+
+ expect(mockedOpenURL).toHaveBeenCalledWith(expect.stringMatching(/^http:\/\/localhost:4567\/graphiql/))
+ })
+
+ test('generates a key, passes it to the server, and includes it in the URL', async () => {
+ fakeServer()
+ const controller = new AbortController()
+
+ await openStoreGraphiQL({
+ store: 'shop.myshopify.com',
+ abortSignal: abortAfter(controller),
+ })
+
+ const setupOptions = mockedSetup.mock.calls[0]![0]
+ const openedUrl = mockedOpenURL.mock.calls[0]![0]
+
+ expect(setupOptions.key).toMatch(/^[0-9a-f]{64}$/)
+ expect(new URL(openedUrl).searchParams.get('key')).toBe(setupOptions.key)
+ })
+
+ test('does not open the URL when open is false', async () => {
+ fakeServer()
+ const controller = new AbortController()
+
+ await openStoreGraphiQL({
+ store: 'shop.myshopify.com',
+ open: false,
+ abortSignal: abortAfter(controller),
+ })
+
+ expect(mockedOpenURL).not.toHaveBeenCalled()
+ })
+
+ test('closes the server after the abort signal fires', async () => {
+ const server = fakeServer()
+ const controller = new AbortController()
+
+ await openStoreGraphiQL({
+ store: 'shop.myshopify.com',
+ open: false,
+ abortSignal: abortAfter(controller),
+ })
+
+ expect(server.close).toHaveBeenCalled()
+ })
+
+ test('encodes prefilled query, variables, and apiVersion into the URL', async () => {
+ fakeServer()
+ const controller = new AbortController()
+
+ await openStoreGraphiQL({
+ store: 'shop.myshopify.com',
+ query: 'query { shop { name } }',
+ variables: '{"id":1}',
+ apiVersion: '2024-10',
+ abortSignal: abortAfter(controller),
+ })
+
+ const openedUrl = mockedOpenURL.mock.calls[0]![0]
+ const parsed = new URL(openedUrl)
+ expect(parsed.searchParams.get('query')).toBe('query { shop { name } }')
+ expect(parsed.searchParams.get('variables')).toBe('{"id":1}')
+ expect(parsed.searchParams.get('api_version')).toBe('2024-10')
+ })
+})
diff --git a/packages/store/src/cli/services/store/execute/graphiql.ts b/packages/store/src/cli/services/store/execute/graphiql.ts
new file mode 100644
index 0000000000..7443567fe9
--- /dev/null
+++ b/packages/store/src/cli/services/store/execute/graphiql.ts
@@ -0,0 +1,114 @@
+import {loadStoredStoreSession} from '../auth/session-lifecycle.js'
+import {setupGraphiQLServer, TokenProvider} from '@shopify/cli-kit/node/graphiql/server'
+import {getAvailableTCPPort} from '@shopify/cli-kit/node/tcp'
+import {openURL} from '@shopify/cli-kit/node/system'
+import {outputContent, outputInfo, outputToken, outputWarn} from '@shopify/cli-kit/node/output'
+import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort'
+import {randomBytes} from 'crypto'
+
+interface OpenStoreGraphiQLOptions {
+ store: string
+ port?: number
+ open?: boolean
+ allowMutations?: boolean
+ query?: string
+ variables?: string
+ apiVersion?: string
+ /**
+ * Test-only seam: aborts the server-running loop without requiring a real SIGINT.
+ * In production, the command itself listens for SIGINT and exits.
+ */
+ abortSignal?: AbortSignal
+}
+
+/**
+ * Spins up a GraphiQL server pointed at `store` using credentials previously stored
+ * by `shopify store auth`, prints the URL, optionally opens the browser, and waits
+ * for the process to be aborted (Ctrl+C) before shutting down.
+ */
+export async function openStoreGraphiQL(options: OpenStoreGraphiQLOptions): Promise {
+ const tokenProvider = createStoredSessionTokenProvider(options.store)
+
+ // Generate the GraphiQL key here (instead of letting the server generate one internally)
+ // so we can include it in the URL we print and open. The server requires `?key=...` on
+ // every request and rejects mismatches with HTTP 404.
+ const key = randomBytes(32).toString('hex')
+
+ const port = await getAvailableTCPPort(options.port)
+ const server = setupGraphiQLServer({
+ stdout: process.stdout,
+ port,
+ storeFqdn: options.store,
+ tokenProvider,
+ key,
+ protectMutations: !options.allowMutations,
+ })
+
+ const url = buildGraphiQLUrl({
+ port,
+ key,
+ query: options.query,
+ variables: options.variables,
+ apiVersion: options.apiVersion,
+ })
+
+ outputInfo(outputContent`GraphiQL is running at ${outputToken.link(url)}`)
+ outputInfo(outputContent`Mutations are ${options.allowMutations ? outputToken.green('allowed') : outputToken.yellow('blocked')}.`)
+ outputInfo('Press Ctrl+C to stop.')
+
+ if (options.open !== false) {
+ const opened = await openURL(url)
+ if (!opened) {
+ outputWarn('Browser did not open automatically. Open the URL above manually.')
+ }
+ }
+
+ await waitForAbort(options.abortSignal)
+ server.close()
+}
+
+function createStoredSessionTokenProvider(store: string): TokenProvider {
+ return {
+ getToken: async () => (await loadStoredStoreSession(store)).accessToken,
+ refreshToken: async () => (await loadStoredStoreSession(store)).accessToken,
+ }
+}
+
+function buildGraphiQLUrl(options: {
+ port: number
+ key: string
+ query?: string
+ variables?: string
+ apiVersion?: string
+}): string {
+ const url = new URL(`http://localhost:${options.port}/graphiql`)
+ url.searchParams.set('key', options.key)
+ if (options.query) url.searchParams.set('query', options.query)
+ if (options.variables) url.searchParams.set('variables', options.variables)
+ if (options.apiVersion) url.searchParams.set('api_version', options.apiVersion)
+ return url.toString()
+}
+
+/**
+ * Resolves when the abort signal fires, or when the process receives SIGINT.
+ * Used to keep the server alive until the user explicitly stops the command.
+ */
+async function waitForAbort(externalSignal?: AbortSignal): Promise {
+ const controller = new AbortController()
+
+ const onSigint = () => controller.abort()
+ process.once('SIGINT', onSigint)
+
+ try {
+ await new Promise((resolve) => {
+ if (controller.signal.aborted) {
+ resolve()
+ return
+ }
+ controller.signal.addEventListener('abort', () => resolve(), {once: true})
+ externalSignal?.addEventListener('abort', () => controller.abort(), {once: true})
+ })
+ } finally {
+ process.removeListener('SIGINT', onSigint)
+ }
+}