From bf8287e907feda26c62cd135a9d8f34ed41acd10 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 21 Oct 2026 13:51:39 +0200 Subject: [PATCH 1/8] [Feature] Add JSON streaming to GraphiQL commands --- .changeset/graphiql-json-stream.md | 5 + .../generated/generated_docs_data_v2.json | 22 +- docs/cli/json-output.md | 45 +++ .../app/src/cli/commands/app/graphiql.test.ts | 60 +++ packages/app/src/cli/commands/app/graphiql.ts | 35 +- .../app/src/cli/services/app/graphiql.test.ts | 2 + packages/app/src/cli/services/app/graphiql.ts | 5 +- .../src/public/node/graphiql/schema.test.ts | 37 ++ .../src/public/node/graphiql/schema.ts | 37 ++ .../src/public/node/graphiql/server.ts | 53 ++- .../src/public/node/graphiql/session.test.ts | 352 ++++++++++++++---- .../src/public/node/graphiql/session.ts | 198 ++++++++-- packages/cli/README.md | 310 ++++++++++++++- packages/cli/oclif.manifest.json | 26 +- packages/cli/src/bootstrap.ts | 7 +- .../src/graphiql-shutdown.integration.test.ts | 61 +++ packages/cli/src/index.ts | 7 +- .../src/cli/commands/store/graphiql.test.ts | 61 +++ .../store/src/cli/commands/store/graphiql.ts | 28 +- .../src/cli/services/store/graphiql.test.ts | 2 + .../store/src/cli/services/store/graphiql.ts | 5 +- 21 files changed, 1212 insertions(+), 146 deletions(-) create mode 100644 .changeset/graphiql-json-stream.md create mode 100644 packages/cli-kit/src/public/node/graphiql/schema.test.ts create mode 100644 packages/cli-kit/src/public/node/graphiql/schema.ts create mode 100644 packages/cli/src/graphiql-shutdown.integration.test.ts 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/docs/cli/json-output.md b/docs/cli/json-output.md index aea695beaf9..7f2ceaa19d1 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -238,6 +238,51 @@ When a JSON command prompts, stderr also contains human-readable UI and terminal interactive mode, stderr is not a pure JSONL stream. For automation, use `--json --no-input` to capture JSON side events from stderr; missing required input then produces a fatal error instead of a prompt. +## GraphiQL streaming commands + +`shopify app graphiql --json` and `shopify store graphiql --json` use the following command-specific streaming +contract. The broader streaming investigation is still open; this does not define the contract for other commands. + +Each stdout line is one compact JSON object followed by a newline. Lifecycle records conform to `GraphiQLRecord`, +available through either command's `--json-schema`. All timestamps use whole-second UTC instants. + +| Record | Meaning | +| --- | --- | +| `starting` | Local server startup has begun. Includes `storeDomain`; authentication and app/store selection can precede this record. | +| `ready` | The HTTP server is listening. Includes the authenticated local `url`, `storeDomain`, `api: "admin"`, requested `apiVersion`, `mutationsAllowed`, and `app`. | +| `stopped` | Cancellation has completed and the server's connections have closed. `reason` is `cancelled`, `sigint`, `sigterm`, or `sigquit`. | + +`ready.apiVersion` is `null` when the browser chooses the latest stable API version. It is the requested version, +not a live report of the version selected in the browser. `ready.app` is `{name, url}` for app GraphiQL and `null` +for store GraphiQL. The local URL includes its authentication key, just as the text output does; app secrets and +Admin API access tokens are never included in these records. + +There is no final success result, heartbeat, request-result stream, or session file. Consumers must also handle +process exit: hard termination cannot guarantee a `stopped` record. A fatal startup or server failure closes owned +resources, writes one standard `{error: {...}}` envelope as the final stdout line, and exits nonzero without `stopped`. +Earlier lifecycle lines remain valid. Startup can fail before `starting`, for example during authentication. +SIGINT, SIGTERM, and SIGQUIT exit with 130, 143, and 131 respectively after cleanup. A caller-supplied abort signal +completes normally. + +Setup messages, browser warnings, debug logs, and recoverable request failures use the existing diagnostic events +on stderr. Recoverable request failures have code `graphiql-request-failed` and leave the server running. Native +GraphQL responses, including errors and partial data, remain HTTP responses to the browser; they are not copied to +stdout. Interactive prompts can still add terminal UI to stderr. + +`--json` does not disable browser opening or authenticated app/store selection. `--no-input` skips browser opening +in either format and requires any input otherwise obtained through prompts. Store mutations remain blocked unless +`--allow-mutations` is supplied. Existing text messages and HTTP payloads are preserved; signal cleanup and skipping +the browser with `--no-input` apply to both output formats. + +For example, after `shopify store auth`, capture the two streams independently: + +```sh +shopify store graphiql --store example.myshopify.com --json --no-input >graphiql.jsonl 2>diagnostics.jsonl +``` + +Stop the process with Ctrl+C or SIGTERM. The stdout file contains `starting`, `ready`, and `stopped` records; +diagnostics remain in the stderr file. + ## Exempt only streaming commands Long-lived commands that produce an open-ended event stream don't have one finite result. Track these exemptions in diff --git a/packages/app/src/cli/commands/app/graphiql.test.ts b/packages/app/src/cli/commands/app/graphiql.test.ts index 356af4f38fb..48329747d28 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('JSONL stream') + }) + + 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..80ded81a00a 100644 --- a/packages/app/src/cli/commands/app/graphiql.ts +++ b/packages/app/src/cli/commands/app/graphiql.ts @@ -3,15 +3,22 @@ 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.' static descriptionWithMarkdown = `Opens an authenticated Admin API GraphiQL UI for your app and selected store. -The app must be installed on the store.` +The app must be installed on the store. + +With \`--json\`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use \`--no-input\` to skip opening the browser; it does not select JSON output.` + + static get jsonOutputSchema() { + return graphiqlJsonOutputSchema + } static description = this.descriptionForHelp() @@ -22,6 +29,7 @@ The app must be installed on the store.` static flags = { ...globalFlags, + ...jsonFlag, ...appFlags, store: Flags.string({ char: 's', @@ -47,16 +55,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) + + 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} + 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..d4ecc222dfc --- /dev/null +++ b/packages/cli-kit/src/public/node/graphiql/schema.test.ts @@ -0,0 +1,37 @@ +import {graphiqlJsonOutputSchema} from './schema.js' +import {expect, test} from 'vitest' +import {Ajv} from 'ajv' + +test('publishes a self-contained JSON Schema for every lifecycle record', () => { + 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: 'shop.myshopify.com'}, + { + type: 'ready', + timestamp, + storeDomain: 'shop.myshopify.com', + 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(['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..3a8916f24d1 --- /dev/null +++ b/packages/cli-kit/src/public/node/graphiql/schema.ts @@ -0,0 +1,37 @@ +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-]*\.myshopify\.com$/) +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..5184047b335 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('../tcp.js') -vi.mock('../system.js') +vi.mock('../api/admin.js', async (importOriginal) => { + const original = await importOriginal() + return {...original, supportedApiVersions: vi.fn(original.supportedApiVersions)} +}) + +const options = { + storeFqdn: 'shop.myshopify.com', + tokenProvider: {getToken: async () => 'token'}, + key: 'key', + outputFormat: 'json' as const, +} -const mockedSetupGraphiQLServer = vi.mocked(setupGraphiQLServer) -const mockedGetAvailableTCPPort = vi.mocked(getAvailableTCPPort) -const mockedOpenURL = vi.mocked(openURL) +function records(stdout: string) { + return stdout + .trim() + .split('\n') + .filter(Boolean) + .map((line) => graphiqlJsonOutputSchema.validate(JSON.parse(line))) +} -function abortAfter(controller: AbortController) { - setImmediate(() => controller.abort()) - return controller.signal +function listeners() { + const processEvents: NodeJS.EventEmitter = process + return ['SIGINT', 'SIGTERM', 'SIGQUIT', 'shopify:signal'].map((name) => processEvents.listeners(name)) } describe('buildGraphiQLUrl', () => { @@ -51,70 +79,254 @@ describe('generateRandomGraphiQLKey', () => { }) describe('runGraphiQLSession', () => { - beforeEach(() => { - mockedGetAvailableTCPPort.mockResolvedValue(4567) - mockedOpenURL.mockResolvedValue(true) - mockedSetupGraphiQLServer.mockReturnValue({close: vi.fn()} as unknown as ReturnType) + test('writes lifecycle records only after listening and closes its real server', async () => { + 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, + 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: options.storeFqdn, timestamp: expect.any(String)}, + { + type: 'ready', + timestamp: expect.any(String), + url, + storeDomain: options.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('starts the server, opens the keyed URL, waits for abort, and closes the server', async () => { + test('keeps repeated recoverable request failures on stderr and native responses over HTTP', async () => { const controller = new AbortController() - const tokenProvider = {getToken: async () => 'token'} - const server = {close: vi.fn()} - mockedSetupGraphiQLServer.mockReturnValueOnce(server as unknown as ReturnType) + 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), + })), + ) + }) + }) - 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('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('encodes HTTP handler failures as diagnostics without H3 terminal logs', async () => { + const controller = new AbortController() + 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'}) }) + }) - 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('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) + }) }) - 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(['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.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..d118a38ef36 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,152 @@ DESCRIPTION The app must be installed on the store. + With `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; + fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip + opening the browser; it does not select JSON output. + + 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-]*\\.myshopify\\.com$" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES $ shopify app graphiql --store shop.myshopify.com @@ -12030,10 +12180,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 +12233,152 @@ DESCRIPTION Mutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data. + With `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; + fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip + opening the browser; it does not select JSON output. + + 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-]*\\.myshopify\\.com$" + } + }, + "$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..6f166c582b1 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2810,8 +2810,8 @@ "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.", - "descriptionWithMarkdown": "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\nWith `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip opening the browser; it does not select JSON output.\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-]*\\\\.myshopify\\\\.com$\"\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.\n\nWith `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip opening the browser; it does not select JSON output.", "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --port 9123" @@ -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,8 +10255,8 @@ "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.", - "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.", + "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\nWith `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip opening the browser; it does not select JSON output.\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-]*\\\\.myshopify\\\\.com$\"\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.\n\nWith `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip opening the browser; it does not select JSON output.", "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --allow-mutations", @@ -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..61ae8414ad0 --- /dev/null +++ b/packages/cli/src/graphiql-shutdown.integration.test.ts @@ -0,0 +1,61 @@ +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', () => { + test.each([ + ['SIGINT', 'sigint', 130], + ['SIGTERM', 'sigterm', 143], + ])('flushes lifecycle records and closes the server after %s', {timeout: 20000}, async (signal, reason, exitCode) => { + const script = ` + await import(${JSON.stringify(bootstrapUrl)}) + 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(() => process.emit(${JSON.stringify(signal)})) + } + 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', + SHOPIFY_UNIT_TEST: 'false', + SHOPIFY_FLAG_NO_INPUT: '1', + }, + }, + ) + + expect(result.exitCode, result.stderr).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/store/src/cli/commands/store/graphiql.test.ts b/packages/store/src/cli/commands/store/graphiql.test.ts index af1a480e21d..f4194ddcbcc 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('JSONL stream') + }) + + 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..32b71c8e73c 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.' @@ -11,7 +12,13 @@ export default class StoreGraphiQL extends StoreCommand { 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.` +Mutations are disabled by default. Re-run with \`--allow-mutations\` if you intend to modify store data. + +With \`--json\`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use \`--no-input\` to skip opening the browser; it does not select JSON output.` + + static get jsonOutputSchema() { + return graphiqlJsonOutputSchema + } static description = this.descriptionForHelp() @@ -23,6 +30,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 +54,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')}.`, ], From f0c3ae9a4eec59161112530c716472d51e4c2c20 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 22 Oct 2026 17:04:15 +0200 Subject: [PATCH 2/8] Remove GraphiQL section from JSON output guide --- docs/cli/json-output.md | 45 ----------------------------------------- 1 file changed, 45 deletions(-) diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index 7f2ceaa19d1..aea695beaf9 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -238,51 +238,6 @@ When a JSON command prompts, stderr also contains human-readable UI and terminal interactive mode, stderr is not a pure JSONL stream. For automation, use `--json --no-input` to capture JSON side events from stderr; missing required input then produces a fatal error instead of a prompt. -## GraphiQL streaming commands - -`shopify app graphiql --json` and `shopify store graphiql --json` use the following command-specific streaming -contract. The broader streaming investigation is still open; this does not define the contract for other commands. - -Each stdout line is one compact JSON object followed by a newline. Lifecycle records conform to `GraphiQLRecord`, -available through either command's `--json-schema`. All timestamps use whole-second UTC instants. - -| Record | Meaning | -| --- | --- | -| `starting` | Local server startup has begun. Includes `storeDomain`; authentication and app/store selection can precede this record. | -| `ready` | The HTTP server is listening. Includes the authenticated local `url`, `storeDomain`, `api: "admin"`, requested `apiVersion`, `mutationsAllowed`, and `app`. | -| `stopped` | Cancellation has completed and the server's connections have closed. `reason` is `cancelled`, `sigint`, `sigterm`, or `sigquit`. | - -`ready.apiVersion` is `null` when the browser chooses the latest stable API version. It is the requested version, -not a live report of the version selected in the browser. `ready.app` is `{name, url}` for app GraphiQL and `null` -for store GraphiQL. The local URL includes its authentication key, just as the text output does; app secrets and -Admin API access tokens are never included in these records. - -There is no final success result, heartbeat, request-result stream, or session file. Consumers must also handle -process exit: hard termination cannot guarantee a `stopped` record. A fatal startup or server failure closes owned -resources, writes one standard `{error: {...}}` envelope as the final stdout line, and exits nonzero without `stopped`. -Earlier lifecycle lines remain valid. Startup can fail before `starting`, for example during authentication. -SIGINT, SIGTERM, and SIGQUIT exit with 130, 143, and 131 respectively after cleanup. A caller-supplied abort signal -completes normally. - -Setup messages, browser warnings, debug logs, and recoverable request failures use the existing diagnostic events -on stderr. Recoverable request failures have code `graphiql-request-failed` and leave the server running. Native -GraphQL responses, including errors and partial data, remain HTTP responses to the browser; they are not copied to -stdout. Interactive prompts can still add terminal UI to stderr. - -`--json` does not disable browser opening or authenticated app/store selection. `--no-input` skips browser opening -in either format and requires any input otherwise obtained through prompts. Store mutations remain blocked unless -`--allow-mutations` is supplied. Existing text messages and HTTP payloads are preserved; signal cleanup and skipping -the browser with `--no-input` apply to both output formats. - -For example, after `shopify store auth`, capture the two streams independently: - -```sh -shopify store graphiql --store example.myshopify.com --json --no-input >graphiql.jsonl 2>diagnostics.jsonl -``` - -Stop the process with Ctrl+C or SIGTERM. The stdout file contains `starting`, `ready`, and `stopped` records; -diagnostics remain in the stderr file. - ## Exempt only streaming commands Long-lived commands that produce an open-ended event stream don't have one finite result. Track these exemptions in From 85920ecfe5901a719b1c1279f08ebc874d2f16e9 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 22 Oct 2026 17:07:50 +0200 Subject: [PATCH 3/8] Simplify GraphiQL command descriptions --- packages/app/src/cli/commands/app/graphiql.ts | 4 +--- packages/cli/README.md | 8 -------- packages/cli/oclif.manifest.json | 8 ++++---- packages/store/src/cli/commands/store/graphiql.ts | 4 +--- 4 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/app/src/cli/commands/app/graphiql.ts b/packages/app/src/cli/commands/app/graphiql.ts index 80ded81a00a..2d63d182934 100644 --- a/packages/app/src/cli/commands/app/graphiql.ts +++ b/packages/app/src/cli/commands/app/graphiql.ts @@ -12,9 +12,7 @@ export default class AppGraphiQL extends AppLinkedCommand { static descriptionWithMarkdown = `Opens an authenticated Admin API GraphiQL UI for your app and selected store. -The app must be installed on the store. - -With \`--json\`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use \`--no-input\` to skip opening the browser; it does not select JSON output.` +The app must be installed on the store.` static get jsonOutputSchema() { return graphiqlJsonOutputSchema diff --git a/packages/cli/README.md b/packages/cli/README.md index d118a38ef36..19f6a2e6e09 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3531,10 +3531,6 @@ DESCRIPTION The app must be installed on the store. - With `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; - fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip - opening the browser; it does not select JSON output. - Use `--json-schema` to print the result, error, and event schemas. Output from `--json` conforms to the `GraphiQLRecord` schema. @@ -12233,10 +12229,6 @@ DESCRIPTION Mutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data. - With `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; - fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip - opening the browser; it does not select JSON output. - Use `--json-schema` to print the result, error, and event schemas. Output from `--json` conforms to the `GraphiQLRecord` schema. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 6f166c582b1..4f36fe3c27c 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2810,8 +2810,8 @@ "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.\n\nWith `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip opening the browser; it does not select JSON output.\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-]*\\\\.myshopify\\\\.com$\"\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.\n\nWith `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip opening the browser; it does not select JSON output.", + "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-]*\\\\.myshopify\\\\.com$\"\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", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --port 9123" @@ -10255,8 +10255,8 @@ "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.\n\nWith `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip opening the browser; it does not select JSON output.\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-]*\\\\.myshopify\\\\.com$\"\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.\n\nWith `--json`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use `--no-input` to skip opening the browser; it does not select JSON output.", + "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-]*\\\\.myshopify\\\\.com$\"\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", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --allow-mutations", diff --git a/packages/store/src/cli/commands/store/graphiql.ts b/packages/store/src/cli/commands/store/graphiql.ts index 32b71c8e73c..81e44adee06 100644 --- a/packages/store/src/cli/commands/store/graphiql.ts +++ b/packages/store/src/cli/commands/store/graphiql.ts @@ -12,9 +12,7 @@ export default class StoreGraphiQL extends StoreCommand { 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. - -With \`--json\`, stdout is a JSONL stream of starting, ready, and stopped lifecycle records. Diagnostics use stderr; fatal errors use the standard error envelope on stdout. There is no final success result. Use \`--no-input\` to skip opening the browser; it does not select JSON output.` +Mutations are disabled by default. Re-run with \`--allow-mutations\` if you intend to modify store data.` static get jsonOutputSchema() { return graphiqlJsonOutputSchema From 48ac481c8ac139fab3124bb64ae921a666b31ff2 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 23 Oct 2026 11:42:44 +0200 Subject: [PATCH 4/8] Enforce GraphiQL JSON output contracts and fix CI tests --- packages/app/src/cli/commands/app/graphiql.test.ts | 2 +- packages/cli/src/graphiql-shutdown.integration.test.ts | 2 ++ .../eslint-plugin-cli/rules/json-output-command-exceptions.js | 2 -- packages/store/src/cli/commands/store/graphiql.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/commands/app/graphiql.test.ts b/packages/app/src/cli/commands/app/graphiql.test.ts index 48329747d28..45bbdbe57c9 100644 --- a/packages/app/src/cli/commands/app/graphiql.test.ts +++ b/packages/app/src/cli/commands/app/graphiql.test.ts @@ -93,7 +93,7 @@ describe('app graphiql command', () => { test('exposes the lifecycle schema in command help', () => { expect(AppGraphiQL.jsonOutputSchema.name).toBe('GraphiQLRecord') - expect(AppGraphiQL.description).toContain('JSONL stream') + expect(AppGraphiQL.description).toContain('Output from `--json` conforms to the `GraphiQLRecord` schema.') }) test('discovers the schema without starting or authenticating GraphiQL', async () => { diff --git a/packages/cli/src/graphiql-shutdown.integration.test.ts b/packages/cli/src/graphiql-shutdown.integration.test.ts index 61ae8414ad0..a1ceec7d389 100644 --- a/packages/cli/src/graphiql-shutdown.integration.test.ts +++ b/packages/cli/src/graphiql-shutdown.integration.test.ts @@ -43,6 +43,8 @@ describe('GraphiQL shutdown through the CLI bootstrap', () => { ...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', }, 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 f4194ddcbcc..214d4048d60 100644 --- a/packages/store/src/cli/commands/store/graphiql.test.ts +++ b/packages/store/src/cli/commands/store/graphiql.test.ts @@ -68,7 +68,7 @@ describe('store graphiql command', () => { test('exposes the lifecycle schema in command help', () => { expect(StoreGraphiQL.jsonOutputSchema.name).toBe('GraphiQLRecord') - expect(StoreGraphiQL.description).toContain('JSONL stream') + expect(StoreGraphiQL.description).toContain('Output from `--json` conforms to the `GraphiQLRecord` schema.') }) test('discovers the schema without starting or authenticating GraphiQL', async () => { From 40c57fd28a88855c0bdf87d9fc8d68ea90bd722e Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 23 Oct 2026 11:49:34 +0200 Subject: [PATCH 5/8] Include subprocess output in GraphiQL shutdown failures --- packages/cli/src/graphiql-shutdown.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/graphiql-shutdown.integration.test.ts b/packages/cli/src/graphiql-shutdown.integration.test.ts index a1ceec7d389..aea651eeb65 100644 --- a/packages/cli/src/graphiql-shutdown.integration.test.ts +++ b/packages/cli/src/graphiql-shutdown.integration.test.ts @@ -51,7 +51,7 @@ describe('GraphiQL shutdown through the CLI bootstrap', () => { }, ) - expect(result.exitCode, result.stderr).toBe(exitCode) + expect(result.exitCode, JSON.stringify(result)).toBe(exitCode) expect(result.stderr).toBe('') const records = result.stdout .trim() From aba7c04e662b25b2a1cb3fced985967e262550d5 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 23 Oct 2026 11:57:49 +0200 Subject: [PATCH 6/8] Exercise the bootstrap signal handler directly in shutdown tests --- packages/cli/src/graphiql-shutdown.integration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/graphiql-shutdown.integration.test.ts b/packages/cli/src/graphiql-shutdown.integration.test.ts index aea651eeb65..1d74b8f98ce 100644 --- a/packages/cli/src/graphiql-shutdown.integration.test.ts +++ b/packages/cli/src/graphiql-shutdown.integration.test.ts @@ -12,6 +12,7 @@ describe('GraphiQL shutdown through the CLI bootstrap', () => { ])('flushes lifecycle records and closes the server after %s', {timeout: 20000}, 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 @@ -19,7 +20,7 @@ describe('GraphiQL shutdown through the CLI bootstrap', () => { const result = write(chunk, ...args) if (String(chunk).includes('"type":"ready"')) { url = JSON.parse(String(chunk)).url - setImmediate(() => process.emit(${JSON.stringify(signal)})) + setImmediate(() => signalHandler()) } return result } From 28b3cdb5779dd37db01e891d46b0cd205fca781c Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 23 Oct 2026 12:08:13 +0200 Subject: [PATCH 7/8] Allow time for TypeScript startup in Windows shutdown tests --- packages/cli/src/graphiql-shutdown.integration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/graphiql-shutdown.integration.test.ts b/packages/cli/src/graphiql-shutdown.integration.test.ts index 1d74b8f98ce..dd7208a43f4 100644 --- a/packages/cli/src/graphiql-shutdown.integration.test.ts +++ b/packages/cli/src/graphiql-shutdown.integration.test.ts @@ -6,10 +6,11 @@ const sessionUrl = new URL('../../cli-kit/src/public/node/graphiql/session.ts', 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: 20000}, async (signal, reason, exitCode) => { + ])('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) From 8802782240bef2aa855016063108c83ef442a7d0 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 23 Oct 2026 12:51:22 +0200 Subject: [PATCH 8/8] Accept development store hostnames in GraphiQL JSON records --- .../src/public/node/graphiql/schema.test.ts | 58 ++++++++----- .../src/public/node/graphiql/schema.ts | 5 +- .../src/public/node/graphiql/session.test.ts | 86 ++++++++++--------- packages/cli/README.md | 6 +- packages/cli/oclif.manifest.json | 4 +- 5 files changed, 91 insertions(+), 68 deletions(-) diff --git a/packages/cli-kit/src/public/node/graphiql/schema.test.ts b/packages/cli-kit/src/public/node/graphiql/schema.test.ts index d4ecc222dfc..2da1df43876 100644 --- a/packages/cli-kit/src/public/node/graphiql/schema.test.ts +++ b/packages/cli-kit/src/public/node/graphiql/schema.test.ts @@ -2,28 +2,42 @@ import {graphiqlJsonOutputSchema} from './schema.js' import {expect, test} from 'vitest' import {Ajv} from 'ajv' -test('publishes a self-contained JSON Schema for every lifecycle record', () => { - 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: 'shop.myshopify.com'}, - { - type: 'ready', - timestamp, - storeDomain: 'shop.myshopify.com', - 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(['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', diff --git a/packages/cli-kit/src/public/node/graphiql/schema.ts b/packages/cli-kit/src/public/node/graphiql/schema.ts index 3a8916f24d1..679bafcd01d 100644 --- a/packages/cli-kit/src/public/node/graphiql/schema.ts +++ b/packages/cli-kit/src/public/node/graphiql/schema.ts @@ -1,7 +1,10 @@ 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-]*\.myshopify\.com$/) +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. */ 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 5184047b335..83e66b21d07 100644 --- a/packages/cli-kit/src/public/node/graphiql/session.test.ts +++ b/packages/cli-kit/src/public/node/graphiql/session.test.ts @@ -79,48 +79,52 @@ describe('generateRandomGraphiQLKey', () => { }) describe('runGraphiQLSession', () => { - test('writes lifecycle records only after listening and closes its real server', async () => { - 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 + 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 runWithCommandEventsForCommand(['--json'], () => - runGraphiQLSession({ - ...options, - 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: options.storeFqdn, timestamp: expect.any(String)}, - { - type: 'ready', - timestamp: expect.any(String), - url, - storeDomain: options.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) - }) + 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() diff --git a/packages/cli/README.md b/packages/cli/README.md index 19f6a2e6e09..c3cab4cdb0c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3666,7 +3666,8 @@ DESCRIPTION }, "GraphiQLStoreDomain": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$" + "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#" @@ -12364,7 +12365,8 @@ DESCRIPTION }, "GraphiQLStoreDomain": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$" + "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#" diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 4f36fe3c27c..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.\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-]*\\\\.myshopify\\\\.com$\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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", @@ -10255,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.\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-]*\\\\.myshopify\\\\.com$\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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",