Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/graphiql-json-stream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Add JSON lifecycle streaming to app and store GraphiQL commands.
22 changes: 20 additions & 2 deletions docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: 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 <value>'?: string\n\n /**\n * Local port for the GraphiQL server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: 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 <value>'?: string\n\n /**\n * Local port for the GraphiQL server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: string\n}"
}
},
"appimportcustomdatadefinitions": {
Expand Down Expand Up @@ -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",
Expand All @@ -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 <value>'?: string\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>': 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 <value>'?: 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 <value>'?: 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 <value>'?: string\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>': 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 <value>'?: 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 <value>'?: string\n}"
}
},
"storeinfo": {
Expand Down
60 changes: 60 additions & 0 deletions packages/app/src/cli/commands/app/graphiql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -66,7 +71,62 @@ describe('app graphiql command', () => {
port: 9123,
variables: '{"id":1}',
apiVersion: '2024-10',
outputFormat: 'text',
})
expect(result).toEqual({app})
})

test.each([
{json: false, noInput: false},
{json: true, noInput: false},
{json: false, noInput: true},
{json: true, noInput: true},
])('keeps output format independent of input: %j', async ({json, noInput}) => {
await AppGraphiQL.run([
'--store',
'shop.myshopify.com',
...(json ? ['--json'] : []),
...(noInput ? ['--no-input'] : []),
])
expect(openAppGraphiQL).toHaveBeenCalledWith(expect.objectContaining({outputFormat: json ? 'json' : 'text'}))
})

test('exposes the lifecycle schema in command help', () => {
expect(AppGraphiQL.jsonOutputSchema.name).toBe('GraphiQLRecord')
expect(AppGraphiQL.description).toContain('Output from `--json` conforms to the `GraphiQLRecord` schema.')
})

test('discovers the schema without starting or authenticating GraphiQL', async () => {
vi.spyOn(ShopifyConfig.prototype, 'runHook').mockResolvedValue({successes: [], failures: []})
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await launchCLI({
moduleURL: new URL('../../../../../cli/src/bootstrap.ts', import.meta.url).href,
argv: ['app', 'graphiql', '--json-schema'],
lazyCommandLoader: async () => AppGraphiQL,
})
expect(JSON.parse(stdout()).definitions.Result.anyOf).toHaveLength(3)
expect(openAppGraphiQL).not.toHaveBeenCalled()
expect(stderr()).toBe('')
})
})

test.each(['--json', '-j'])('keeps fatal errors on stdout and diagnostics on stderr with %s', async (flag) => {
vi.stubEnv('SHOPIFY_CLI_NO_ANALYTICS', '1')
const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never)
vi.mocked(openAppGraphiQL).mockImplementationOnce(async () => {
outputInfo('GraphiQL setup diagnostic')
throw new AbortError('GraphiQL setup failed.')
})
try {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await AppGraphiQL.run(['--store', 'shop.myshopify.com', flag])
expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message: 'GraphiQL setup failed.'}})
expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'GraphiQL setup diagnostic'})
expect(exit).toHaveBeenCalledWith(1)
})
} finally {
exit.mockRestore()
vi.unstubAllEnvs()
}
})
})
31 changes: 20 additions & 11 deletions packages/app/src/cli/commands/app/graphiql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import {openAppGraphiQL} from '../../services/app/graphiql.js'
import {prepareAppStoreContext} from '../../utilities/execute-command-helpers.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {globalFlags, portFlag} from '@shopify/cli-kit/node/cli'
import {globalFlags, jsonFlag, portFlag} from '@shopify/cli-kit/node/cli'
import {Flags} from '@oclif/core'
import {graphiqlJsonOutputSchema} from '@shopify/cli-kit/node/graphiql/schema'

export default class AppGraphiQL extends AppLinkedCommand {
static summary = 'Open a local GraphiQL UI for your app and store.'
Expand All @@ -13,6 +14,10 @@ export default class AppGraphiQL extends AppLinkedCommand {

The app must be installed on the store.`

static get jsonOutputSchema() {
return graphiqlJsonOutputSchema
}

static description = this.descriptionForHelp()

static examples = [
Expand All @@ -22,6 +27,7 @@ The app must be installed on the store.`

static flags = {
...globalFlags,
...jsonFlag,
...appFlags,
store: Flags.string({
char: 's',
Expand All @@ -47,16 +53,19 @@ The app must be installed on the store.`

public async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(AppGraphiQL)
const {appContextResult, store} = await prepareAppStoreContext(flags)

await openAppGraphiQL({
remoteApp: appContextResult.remoteApp,
store: store.shopDomain,
port: flags.port,
variables: flags.variables,
apiVersion: flags.version,
})
return this.runWithParsedOutput(flags, async () => {
const {appContextResult, store} = await prepareAppStoreContext(flags)

return {app: appContextResult.app}
await openAppGraphiQL({
remoteApp: appContextResult.remoteApp,
store: store.shopDomain,
port: flags.port,
variables: flags.variables,
apiVersion: flags.version,
outputFormat: flags.json ? 'json' : 'text',
})

return {app: appContextResult.app}
})
}
}
2 changes: 2 additions & 0 deletions packages/app/src/cli/services/app/graphiql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
)
})
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/cli/services/app/graphiql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -43,6 +43,7 @@ export async function openAppGraphiQL(options: OpenAppGraphiQLOptions): Promise<
},
variables: options.variables,
apiVersion: options.apiVersion,
outputFormat: options.outputFormat,
abortSignal: options.abortSignal,
})
}
51 changes: 51 additions & 0 deletions packages/cli-kit/src/public/node/graphiql/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {graphiqlJsonOutputSchema} from './schema.js'
import {expect, test} from 'vitest'
import {Ajv} from 'ajv'

test.each(['shop.myshopify.com', 'shop.myshopify.io', 'shop.my.shop.dev', 'nested.shop.myshopify.com'])(
'publishes a self-contained JSON Schema for every lifecycle record with store %s',
(storeDomain) => {
const validate = new Ajv({formats: {'date-time': true, uri: true}}).compile(graphiqlJsonOutputSchema.jsonSchema)
const timestamp = '2026-10-21T10:20:30Z'
for (const record of [
{type: 'starting', timestamp, storeDomain},
{
type: 'ready',
timestamp,
storeDomain,
url: 'http://localhost:3456/graphiql?key=key',
api: 'admin',
apiVersion: null,
mutationsAllowed: false,
app: null,
},
{type: 'stopped', timestamp, reason: 'cancelled'},
]) {
expect(validate(record), JSON.stringify(validate.errors)).toBe(true)
expect(graphiqlJsonOutputSchema.validate(record)).toEqual(record)
expect(validate({...record, accessToken: 'secret'})).toBe(false)
}
},
)

test.each(['', 'https://shop.myshopify.io', 'shop.my.shop.dev/admin', 'shop..myshopify.com'])(
'rejects store domains that are not normalized hostnames (%s)',
(storeDomain) => {
const record = {type: 'starting', timestamp: '2026-10-21T10:20:30Z', storeDomain}
expect(() => graphiqlJsonOutputSchema.validate(record)).toThrow()
expect(
new Ajv({formats: {'date-time': true, uri: true}}).compile(graphiqlJsonOutputSchema.jsonSchema)(record),
).toBe(false)
},
)

test.each(['2026-10-21T10:20:30.123Z', '2026-10-21T10:20:30+00:00'])(
'rejects noncanonical timestamp %s',
(timestamp) => {
const record = {type: 'starting', timestamp, storeDomain: 'shop.myshopify.com'}
expect(() => graphiqlJsonOutputSchema.validate(record)).toThrow()
expect(
new Ajv({formats: {'date-time': true, uri: true}}).compile(graphiqlJsonOutputSchema.jsonSchema)(record),
).toBe(false)
},
)
40 changes: 40 additions & 0 deletions packages/cli-kit/src/public/node/graphiql/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {defineJsonOutputSchema, jsonOutputTimestampSchema, type InferJsonOutputSchema} from '../json-output-schema.js'
import {zod} from '../schema.js'

const storeDomain = zod
.string()
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/)
.describe('Normalized store hostname, including local development domains.')
const timestamp = jsonOutputTimestampSchema

/** Lifecycle records written as JSONL by the standalone GraphiQL commands. */
export const graphiqlJsonOutputSchema = defineJsonOutputSchema({
name: 'GraphiQLRecord',
definitions: {GraphiQLTimestamp: timestamp, GraphiQLStoreDomain: storeDomain},
schema: zod.discriminatedUnion('type', [
zod.object({type: zod.literal('starting'), timestamp, storeDomain}).strict(),
zod
.object({
type: zod.literal('ready'),
timestamp,
url: zod.string().url().describe('Local browser URL, including the session authentication key.'),
storeDomain,
api: zod.literal('admin'),
apiVersion: zod.string().nullable().describe('Requested API version; null uses the latest stable version.'),
mutationsAllowed: zod.boolean(),
app: zod.object({name: zod.string(), url: zod.string().url()}).strict().nullable(),
})
.strict(),
zod
.object({
type: zod.literal('stopped'),
timestamp,
reason: zod.enum(['cancelled', 'sigint', 'sigterm', 'sigquit']),
})
.strict(),
]),
})

type WithoutTimestamp<T> = T extends unknown ? Omit<T, 'timestamp'> : never
type GraphiQLRecord = InferJsonOutputSchema<typeof graphiqlJsonOutputSchema>
export type GraphiQLRecordInput = WithoutTimestamp<GraphiQLRecord>
Loading
Loading