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) + } +}