Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
}
})
})
29 changes: 18 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,8 @@ export default class AppGraphiQL extends AppLinkedCommand {

The app must be installed on the store.`

static jsonOutputSchema = graphiqlJsonOutputSchema

static description = this.descriptionForHelp()

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

static flags = {
...globalFlags,
...jsonFlag,
...appFlags,
store: Flags.string({
char: 's',
Expand All @@ -47,16 +51,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,
})
}
7 changes: 2 additions & 5 deletions packages/app/src/cli/services/bulk-operations/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {jsonOutputTimestampSchema} from '@shopify/cli-kit/node/json-output-schema'
import {jsonOutputTimestampSchema, jsonOutputStoreDomainSchema} from '@shopify/cli-kit/node/json-output-schema'
import {zod} from '@shopify/cli-kit/node/schema'

export const BulkOperationGidSchema = zod
Expand All @@ -25,10 +25,7 @@ export const BulkOperationSchema = zod

export const BulkOperationContextSchema = zod
.object({
storeDomain: zod
.string()
.regex(/^[^.]+\.myshopify\.com$/)
.nullable(),
storeDomain: jsonOutputStoreDomainSchema.nullable(),
apiVersion: zod.string().nullable().describe('The API version selected for the request.'),
})
.strict()
Expand Down
10 changes: 8 additions & 2 deletions packages/app/src/cli/services/dev-clean/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
import {
defineJsonOutputSchema,
jsonOutputStoreDomainSchema,
type InferJsonOutputSchema,
} from '@shopify/cli-kit/node/json-output-schema'
import {zod} from '@shopify/cli-kit/node/schema'

const appSchema = zod.object({name: zod.string(), clientId: zod.string().min(1)}).strict()
Expand All @@ -9,7 +13,9 @@ export const appDevCleanJsonOutputSchema = defineJsonOutputSchema({
.object({
status: zod.literal('success'),
app: appSchema,
storeDomain: zod.string().nullable().describe('The canonical *.myshopify.com hostname, or null when unknown.'),
storeDomain: jsonOutputStoreDomainSchema
.nullable()
.describe('The canonical *.myshopify.com hostname, or null when unknown.'),
})
.strict(),
definitions: {AppDevCleanApp: appSchema},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,33 @@ describe('createClientCredentialsTokenProvider', () => {
)
})

test.each(['getToken', 'refreshToken'] as const)(
'cancels an outstanding %s request when aborted',
async (operation) => {
const controller = new AbortController()
mockedFetch.mockImplementationOnce(async (_url, request) => {
return new Promise((_resolve, reject) => {
request?.signal?.addEventListener('abort', () => reject(new Error('Token request aborted')))
})
})
const provider = createClientCredentialsTokenProvider({
apiKey: 'api-key',
apiSecret: 'api-secret',
storeFqdn: 'store.myshopify.com',
})
const token = provider[operation]!(controller.signal)
const rejected = expect(token).rejects.toThrow('Token request aborted')
expect(mockedFetch).toHaveBeenCalledWith(
'https://store.myshopify.com/admin/oauth/access_token',
expect.objectContaining({signal: controller.signal}),
)

controller.abort()

await rejected
},
)

test('throws when the token response is not successful', async () => {
mockFailedTokenResponse(401)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function createClientCredentialsTokenProvider({
}: ClientCredentialsTokenProviderOptions): TokenProvider {
let cachedToken: string | undefined

const mint = async (): Promise<string> => {
const mint = async (signal?: AbortSignal): Promise<string> => {
const tokenResponse = await fetch(`https://${storeFqdn}/admin/oauth/access_token`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
Expand All @@ -31,6 +31,7 @@ export function createClientCredentialsTokenProvider({
client_secret: apiSecret,
grant_type: 'client_credentials',
}),
signal,
})

const tokenJson = (await tokenResponse.json()) as {access_token?: string}
Expand All @@ -43,10 +44,10 @@ export function createClientCredentialsTokenProvider({
}

return {
getToken: async () => cachedToken ?? mint(),
refreshToken: async () => {
getToken: async (signal) => cachedToken ?? mint(signal),
refreshToken: async (signal) => {
cachedToken = undefined
return mint()
return mint(signal)
},
}
}
Loading
Loading