diff --git a/.changeset/graphiql-json-stream.md b/.changeset/graphiql-json-stream.md new file mode 100644 index 00000000000..006a6cf3119 --- /dev/null +++ b/.changeset/graphiql-json-stream.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add JSON lifecycle streaming to app and store GraphiQL commands. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index bd1fca43978..806fdc25965 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2693,6 +2693,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", "syntaxKind": "PropertySignature", @@ -2712,7 +2721,7 @@ "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "value": "export interface appgraphiql {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\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 * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port for the GraphiQL server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: 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 open GraphiQL 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. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use in GraphiQL. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + "value": "export interface appgraphiql {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\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 * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port for the GraphiQL server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: 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 open GraphiQL 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. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use in GraphiQL. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } }, "appimportcustomdatadefinitions": { @@ -8242,6 +8251,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERSION" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts", "syntaxKind": "PropertySignature", @@ -8260,7 +8278,7 @@ "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "value": "export interface storegraphiql {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Local port for the GraphiQL server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * The myshopify.com domain of the 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. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use in GraphiQL. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + "value": "export interface storegraphiql {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Local port for the GraphiQL server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * The myshopify.com domain of the 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. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use in GraphiQL. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } }, "storeinfo": { diff --git a/packages/app/src/cli/commands/app/graphiql.test.ts b/packages/app/src/cli/commands/app/graphiql.test.ts index 356af4f38fb..45bbdbe57c9 100644 --- a/packages/app/src/cli/commands/app/graphiql.test.ts +++ b/packages/app/src/cli/commands/app/graphiql.test.ts @@ -8,6 +8,11 @@ import { testOrganizationStore, testProject, } from '../../models/app/app.test-data.js' +import {launchCLI} from '@shopify/cli-kit/node/cli-launcher' +import {ShopifyConfig} from '@shopify/cli-kit/node/custom-oclif-loader' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputInfo} from '@shopify/cli-kit/node/output' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('../../services/app/graphiql.js') @@ -66,7 +71,62 @@ describe('app graphiql command', () => { port: 9123, variables: '{"id":1}', apiVersion: '2024-10', + outputFormat: 'text', }) expect(result).toEqual({app}) }) + + test.each([ + {json: false, noInput: false}, + {json: true, noInput: false}, + {json: false, noInput: true}, + {json: true, noInput: true}, + ])('keeps output format independent of input: %j', async ({json, noInput}) => { + await AppGraphiQL.run([ + '--store', + 'shop.myshopify.com', + ...(json ? ['--json'] : []), + ...(noInput ? ['--no-input'] : []), + ]) + expect(openAppGraphiQL).toHaveBeenCalledWith(expect.objectContaining({outputFormat: json ? 'json' : 'text'})) + }) + + test('exposes the lifecycle schema in command help', () => { + expect(AppGraphiQL.jsonOutputSchema.name).toBe('GraphiQLRecord') + expect(AppGraphiQL.description).toContain('Output from `--json` conforms to the `GraphiQLRecord` schema.') + }) + + test('discovers the schema without starting or authenticating GraphiQL', async () => { + vi.spyOn(ShopifyConfig.prototype, 'runHook').mockResolvedValue({successes: [], failures: []}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await launchCLI({ + moduleURL: new URL('../../../../../cli/src/bootstrap.ts', import.meta.url).href, + argv: ['app', 'graphiql', '--json-schema'], + lazyCommandLoader: async () => AppGraphiQL, + }) + expect(JSON.parse(stdout()).definitions.Result.anyOf).toHaveLength(3) + expect(openAppGraphiQL).not.toHaveBeenCalled() + expect(stderr()).toBe('') + }) + }) + + test.each(['--json', '-j'])('keeps fatal errors on stdout and diagnostics on stderr with %s', async (flag) => { + vi.stubEnv('SHOPIFY_CLI_NO_ANALYTICS', '1') + const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) + vi.mocked(openAppGraphiQL).mockImplementationOnce(async () => { + outputInfo('GraphiQL setup diagnostic') + throw new AbortError('GraphiQL setup failed.') + }) + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await AppGraphiQL.run(['--store', 'shop.myshopify.com', flag]) + expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message: 'GraphiQL setup failed.'}}) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'GraphiQL setup diagnostic'}) + expect(exit).toHaveBeenCalledWith(1) + }) + } finally { + exit.mockRestore() + vi.unstubAllEnvs() + } + }) }) diff --git a/packages/app/src/cli/commands/app/graphiql.ts b/packages/app/src/cli/commands/app/graphiql.ts index 7a1d730abfa..2d63d182934 100644 --- a/packages/app/src/cli/commands/app/graphiql.ts +++ b/packages/app/src/cli/commands/app/graphiql.ts @@ -3,8 +3,9 @@ import {openAppGraphiQL} from '../../services/app/graphiql.js' import {prepareAppStoreContext} from '../../utilities/execute-command-helpers.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {globalFlags, portFlag} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag, portFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' +import {graphiqlJsonOutputSchema} from '@shopify/cli-kit/node/graphiql/schema' export default class AppGraphiQL extends AppLinkedCommand { static summary = 'Open a local GraphiQL UI for your app and store.' @@ -13,6 +14,10 @@ export default class AppGraphiQL extends AppLinkedCommand { The app must be installed on the store.` + static get jsonOutputSchema() { + return graphiqlJsonOutputSchema + } + static description = this.descriptionForHelp() static examples = [ @@ -22,6 +27,7 @@ The app must be installed on the store.` static flags = { ...globalFlags, + ...jsonFlag, ...appFlags, store: Flags.string({ char: 's', @@ -47,16 +53,19 @@ The app must be installed on the store.` public async run(): Promise { const {flags} = await this.parse(AppGraphiQL) - const {appContextResult, store} = await prepareAppStoreContext(flags) - - await openAppGraphiQL({ - remoteApp: appContextResult.remoteApp, - store: store.shopDomain, - port: flags.port, - variables: flags.variables, - apiVersion: flags.version, - }) + return this.runWithParsedOutput(flags, async () => { + const {appContextResult, store} = await prepareAppStoreContext(flags) - return {app: appContextResult.app} + await openAppGraphiQL({ + remoteApp: appContextResult.remoteApp, + store: store.shopDomain, + port: flags.port, + variables: flags.variables, + apiVersion: flags.version, + outputFormat: flags.json ? 'json' : 'text', + }) + + return {app: appContextResult.app} + }) } } diff --git a/packages/app/src/cli/services/app/graphiql.test.ts b/packages/app/src/cli/services/app/graphiql.test.ts index 0458579af07..5cdc0a05783 100644 --- a/packages/app/src/cli/services/app/graphiql.test.ts +++ b/packages/app/src/cli/services/app/graphiql.test.ts @@ -71,12 +71,14 @@ describe('openAppGraphiQL', () => { store: 'shop.myshopify.com', variables: '{"id":1}', apiVersion: '2024-10', + outputFormat: 'json', }) expect(mockedRunGraphiQLSession).toHaveBeenCalledWith( expect.objectContaining({ variables: '{"id":1}', apiVersion: '2024-10', + outputFormat: 'json', }), ) }) diff --git a/packages/app/src/cli/services/app/graphiql.ts b/packages/app/src/cli/services/app/graphiql.ts index 9389b778704..e021a1ce360 100644 --- a/packages/app/src/cli/services/app/graphiql.ts +++ b/packages/app/src/cli/services/app/graphiql.ts @@ -12,9 +12,9 @@ interface OpenAppGraphiQLOptions { port?: number variables?: string apiVersion?: string + outputFormat?: 'text' | 'json' /** - * Test-only seam: aborts the server-running loop without requiring a real SIGINT. - * In production, the command itself listens for SIGINT and exits. + * Stops the session and closes its server when aborted. */ abortSignal?: AbortSignal } @@ -43,6 +43,7 @@ export async function openAppGraphiQL(options: OpenAppGraphiQLOptions): Promise< }, variables: options.variables, apiVersion: options.apiVersion, + outputFormat: options.outputFormat, abortSignal: options.abortSignal, }) } diff --git a/packages/cli-kit/src/public/node/graphiql/schema.test.ts b/packages/cli-kit/src/public/node/graphiql/schema.test.ts new file mode 100644 index 00000000000..2da1df43876 --- /dev/null +++ b/packages/cli-kit/src/public/node/graphiql/schema.test.ts @@ -0,0 +1,51 @@ +import {graphiqlJsonOutputSchema} from './schema.js' +import {expect, test} from 'vitest' +import {Ajv} from 'ajv' + +test.each(['shop.myshopify.com', 'shop.myshopify.io', 'shop.my.shop.dev', 'nested.shop.myshopify.com'])( + 'publishes a self-contained JSON Schema for every lifecycle record with store %s', + (storeDomain) => { + const validate = new Ajv({formats: {'date-time': true, uri: true}}).compile(graphiqlJsonOutputSchema.jsonSchema) + const timestamp = '2026-10-21T10:20:30Z' + for (const record of [ + {type: 'starting', timestamp, storeDomain}, + { + type: 'ready', + timestamp, + storeDomain, + url: 'http://localhost:3456/graphiql?key=key', + api: 'admin', + apiVersion: null, + mutationsAllowed: false, + app: null, + }, + {type: 'stopped', timestamp, reason: 'cancelled'}, + ]) { + expect(validate(record), JSON.stringify(validate.errors)).toBe(true) + expect(graphiqlJsonOutputSchema.validate(record)).toEqual(record) + expect(validate({...record, accessToken: 'secret'})).toBe(false) + } + }, +) + +test.each(['', 'https://shop.myshopify.io', 'shop.my.shop.dev/admin', 'shop..myshopify.com'])( + 'rejects store domains that are not normalized hostnames (%s)', + (storeDomain) => { + const record = {type: 'starting', timestamp: '2026-10-21T10:20:30Z', storeDomain} + expect(() => graphiqlJsonOutputSchema.validate(record)).toThrow() + expect( + new Ajv({formats: {'date-time': true, uri: true}}).compile(graphiqlJsonOutputSchema.jsonSchema)(record), + ).toBe(false) + }, +) + +test.each(['2026-10-21T10:20:30.123Z', '2026-10-21T10:20:30+00:00'])( + 'rejects noncanonical timestamp %s', + (timestamp) => { + const record = {type: 'starting', timestamp, storeDomain: 'shop.myshopify.com'} + expect(() => graphiqlJsonOutputSchema.validate(record)).toThrow() + expect( + new Ajv({formats: {'date-time': true, uri: true}}).compile(graphiqlJsonOutputSchema.jsonSchema)(record), + ).toBe(false) + }, +) diff --git a/packages/cli-kit/src/public/node/graphiql/schema.ts b/packages/cli-kit/src/public/node/graphiql/schema.ts new file mode 100644 index 00000000000..679bafcd01d --- /dev/null +++ b/packages/cli-kit/src/public/node/graphiql/schema.ts @@ -0,0 +1,40 @@ +import {defineJsonOutputSchema, jsonOutputTimestampSchema, type InferJsonOutputSchema} from '../json-output-schema.js' +import {zod} from '../schema.js' + +const storeDomain = zod + .string() + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/) + .describe('Normalized store hostname, including local development domains.') +const timestamp = jsonOutputTimestampSchema + +/** Lifecycle records written as JSONL by the standalone GraphiQL commands. */ +export const graphiqlJsonOutputSchema = defineJsonOutputSchema({ + name: 'GraphiQLRecord', + definitions: {GraphiQLTimestamp: timestamp, GraphiQLStoreDomain: storeDomain}, + schema: zod.discriminatedUnion('type', [ + zod.object({type: zod.literal('starting'), timestamp, storeDomain}).strict(), + zod + .object({ + type: zod.literal('ready'), + timestamp, + url: zod.string().url().describe('Local browser URL, including the session authentication key.'), + storeDomain, + api: zod.literal('admin'), + apiVersion: zod.string().nullable().describe('Requested API version; null uses the latest stable version.'), + mutationsAllowed: zod.boolean(), + app: zod.object({name: zod.string(), url: zod.string().url()}).strict().nullable(), + }) + .strict(), + zod + .object({ + type: zod.literal('stopped'), + timestamp, + reason: zod.enum(['cancelled', 'sigint', 'sigterm', 'sigquit']), + }) + .strict(), + ]), +}) + +type WithoutTimestamp = T extends unknown ? Omit : never +type GraphiQLRecord = InferJsonOutputSchema +export type GraphiQLRecordInput = WithoutTimestamp diff --git a/packages/cli-kit/src/public/node/graphiql/server.ts b/packages/cli-kit/src/public/node/graphiql/server.ts index 9ca1548c886..f87f76d5859 100644 --- a/packages/cli-kit/src/public/node/graphiql/server.ts +++ b/packages/cli-kit/src/public/node/graphiql/server.ts @@ -5,7 +5,8 @@ import {performActionWithRetryAfterRecovery} from '../../common/retry.js' import {CLI_KIT_VERSION} from '../../common/version.js' import {AbortError} from '../error.js' import {adminUrl, supportedApiVersions} from '../api/admin.js' -import {fetch} from '../http.js' +import {fetch, requestMode} from '../http.js' +import {maxRequestTimeForNetworkCallsMs} from '../environment.js' import {renderLiquidTemplate} from '../liquid.js' import {outputDebug} from '../output.js' import {containsMutation} from '../graphql.js' @@ -17,6 +18,7 @@ import { getRequestHeader, getRequestHeaders, readBody, + sendError, setResponseHeader, setResponseStatus, toNodeListener, @@ -126,6 +128,10 @@ export interface SetupGraphiQLServerOptions { * interactive UI. */ protectMutations?: boolean + /** Called for request failures that leave the server available for subsequent requests. */ + onRequestError?: (message: string) => void + /** Routes diagnostics through command events and leaves readiness output to the session. */ + json?: boolean } export const MUTATIONS_BLOCKED_MESSAGE = 'Mutations are disabled. Re-run with --allow-mutations to enable mutations.' @@ -143,14 +149,26 @@ export const MUTATIONS_BLOCKED_MESSAGE = 'Mutations are disabled. Re-run with -- export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server { const {stdout, port, storeFqdn, tokenProvider, key: providedKey, appContext, protectMutations = false} = options const key = resolveGraphiQLServerKey(providedKey, appContext, storeFqdn) - outputDebug(`Setting up GraphiQL HTTP server on port ${port}...`, stdout) + const debug = (message: string) => (options.json ? outputDebug(message) : outputDebug(message, stdout)) + const requests = new AbortController() + const reportRequestError = (message: string) => { + if (!requests.signal.aborted) options.onRequestError?.(message) + } + debug(`Setting up GraphiQL HTTP server on port ${port}...`) - const app = createApp() + const app = createApp({ + onError: options.json + ? (error, event) => { + reportRequestError('GraphiQL could not complete the HTTP request.') + return sendError(event, error) + } + : undefined, + }) const router = createRouter() const refreshUpstreamToken = async (): Promise => { try { - outputDebug('refreshing token', stdout) + debug('refreshing token') return await (tokenProvider.refreshToken ?? tokenProvider.getToken)() } catch (_error) { throw new TokenRefreshError() @@ -167,7 +185,15 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server async function fetchApiVersionsWithTokenRefresh(): Promise { return performActionWithRetryAfterRecovery( - async () => supportedApiVersions({storeFqdn, token: await currentToken()}), + async () => + supportedApiVersions( + {storeFqdn, token: await currentToken()}, + { + ...requestMode(), + useAbortSignal: () => + AbortSignal.any([requests.signal, AbortSignal.timeout(maxRequestTimeForNetworkCallsMs())]), + }, + ), refreshUpstreamToken, ) } @@ -218,6 +244,7 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server return {status: 'OK', storeFqdn, appName: appContext?.appName, appUrl: appContext?.appUrl} // eslint-disable-next-line no-catch-all/no-catch-all } catch { + reportRequestError('GraphiQL could not authenticate with the Admin API.') return {status: 'UNAUTHENTICATED'} } }), @@ -226,7 +253,7 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server router.get( '/graphiql', defineEventHandler(async (event) => { - outputDebug('Handling /graphiql request', stdout) + debug('Handling /graphiql request') const query = getQuery(event) @@ -245,6 +272,7 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server apiVersions = await fetchApiVersionsWithTokenRefresh() } catch (err) { if (err instanceof TokenRefreshError) { + reportRequestError('GraphiQL could not load the Admin API versions.') return renderLiquidTemplate(unauthorizedTemplate({hasAppContext: Boolean(appContext)}), { previewUrl: appContext?.appUrl ?? '', storeFqdn, @@ -286,7 +314,7 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server router.post( '/graphiql/graphql.json', defineEventHandler(async (event) => { - outputDebug('Handling /graphiql/graphql.json request', stdout) + debug('Handling /graphiql/graphql.json request') const query = getQuery(event) @@ -300,6 +328,7 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server const body = await readBody(event) if (protectMutations && isMutationRequestBody(body)) { + reportRequestError(MUTATIONS_BLOCKED_MESSAGE) setResponseStatus(event, 400) return {errors: [{message: MUTATIONS_BLOCKED_MESSAGE}]} } @@ -322,20 +351,23 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server method: 'POST', headers, body: reqBody, + signal: AbortSignal.any([requests.signal, AbortSignal.timeout(maxRequestTimeForNetworkCallsMs())]), }) } let result = await runRequest(await currentToken()) if (result.status === 401) { - outputDebug('Token expired, fetching new token', stdout) + debug('Token expired, fetching new token') result = await runRequest(await refreshUpstreamToken()) } setResponseHeader(event, 'Content-Type', 'application/json') setResponseStatus(event, result.status) + if (!result.ok) reportRequestError(`GraphiQL Admin API request failed with HTTP ${result.status}.`) return result.json() // eslint-disable-next-line no-catch-all/no-catch-all } catch (error: unknown) { + reportRequestError('GraphiQL could not complete the Admin API request.') setResponseStatus(event, 500) if (error instanceof Error) { return {errors: [error.message]} @@ -348,7 +380,10 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server app.use(router) const server = createServer(toNodeListener(app)) - server.listen(port, 'localhost', () => stdout.write(`GraphiQL server started on port ${port}`)) + server.once('close', () => requests.abort()) + server.listen(port, 'localhost', () => { + if (!options.json) stdout.write(`GraphiQL server started on port ${port}`) + }) return server } diff --git a/packages/cli-kit/src/public/node/graphiql/session.test.ts b/packages/cli-kit/src/public/node/graphiql/session.test.ts index 36c2444ae5d..83e66b21d07 100644 --- a/packages/cli-kit/src/public/node/graphiql/session.test.ts +++ b/packages/cli-kit/src/public/node/graphiql/session.test.ts @@ -1,27 +1,55 @@ import {buildGraphiQLUrl, generateRandomGraphiQLKey, runGraphiQLSession, waitForGraphiQLAbort} from './session.js' -import {setupGraphiQLServer} from './server.js' +import {graphiqlJsonOutputSchema} from './schema.js' +import {MUTATIONS_BLOCKED_MESSAGE, setupGraphiQLServer} from './server.js' +import {runWithCommandEventsForCommand} from '../command-events.js' +import {withCapturedStandardStreams} from '../testing/output.js' import {AbortController} from '../abort.js' import {openURL} from '../system.js' import {getAvailableTCPPort} from '../tcp.js' -import {beforeEach, describe, expect, test, vi} from 'vitest' +import {fetch as shopifyFetch} from '../http.js' +import {supportedApiVersions} from '../api/admin.js' +import {describe, expect, test, vi} from 'vitest' +import {createServer} from 'node:http' +vi.mock('../system.js', async (importOriginal) => { + const original = await importOriginal() + return {...original, openURL: vi.fn(async () => true)} +}) vi.mock('./server.js', async (importOriginal) => { const original = await importOriginal() - return { - ...original, - setupGraphiQLServer: vi.fn(), - } + return {...original, setupGraphiQLServer: vi.fn(original.setupGraphiQLServer)} +}) +vi.mock('../tcp.js', async (importOriginal) => { + const original = await importOriginal() + return {...original, getAvailableTCPPort: vi.fn(original.getAvailableTCPPort)} +}) +vi.mock('../http.js', async (importOriginal) => { + const original = await importOriginal() + return {...original, fetch: vi.fn(original.fetch)} +}) +vi.mock('../api/admin.js', async (importOriginal) => { + const original = await importOriginal() + return {...original, supportedApiVersions: vi.fn(original.supportedApiVersions)} }) -vi.mock('../tcp.js') -vi.mock('../system.js') -const mockedSetupGraphiQLServer = vi.mocked(setupGraphiQLServer) -const mockedGetAvailableTCPPort = vi.mocked(getAvailableTCPPort) -const mockedOpenURL = vi.mocked(openURL) +const options = { + storeFqdn: 'shop.myshopify.com', + tokenProvider: {getToken: async () => 'token'}, + key: 'key', + outputFormat: 'json' as const, +} -function abortAfter(controller: AbortController) { - setImmediate(() => controller.abort()) - return controller.signal +function records(stdout: string) { + return stdout + .trim() + .split('\n') + .filter(Boolean) + .map((line) => graphiqlJsonOutputSchema.validate(JSON.parse(line))) +} + +function listeners() { + const processEvents: NodeJS.EventEmitter = process + return ['SIGINT', 'SIGTERM', 'SIGQUIT', 'shopify:signal'].map((name) => processEvents.listeners(name)) } describe('buildGraphiQLUrl', () => { @@ -51,70 +79,258 @@ describe('generateRandomGraphiQLKey', () => { }) describe('runGraphiQLSession', () => { - beforeEach(() => { - mockedGetAvailableTCPPort.mockResolvedValue(4567) - mockedOpenURL.mockResolvedValue(true) - mockedSetupGraphiQLServer.mockReturnValue({close: vi.fn()} as unknown as ReturnType) + test.each(['shop.myshopify.com', 'shop.myshopify.io', 'shop.my.shop.dev'])( + 'writes lifecycle records for %s only after listening and closes its real server', + async (storeFqdn) => { + const controller = new AbortController() + const before = listeners() + let url = '' + await withCapturedStandardStreams(async ({stdout, stderr}) => { + vi.mocked(openURL).mockImplementationOnce(async (readyUrl) => { + url = readyUrl + const response = await fetch(new URL('/graphiql/ping', readyUrl)) + await expect(response.text()).resolves.toBe('pong') + expect(records(stdout()).map((record) => record.type)).toEqual(['starting', 'ready']) + controller.abort() + return true + }) + await runWithCommandEventsForCommand(['--json'], () => + runGraphiQLSession({ + ...options, + storeFqdn, + abortSignal: controller.signal, + protectMutations: true, + apiVersion: '2024-10', + appContext: {appName: 'Test App', appUrl: 'https://example.com/app', apiSecret: 'secret'}, + }), + ) + expect(records(stdout())).toEqual([ + {type: 'starting', storeDomain: storeFqdn, timestamp: expect.any(String)}, + { + type: 'ready', + timestamp: expect.any(String), + url, + storeDomain: storeFqdn, + api: 'admin', + apiVersion: '2024-10', + mutationsAllowed: false, + app: {name: 'Test App', url: 'https://example.com/app'}, + }, + {type: 'stopped', timestamp: expect.any(String), reason: 'cancelled'}, + ]) + expect(stdout()).not.toContain('secret') + expect(stderr()).toBe('') + }) + await expect(fetch(new URL('/graphiql/ping', url))).rejects.toThrow() + expect(listeners()).toEqual(before) + }, + ) + + test('keeps repeated recoverable request failures on stderr and native responses over HTTP', async () => { + const controller = new AbortController() + await withCapturedStandardStreams(async ({stdout, stderr}) => { + vi.mocked(openURL).mockImplementationOnce(async (url) => { + await Promise.all( + Array.from({length: 2}, async () => { + const response = await fetch(new URL('/graphiql/graphql.json?key=key&api_version=2024-10', url), { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({query: 'mutation { shopUpdate(input: {}) { shop { id } } }'}), + }) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({errors: [{message: MUTATIONS_BLOCKED_MESSAGE}]}) + }), + ) + await expect((await fetch(new URL('/graphiql/ping', url))).text()).resolves.toBe('pong') + controller.abort() + return true + }) + await runWithCommandEventsForCommand(['--json'], () => + runGraphiQLSession({ + ...options, + protectMutations: true, + abortSignal: controller.signal, + }), + ) + expect(records(stdout()).map((record) => record.type)).toEqual(['starting', 'ready', 'stopped']) + const diagnostics = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(diagnostics).toHaveLength(2) + expect(diagnostics).toEqual( + Array.from({length: 2}, () => ({ + type: 'diagnostic', + level: 'error', + code: 'graphiql-request-failed', + message: MUTATIONS_BLOCKED_MESSAGE, + timestamp: expect.any(String), + })), + ) + }) + }) + + test('rejects bind failures without emitting ready or stopped records and releases listeners', async () => { + const port = await getAvailableTCPPort() + const blocker = createServer() + await new Promise((resolve) => blocker.listen(port, 'localhost', resolve)) + const before = listeners() + try { + vi.mocked(getAvailableTCPPort).mockResolvedValueOnce(port) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runGraphiQLSession(options)).rejects.toThrow(/EADDRINUSE/) + expect(records(stdout()).map((record) => record.type)).toEqual(['starting']) + }) + expect(openURL).not.toHaveBeenCalled() + expect(listeners()).toEqual(before) + } finally { + await new Promise((resolve) => blocker.close(() => resolve())) + } }) - test('starts the server, opens the keyed URL, waits for abort, and closes the server', async () => { + test('encodes HTTP handler failures as diagnostics without H3 terminal logs', async () => { const controller = new AbortController() - const tokenProvider = {getToken: async () => 'token'} - const server = {close: vi.fn()} - mockedSetupGraphiQLServer.mockReturnValueOnce(server as unknown as ReturnType) + vi.mocked(supportedApiVersions).mockRejectedValueOnce(new Error('upstream failed')) + vi.mocked(openURL).mockImplementationOnce(async (url) => { + const response = await fetch(url) + expect(response.status).toBe(500) + await expect(response.json()).resolves.toMatchObject({statusCode: 500, stack: []}) + controller.abort() + return true + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => + runGraphiQLSession({...options, abortSignal: controller.signal}), + ) + expect(records(stdout()).map((record) => record.type)).toEqual(['starting', 'ready', 'stopped']) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', code: 'graphiql-request-failed'}) + }) + }) - await runGraphiQLSession({ - storeFqdn: 'shop.myshopify.com', - tokenProvider, - key: 'key', - port: 1234, - query: 'query { shop { name } }', - variables: '{"id":1}', - apiVersion: '2024-10', - protectMutations: true, - appContext: { - appName: 'Test App', - appUrl: 'https://admin.shopify.com/store/shop/apps/api-key?dev-console=show', - apiSecret: 'secret', - }, - abortSignal: abortAfter(controller), + test('aborts an outstanding upstream request and closes active browser connections on cancellation', async () => { + const controller = new AbortController() + let upstreamAborted = false + let requested: () => void + const upstreamStarted = new Promise((resolve) => { + requested = resolve + }) + vi.mocked(shopifyFetch).mockImplementationOnce(async (_url, request) => { + requested() + return new Promise((_resolve, reject) => { + request?.signal?.addEventListener('abort', () => { + upstreamAborted = true + reject(new Error('aborted')) + }) + }) + }) + let browserRequest: Promise | undefined + vi.mocked(openURL).mockImplementationOnce(async (url) => { + browserRequest = fetch(new URL('/graphiql/graphql.json?key=key&api_version=2024-10', url), { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({query: '{ shop { name } }'}), + }).catch((error: unknown) => error) + await upstreamStarted + controller.abort() + return true }) + await withCapturedStandardStreams(async ({stdout}) => { + await runGraphiQLSession({...options, abortSignal: controller.signal}) + expect(records(stdout()).at(-1)).toMatchObject({type: 'stopped'}) + expect(upstreamAborted).toBe(true) + await expect(browserRequest).resolves.toBeInstanceOf(Error) + }) + }) - expect(mockedGetAvailableTCPPort).toHaveBeenCalledWith(1234) - expect(mockedSetupGraphiQLServer).toHaveBeenCalledWith( - expect.objectContaining({ - stdout: process.stdout, - port: 4567, - storeFqdn: 'shop.myshopify.com', - tokenProvider, - key: 'key', - protectMutations: true, - appContext: { - appName: 'Test App', - appUrl: 'https://admin.shopify.com/store/shop/apps/api-key?dev-console=show', - apiSecret: 'secret', - }, - }), - ) - expect(mockedOpenURL).toHaveBeenCalledWith( - 'http://localhost:4567/graphiql?key=key&query=query+%7B+shop+%7B+name+%7D+%7D&variables=%7B%22id%22%3A1%7D&api_version=2024-10', - ) - expect(server.close).toHaveBeenCalled() + test.each(['browser', 'server', 'close'])('closes the server after a fatal %s failure', async (failure) => { + const before = listeners() + const original = await vi.importActual('./server.js') + let runningServer: ReturnType | undefined + let url = '' + vi.mocked(setupGraphiQLServer).mockImplementationOnce((serverOptions) => { + runningServer = original.setupGraphiQLServer(serverOptions) + return runningServer + }) + vi.mocked(openURL).mockImplementationOnce(async (readyUrl) => { + url = readyUrl + if (failure === 'browser') throw new Error('browser failed') + if (failure === 'server') runningServer!.emit('error', new Error('server failed')) + if (failure === 'close') runningServer!.close() + return true + }) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runGraphiQLSession(options)).rejects.toThrow(failure === 'close' ? 'closed unexpectedly' : 'failed') + expect(records(stdout()).map((record) => record.type)).toEqual(['starting', 'ready']) + }) + await expect(fetch(new URL('/graphiql/ping', url))).rejects.toThrow() + expect(listeners()).toEqual(before) + }) + + test.each([ + ['SIGINT', 'sigint', 130], + ['SIGTERM', 'sigterm', 143], + ['SIGQUIT', 'sigquit', 131], + ] as const)('handles delegated %s and emits stopped after cleanup', async (signal, reason, exitCode) => { + const originalExitCode = process.exitCode + const before = listeners() + let url = '' + vi.mocked(openURL).mockImplementationOnce(async (readyUrl) => { + url = readyUrl + const processEvents: NodeJS.EventEmitter = process + processEvents.emit('shopify:signal', signal) + processEvents.emit('shopify:signal', signal) + return true + }) + try { + await withCapturedStandardStreams(async ({stdout}) => { + await runGraphiQLSession(options) + expect(records(stdout()).at(-1)).toMatchObject({type: 'stopped', reason}) + }) + expect(process.exitCode).toBe(exitCode) + await expect(fetch(new URL('/graphiql/ping', url))).rejects.toThrow() + expect(listeners()).toEqual(before) + } finally { + // eslint-disable-next-line require-atomic-updates -- This test owns and restores the process exit code. + process.exitCode = originalExitCode + } }) - test('closes the server when opening the browser fails', async () => { - const server = {close: vi.fn()} - mockedSetupGraphiQLServer.mockReturnValueOnce(server as unknown as ReturnType) - mockedOpenURL.mockRejectedValueOnce(new Error('failed to open')) - - await expect( - runGraphiQLSession({ - storeFqdn: 'shop.myshopify.com', - tokenProvider: {getToken: async () => 'token'}, - key: 'key', - }), - ).rejects.toThrow('failed to open') - expect(server.close).toHaveBeenCalled() + test.each(['text', 'json'] as const)( + 'supports --no-input with %s output without opening a browser', + async (outputFormat) => { + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '1') + const controller = new AbortController() + const original = await vi.importActual('./server.js') + vi.mocked(setupGraphiQLServer).mockImplementationOnce((serverOptions) => { + const server = original.setupGraphiQLServer(serverOptions) + server.once('listening', () => setImmediate(() => controller.abort())) + return server + }) + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runGraphiQLSession({...options, outputFormat, abortSignal: controller.signal}) + if (outputFormat === 'json') { + expect(records(stdout()).map((record) => record.type)).toEqual(['starting', 'ready', 'stopped']) + } else { + expect(stdout()).toContain('GraphiQL server started on port') + expect(stderr()).toContain('Press Ctrl+C to stop.') + } + }) + expect(openURL).not.toHaveBeenCalled() + } finally { + vi.unstubAllEnvs() + } + }, + ) + + test('does not acquire a port for an already cancelled session', async () => { + const controller = new AbortController() + controller.abort() + await withCapturedStandardStreams(async ({stdout}) => { + await runGraphiQLSession({...options, abortSignal: controller.signal}) + expect(records(stdout()).map((record) => record.type)).toEqual(['stopped']) + }) + expect(getAvailableTCPPort).not.toHaveBeenCalled() }) }) diff --git a/packages/cli-kit/src/public/node/graphiql/session.ts b/packages/cli-kit/src/public/node/graphiql/session.ts index 7c700b85c17..9220017a02b 100644 --- a/packages/cli-kit/src/public/node/graphiql/session.ts +++ b/packages/cli-kit/src/public/node/graphiql/session.ts @@ -1,6 +1,18 @@ import {GraphiQLAppContext, setupGraphiQLServer, TokenProvider} from './server.js' +import {graphiqlJsonOutputSchema, type GraphiQLRecordInput} from './schema.js' import {AbortController, AbortSignal} from '../abort.js' -import {OutputMessage, outputContent, outputInfo, outputToken, outputWarn} from '../output.js' +import { + OutputMessage, + flushStdout, + outputContent, + outputInfo, + outputResult, + outputToken, + outputWarn, +} from '../output.js' +import {emitCommandEvent} from '../command-events.js' +import {isInputDisabled} from '../no-input.js' +import {formatJsonOutputTimestamp} from '../json-output-schema.js' import {openURL} from '../system.js' import {getAvailableTCPPort} from '../tcp.js' import {randomBytes} from 'crypto' @@ -27,6 +39,7 @@ export interface RunGraphiQLSessionOptions { additionalReadyMessages?: OutputMessage[] abortSignal?: AbortSignal stdout?: Writable + outputFormat?: 'text' | 'json' } /** @@ -59,38 +72,163 @@ export function buildGraphiQLUrl(options: BuildGraphiQLUrlOptions): string { * @param options - The server, authentication, URL prefill, and output options for the session. */ export async function runGraphiQLSession(options: RunGraphiQLSessionOptions): Promise { - const port = await getAvailableTCPPort(options.port) - const server = setupGraphiQLServer({ - stdout: options.stdout ?? process.stdout, - port, - storeFqdn: options.storeFqdn, - tokenProvider: options.tokenProvider, - key: options.key, - appContext: options.appContext, - protectMutations: options.protectMutations, - }) - - const url = buildGraphiQLUrl({ - port, - key: options.key, - query: options.query, - variables: options.variables, - apiVersion: options.apiVersion, - }) + const json = options.outputFormat === 'json' + const cancellation = createGraphiQLCancellation(options.abortSignal) + let server: ReturnType | undefined + let failed = false + let closing = false + let serverError: Error | undefined + const writeRecord = (record: GraphiQLRecordInput) => { + if (!json) return + const timestamp = formatJsonOutputTimestamp(new Date()) + outputResult(JSON.stringify(graphiqlJsonOutputSchema.validate({...record, timestamp}))) + } + const onServerError = (error: Error) => { + serverError = error + cancellation.controller.abort() + } + const onServerClose = () => { + if (!closing) onServerError(new Error('The GraphiQL server closed unexpectedly.')) + } + const throwIfServerFailed = () => { + if (serverError) throw serverError + } try { - outputInfo(outputContent`GraphiQL is running at ${outputToken.link(url)}`) - options.additionalReadyMessages?.forEach((message) => outputInfo(message)) - outputInfo('Press Ctrl+C to stop.') + if (cancellation.controller.signal.aborted) return + writeRecord({type: 'starting', storeDomain: options.storeFqdn}) + const port = await getAvailableTCPPort(options.port) + if (cancellation.controller.signal.aborted) return - const opened = await openURL(url) - if (!opened) { - outputWarn('Browser did not open automatically. Open the URL above manually.') - } + server = setupGraphiQLServer({ + stdout: options.stdout ?? process.stdout, + port, + storeFqdn: options.storeFqdn, + tokenProvider: options.tokenProvider, + key: options.key, + appContext: options.appContext, + protectMutations: options.protectMutations, + json, + onRequestError: json + ? (message) => emitCommandEvent({type: 'diagnostic', level: 'error', code: 'graphiql-request-failed', message}) + : undefined, + }) + server.on('error', onServerError) + server.on('close', onServerClose) + await waitForGraphiQLListening(server) + throwIfServerFailed() + if (cancellation.controller.signal.aborted) return - await waitForGraphiQLAbort(options.abortSignal) + const url = buildGraphiQLUrl({ + port, + key: options.key, + query: options.query, + variables: options.variables, + apiVersion: options.apiVersion, + }) + writeRecord({ + type: 'ready', + url, + storeDomain: options.storeFqdn, + api: 'admin', + apiVersion: options.apiVersion ?? null, + mutationsAllowed: !options.protectMutations, + app: options.appContext ? {name: options.appContext.appName, url: options.appContext.appUrl} : null, + }) + if (!json) { + outputInfo(outputContent`GraphiQL is running at ${outputToken.link(url)}`) + options.additionalReadyMessages?.forEach((message) => outputInfo(message)) + outputInfo('Press Ctrl+C to stop.') + } + if (!isInputDisabled()) { + const opened = await Promise.race([openURL(url), cancellation.stopped.then(() => true)]) + if (!opened) outputWarn('Browser did not open automatically. Open the URL above manually.') + } + await cancellation.stopped + throwIfServerFailed() + } catch (error) { + failed = true + throw error } finally { - server.close() + try { + if (server) { + closing = true + const runningServer = server + await new Promise((resolve) => { + runningServer.close(() => resolve()) + runningServer.closeAllConnections() + }) + server.removeListener('error', onServerError) + server.removeListener('close', onServerClose) + } + if (!failed) writeRecord({type: 'stopped', reason: cancellation.reason()}) + if (json) await flushStdout() + } finally { + cancellation.dispose() + } + } +} + +function waitForGraphiQLListening(server: ReturnType): Promise { + return new Promise((resolve) => { + const settled = () => { + server.removeListener('listening', settled) + server.removeListener('error', settled) + resolve() + } + server.once('listening', settled) + server.once('error', settled) + }) +} + +function createGraphiQLCancellation(externalSignal?: AbortSignal) { + const controller = new AbortController() + let reason: 'cancelled' | 'sigint' | 'sigterm' | 'sigquit' = 'cancelled' + const processEvents: NodeJS.EventEmitter = process + const onSignal = (signal: string) => { + if (controller.signal.aborted) return + switch (signal) { + case 'SIGINT': + reason = 'sigint' + process.exitCode = 130 + break + case 'SIGTERM': + reason = 'sigterm' + process.exitCode = 143 + break + case 'SIGQUIT': + reason = 'sigquit' + process.exitCode = 131 + break + default: + return + } + controller.abort() + } + const onSigint = () => onSignal('SIGINT') + const onSigterm = () => onSignal('SIGTERM') + const onSigquit = () => onSignal('SIGQUIT') + const onAbort = () => controller.abort() + process.on('SIGINT', onSigint) + process.on('SIGTERM', onSigterm) + process.on('SIGQUIT', onSigquit) + processEvents.on('shopify:signal', onSignal) + externalSignal?.addEventListener('abort', onAbort, {once: true}) + const stopped = new Promise((resolve) => + controller.signal.addEventListener('abort', () => resolve(), {once: true}), + ) + if (externalSignal?.aborted) controller.abort() + return { + controller, + stopped, + reason: () => reason, + dispose: () => { + process.removeListener('SIGINT', onSigint) + process.removeListener('SIGTERM', onSigterm) + process.removeListener('SIGQUIT', onSigquit) + processEvents.removeListener('shopify:signal', onSignal) + externalSignal?.removeEventListener('abort', onAbort) + }, } } @@ -103,6 +241,7 @@ export async function waitForGraphiQLAbort(externalSignal?: AbortSignal): Promis const controller = new AbortController() const onSigint = () => controller.abort() + const onAbort = () => controller.abort() process.once('SIGINT', onSigint) try { @@ -112,9 +251,10 @@ export async function waitForGraphiQLAbort(externalSignal?: AbortSignal): Promis return } controller.signal.addEventListener('abort', () => resolve(), {once: true}) - externalSignal?.addEventListener('abort', () => controller.abort(), {once: true}) + externalSignal?.addEventListener('abort', onAbort, {once: true}) }) } finally { process.removeListener('SIGINT', onSigint) + externalSignal?.removeEventListener('abort', onAbort) } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 1bdcda90476..c3cab4cdb0c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3462,15 +3462,19 @@ Open a local GraphiQL UI for your app and store. ``` USAGE - $ shopify app graphiql [--auth-alias ] [--client-id | -c ] [--json-schema] [--no-color] - [--no-input] [--path ] [--port ] [--reset | ] [-s ] [-v ] [--verbose] [--version - ] + $ shopify app graphiql [--auth-alias ] [--client-id | -c ] [-j] [--json-schema] + [--no-color] [--no-input] [--path ] [--port ] [--reset | ] [-s ] [-v ] [--verbose] + [--version ] FLAGS -c, --config= The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -s, --store= The myshopify.com domain of the store to open GraphiQL against. The app must be installed on the store. If not specified, you will be prompted to select a store. @@ -3527,6 +3531,149 @@ DESCRIPTION The app must be installed on the store. + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `GraphiQLRecord` schema. + + ```json + { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "starting" + }, + "timestamp": { + "$ref": "#/definitions/GraphiQLTimestamp" + }, + "storeDomain": { + "$ref": "#/definitions/GraphiQLStoreDomain" + } + }, + "required": [ + "type", + "timestamp", + "storeDomain" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ready" + }, + "timestamp": { + "$ref": "#/definitions/GraphiQLTimestamp" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Local browser URL, including the session authentication key." + }, + "storeDomain": { + "$ref": "#/definitions/GraphiQLStoreDomain" + }, + "api": { + "type": "string", + "const": "admin" + }, + "apiVersion": { + "type": [ + "string", + "null" + ], + "description": "Requested API version; null uses the latest stable version." + }, + "mutationsAllowed": { + "type": "boolean" + }, + "app": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "url" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "timestamp", + "url", + "storeDomain", + "api", + "apiVersion", + "mutationsAllowed", + "app" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "stopped" + }, + "timestamp": { + "$ref": "#/definitions/GraphiQLTimestamp" + }, + "reason": { + "type": "string", + "enum": [ + "cancelled", + "sigint", + "sigterm", + "sigquit" + ] + } + }, + "required": [ + "type", + "timestamp", + "reason" + ], + "additionalProperties": false + } + ], + "title": "GraphiQLRecord", + "definitions": { + "GraphiQLTimestamp": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "GraphiQLStoreDomain": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$", + "description": "Normalized store hostname, including local development domains." + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES $ shopify app graphiql --store shop.myshopify.com @@ -12030,10 +12177,14 @@ Open a local GraphiQL UI for a store. ``` USAGE - $ shopify store graphiql -s [--allow-mutations] [--json-schema] [--no-color] [--no-input] [--port ] - [-v ] [--verbose] [--version ] + $ shopify store graphiql -s [--allow-mutations] [-j] [--json-schema] [--no-color] [--no-input] [--port + ] [-v ] [--verbose] [--version ] FLAGS + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -s, --store= (required) The myshopify.com domain of the store. [env: SHOPIFY_FLAG_STORE] @@ -12079,6 +12230,149 @@ DESCRIPTION Mutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data. + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `GraphiQLRecord` schema. + + ```json + { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "starting" + }, + "timestamp": { + "$ref": "#/definitions/GraphiQLTimestamp" + }, + "storeDomain": { + "$ref": "#/definitions/GraphiQLStoreDomain" + } + }, + "required": [ + "type", + "timestamp", + "storeDomain" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ready" + }, + "timestamp": { + "$ref": "#/definitions/GraphiQLTimestamp" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Local browser URL, including the session authentication key." + }, + "storeDomain": { + "$ref": "#/definitions/GraphiQLStoreDomain" + }, + "api": { + "type": "string", + "const": "admin" + }, + "apiVersion": { + "type": [ + "string", + "null" + ], + "description": "Requested API version; null uses the latest stable version." + }, + "mutationsAllowed": { + "type": "boolean" + }, + "app": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "url" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "timestamp", + "url", + "storeDomain", + "api", + "apiVersion", + "mutationsAllowed", + "app" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "stopped" + }, + "timestamp": { + "$ref": "#/definitions/GraphiQLTimestamp" + }, + "reason": { + "type": "string", + "enum": [ + "cancelled", + "sigint", + "sigterm", + "sigquit" + ] + } + }, + "required": [ + "type", + "timestamp", + "reason" + ], + "additionalProperties": false + } + ], + "title": "GraphiQLRecord", + "definitions": { + "GraphiQLTimestamp": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "GraphiQLStoreDomain": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$", + "description": "Normalized store hostname, including local development domains." + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES $ shopify store graphiql --store shop.myshopify.com diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 6acae62800c..4573bde5519 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2810,7 +2810,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Opens an authenticated Admin API GraphiQL UI for your app and selected store.\n\nThe app must be installed on the store.", + "description": "Opens an authenticated Admin API GraphiQL UI for your app and selected store.\n\nThe app must be installed on the store.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `GraphiQLRecord` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"starting\"\n },\n \"timestamp\": {\n \"$ref\": \"#/definitions/GraphiQLTimestamp\"\n },\n \"storeDomain\": {\n \"$ref\": \"#/definitions/GraphiQLStoreDomain\"\n }\n },\n \"required\": [\n \"type\",\n \"timestamp\",\n \"storeDomain\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"ready\"\n },\n \"timestamp\": {\n \"$ref\": \"#/definitions/GraphiQLTimestamp\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\",\n \"description\": \"Local browser URL, including the session authentication key.\"\n },\n \"storeDomain\": {\n \"$ref\": \"#/definitions/GraphiQLStoreDomain\"\n },\n \"api\": {\n \"type\": \"string\",\n \"const\": \"admin\"\n },\n \"apiVersion\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Requested API version; null uses the latest stable version.\"\n },\n \"mutationsAllowed\": {\n \"type\": \"boolean\"\n },\n \"app\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\"\n }\n },\n \"required\": [\n \"name\",\n \"url\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"type\",\n \"timestamp\",\n \"url\",\n \"storeDomain\",\n \"api\",\n \"apiVersion\",\n \"mutationsAllowed\",\n \"app\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"stopped\"\n },\n \"timestamp\": {\n \"$ref\": \"#/definitions/GraphiQLTimestamp\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"enum\": [\n \"cancelled\",\n \"sigint\",\n \"sigterm\",\n \"sigquit\"\n ]\n }\n },\n \"required\": [\n \"type\",\n \"timestamp\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"GraphiQLRecord\",\n \"definitions\": {\n \"GraphiQLTimestamp\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\",\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"GraphiQLStoreDomain\": {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$\",\n \"description\": \"Normalized store hostname, including local development domains.\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Opens an authenticated Admin API GraphiQL UI for your app and selected store.\n\nThe app must be installed on the store.", "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", @@ -2847,6 +2847,15 @@ "name": "config", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", @@ -10246,7 +10255,7 @@ "args": { }, "customPluginName": "@shopify/store", - "description": "Opens an authenticated Admin API GraphiQL UI for 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": "Opens an authenticated Admin API GraphiQL UI for 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.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `GraphiQLRecord` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"starting\"\n },\n \"timestamp\": {\n \"$ref\": \"#/definitions/GraphiQLTimestamp\"\n },\n \"storeDomain\": {\n \"$ref\": \"#/definitions/GraphiQLStoreDomain\"\n }\n },\n \"required\": [\n \"type\",\n \"timestamp\",\n \"storeDomain\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"ready\"\n },\n \"timestamp\": {\n \"$ref\": \"#/definitions/GraphiQLTimestamp\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\",\n \"description\": \"Local browser URL, including the session authentication key.\"\n },\n \"storeDomain\": {\n \"$ref\": \"#/definitions/GraphiQLStoreDomain\"\n },\n \"api\": {\n \"type\": \"string\",\n \"const\": \"admin\"\n },\n \"apiVersion\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Requested API version; null uses the latest stable version.\"\n },\n \"mutationsAllowed\": {\n \"type\": \"boolean\"\n },\n \"app\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\"\n }\n },\n \"required\": [\n \"name\",\n \"url\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"type\",\n \"timestamp\",\n \"url\",\n \"storeDomain\",\n \"api\",\n \"apiVersion\",\n \"mutationsAllowed\",\n \"app\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"stopped\"\n },\n \"timestamp\": {\n \"$ref\": \"#/definitions/GraphiQLTimestamp\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"enum\": [\n \"cancelled\",\n \"sigint\",\n \"sigterm\",\n \"sigquit\"\n ]\n }\n },\n \"required\": [\n \"type\",\n \"timestamp\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"GraphiQLRecord\",\n \"definitions\": {\n \"GraphiQLTimestamp\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\",\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"GraphiQLStoreDomain\": {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$\",\n \"description\": \"Normalized store hostname, including local development domains.\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Opens an authenticated Admin API GraphiQL UI for 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.", "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", @@ -10261,6 +10270,15 @@ "name": "allow-mutations", "type": "boolean" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 0eddb65e982..fb066d519d4 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -28,10 +28,15 @@ process.on('uncaughtException', async (err) => { await renderUncaughtError(err) process.exit(1) }) +const processEvents: NodeJS.EventEmitter = process +let shutdownRequested = false const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'] signals.forEach((signal) => { process.on(signal, () => { - process.exit(1) + // A repeated signal must not interrupt a command that is flushing its output. + if (shutdownRequested) return + shutdownRequested = processEvents.emit('shopify:signal', signal) + if (!shutdownRequested) process.exit(1) }) }) diff --git a/packages/cli/src/graphiql-shutdown.integration.test.ts b/packages/cli/src/graphiql-shutdown.integration.test.ts new file mode 100644 index 00000000000..dd7208a43f4 --- /dev/null +++ b/packages/cli/src/graphiql-shutdown.integration.test.ts @@ -0,0 +1,65 @@ +import {captureOutputWithExitCode} from '@shopify/cli-kit/node/system' +import {describe, expect, test} from 'vitest' + +const bootstrapUrl = new URL('./bootstrap.ts', import.meta.url).href +const sessionUrl = new URL('../../cli-kit/src/public/node/graphiql/session.ts', import.meta.url).href +const sourceLoaderUrl = new URL('../../cli-kit/test/fixtures/cli-kit-source-loader.js', import.meta.url).href + +describe('GraphiQL shutdown through the CLI bootstrap', () => { + // A fresh TypeScript subprocess needs extra startup time on loaded Windows runners. + test.each([ + ['SIGINT', 'sigint', 130], + ['SIGTERM', 'sigterm', 143], + ])('flushes lifecycle records and closes the server after %s', {timeout: 60000}, async (signal, reason, exitCode) => { + const script = ` + await import(${JSON.stringify(bootstrapUrl)}) + const signalHandler = process.listeners(${JSON.stringify(signal)}).at(-1) + const {runGraphiQLSession} = await import(${JSON.stringify(sessionUrl)}) + const write = process.stdout.write.bind(process.stdout) + let url + process.stdout.write = (chunk, ...args) => { + const result = write(chunk, ...args) + if (String(chunk).includes('"type":"ready"')) { + url = JSON.parse(String(chunk)).url + setImmediate(() => signalHandler()) + } + return result + } + await runGraphiQLSession({ + storeFqdn: 'shop.myshopify.com', key: 'key', + tokenProvider: {getToken: async () => 'token'}, outputFormat: 'json', + }) + process.stdout.write = write + try { + await fetch(new URL('/graphiql/ping', url)) + throw new Error('GraphiQL is still listening after shutdown') + } catch (error) { + if (error.message !== 'fetch failed') throw error + } + ` + const result = await captureOutputWithExitCode( + process.execPath, + ['--loader', 'ts-node/esm', '--loader', sourceLoaderUrl, '--input-type=module', '--eval', script], + { + env: { + ...process.env, + FORCE_COLOR: '0', + NODE_NO_WARNINGS: '1', + // Unit-test CI has no built workspace declarations; runtime imports still resolve through the source loader. + TS_NODE_IGNORE_DIAGNOSTICS: '2307', + SHOPIFY_UNIT_TEST: 'false', + SHOPIFY_FLAG_NO_INPUT: '1', + }, + }, + ) + + expect(result.exitCode, JSON.stringify(result)).toBe(exitCode) + expect(result.stderr).toBe('') + const records = result.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(records.map((record) => record.type)).toEqual(['starting', 'ready', 'stopped']) + expect(records.at(-1)).toMatchObject({reason}) + }) +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a7a087bd4ce..dc7320892c5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -59,10 +59,15 @@ process.on('uncaughtException', async (err) => { await renderUncaughtError(err) process.exit(1) }) +const processEvents: NodeJS.EventEmitter = process +let shutdownRequested = false const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'] signals.forEach((signal) => { process.on(signal, () => { - process.exit(1) + // A repeated signal must not interrupt a command that is flushing its output. + if (shutdownRequested) return + shutdownRequested = processEvents.emit('shopify:signal', signal) + if (!shutdownRequested) process.exit(1) }) }) diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index a2165527d7a..0e7b1c4b944 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -12,9 +12,7 @@ const commandExceptions = [ // Streaming commands without a single finite result. 'packages/app/src/cli/commands/app/dev.ts', - 'packages/app/src/cli/commands/app/graphiql.ts', 'packages/app/src/cli/commands/app/logs.ts', - 'packages/store/src/cli/commands/store/graphiql.ts', 'packages/theme/src/cli/commands/theme/console.ts', 'packages/theme/src/cli/commands/theme/dev.ts', 'packages/theme/src/cli/commands/theme/language-server.ts', diff --git a/packages/store/src/cli/commands/store/graphiql.test.ts b/packages/store/src/cli/commands/store/graphiql.test.ts index af1a480e21d..214d4048d60 100644 --- a/packages/store/src/cli/commands/store/graphiql.test.ts +++ b/packages/store/src/cli/commands/store/graphiql.test.ts @@ -1,5 +1,10 @@ import StoreGraphiQL from './graphiql.js' import {openStoreGraphiQL} from '../../services/store/graphiql.js' +import {launchCLI} from '@shopify/cli-kit/node/cli-launcher' +import {ShopifyConfig} from '@shopify/cli-kit/node/custom-oclif-loader' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputInfo} from '@shopify/cli-kit/node/output' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('../../services/store/graphiql.js', () => ({openStoreGraphiQL: vi.fn()})) @@ -19,6 +24,7 @@ describe('store graphiql command', () => { allowMutations: false, variables: undefined, apiVersion: undefined, + outputFormat: 'text', }) }) @@ -41,6 +47,61 @@ describe('store graphiql command', () => { allowMutations: true, variables: '{"id":"gid://shopify/Product/1"}', apiVersion: '2024-10', + outputFormat: 'text', }) }) + + test.each([ + {json: false, noInput: false}, + {json: true, noInput: false}, + {json: false, noInput: true}, + {json: true, noInput: true}, + ])('keeps output format independent of input: %j', async ({json, noInput}) => { + await StoreGraphiQL.run([ + '--store', + 'shop.myshopify.com', + ...(json ? ['--json'] : []), + ...(noInput ? ['--no-input'] : []), + ]) + expect(openStoreGraphiQL).toHaveBeenCalledWith(expect.objectContaining({outputFormat: json ? 'json' : 'text'})) + }) + + test('exposes the lifecycle schema in command help', () => { + expect(StoreGraphiQL.jsonOutputSchema.name).toBe('GraphiQLRecord') + expect(StoreGraphiQL.description).toContain('Output from `--json` conforms to the `GraphiQLRecord` schema.') + }) + + test('discovers the schema without starting or authenticating GraphiQL', async () => { + vi.spyOn(ShopifyConfig.prototype, 'runHook').mockResolvedValue({successes: [], failures: []}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await launchCLI({ + moduleURL: new URL('../../../../../cli/src/bootstrap.ts', import.meta.url).href, + argv: ['store', 'graphiql', '--json-schema'], + lazyCommandLoader: async () => StoreGraphiQL, + }) + expect(JSON.parse(stdout()).definitions.Result.anyOf).toHaveLength(3) + expect(openStoreGraphiQL).not.toHaveBeenCalled() + expect(stderr()).toBe('') + }) + }) + + test.each(['--json', '-j'])('keeps fatal errors on stdout and diagnostics on stderr with %s', async (flag) => { + vi.stubEnv('SHOPIFY_CLI_NO_ANALYTICS', '1') + const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) + vi.mocked(openStoreGraphiQL).mockImplementationOnce(async () => { + outputInfo('GraphiQL setup diagnostic') + throw new AbortError('GraphiQL setup failed.') + }) + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await StoreGraphiQL.run(['--store', 'shop.myshopify.com', flag]) + expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message: 'GraphiQL setup failed.'}}) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'GraphiQL setup diagnostic'}) + expect(exit).toHaveBeenCalledWith(1) + }) + } finally { + exit.mockRestore() + vi.unstubAllEnvs() + } + }) }) diff --git a/packages/store/src/cli/commands/store/graphiql.ts b/packages/store/src/cli/commands/store/graphiql.ts index 271baa5497a..81e44adee06 100644 --- a/packages/store/src/cli/commands/store/graphiql.ts +++ b/packages/store/src/cli/commands/store/graphiql.ts @@ -1,8 +1,9 @@ import {openStoreGraphiQL} from '../../services/store/graphiql.js' import StoreCommand from '../../utilities/store-command.js' import {storeFlags} from '../../flags.js' -import {globalFlags, portFlag} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag, portFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' +import {graphiqlJsonOutputSchema} from '@shopify/cli-kit/node/graphiql/schema' export default class StoreGraphiQL extends StoreCommand { static summary = 'Open a local GraphiQL UI for a store.' @@ -13,6 +14,10 @@ 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.` + static get jsonOutputSchema() { + return graphiqlJsonOutputSchema + } + static description = this.descriptionForHelp() static examples = [ @@ -23,6 +28,7 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte static flags = { ...globalFlags, + ...jsonFlag, store: storeFlags.store, port: portFlag({ description: 'Local port for the GraphiQL server.', @@ -46,13 +52,15 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte public async run(): Promise { const {flags} = await this.parse(StoreGraphiQL) - - await openStoreGraphiQL({ - store: flags.store, - port: flags.port, - allowMutations: flags['allow-mutations'], - variables: flags.variables, - apiVersion: flags.version, + return this.runWithParsedOutput(flags, async () => { + await openStoreGraphiQL({ + store: flags.store, + port: flags.port, + allowMutations: flags['allow-mutations'], + variables: flags.variables, + apiVersion: flags.version, + outputFormat: flags.json ? 'json' : 'text', + }) }) } } diff --git a/packages/store/src/cli/services/store/graphiql.test.ts b/packages/store/src/cli/services/store/graphiql.test.ts index 928b3fe76c9..55e8fdd86f4 100644 --- a/packages/store/src/cli/services/store/graphiql.test.ts +++ b/packages/store/src/cli/services/store/graphiql.test.ts @@ -85,6 +85,7 @@ describe('openStoreGraphiQL', () => { query: 'query { shop { name } }', variables: '{"id":1}', apiVersion: '2024-10', + outputFormat: 'json', }) expect(mockedRunGraphiQLSession).toHaveBeenCalledWith( @@ -92,6 +93,7 @@ describe('openStoreGraphiQL', () => { query: 'query { shop { name } }', variables: '{"id":1}', apiVersion: '2024-10', + outputFormat: 'json', }), ) }) diff --git a/packages/store/src/cli/services/store/graphiql.ts b/packages/store/src/cli/services/store/graphiql.ts index 06446a480b3..4a352a5195b 100644 --- a/packages/store/src/cli/services/store/graphiql.ts +++ b/packages/store/src/cli/services/store/graphiql.ts @@ -12,9 +12,9 @@ interface OpenStoreGraphiQLOptions { query?: string variables?: string apiVersion?: string + outputFormat?: 'text' | 'json' /** - * Test-only seam: aborts the server-running loop without requiring a real SIGINT. - * In production, the command itself listens for SIGINT and exits. + * Stops the session and closes its server when aborted. */ abortSignal?: AbortSignal } @@ -39,6 +39,7 @@ export async function openStoreGraphiQL(options: OpenStoreGraphiQLOptions): Prom query: options.query, variables: options.variables, apiVersion: options.apiVersion, + outputFormat: options.outputFormat, additionalReadyMessages: [ outputContent`Mutations are ${options.allowMutations ? outputToken.green('allowed') : outputToken.yellow('blocked')}.`, ],