diff --git a/.changeset/store-execute-graphiql.md b/.changeset/store-execute-graphiql.md new file mode 100644 index 00000000000..e35bfbe28c0 --- /dev/null +++ b/.changeset/store-execute-graphiql.md @@ -0,0 +1,7 @@ +--- +'@shopify/cli-kit': minor +'@shopify/app': minor +'@shopify/store': minor +--- + +Add `shopify app graphiql` and `shopify store graphiql` commands for opening authenticated local Admin API GraphiQL sessions. diff --git a/docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts b/docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts new file mode 100644 index 00000000000..8a650167e23 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts @@ -0,0 +1,66 @@ +// This is an autogenerated file. Don't edit this file manually. +/** + * The following flags are available for the `app graphiql` command: + * @publicDocs + */ +export interface appgraphiql { + /** + * The Client ID of your app. + * @environment SHOPIFY_FLAG_CLIENT_ID + */ + '--client-id '?: string + + /** + * The name of the app configuration. + * @environment SHOPIFY_FLAG_APP_CONFIG + */ + '-c, --config '?: string + + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * The path to your app directory. + * @environment SHOPIFY_FLAG_PATH + */ + '--path '?: string + + /** + * Local port for the GraphiQL server. Must be between 1 and 65535. + * @environment SHOPIFY_FLAG_PORT + */ + '--port '?: string + + /** + * Reset all your settings. + * @environment SHOPIFY_FLAG_RESET + */ + '--reset'?: '' + + /** + * 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. + * @environment SHOPIFY_FLAG_STORE + */ + '-s, --store '?: string + + /** + * The values for any GraphQL variables in your query or mutation, in JSON format. + * @environment SHOPIFY_FLAG_VARIABLES + */ + '-v, --variables '?: string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' + + /** + * The API version to use in GraphiQL. Defaults to the latest stable version. + * @environment SHOPIFY_FLAG_VERSION + */ + '--version '?: string +} diff --git a/docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts b/docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts new file mode 100644 index 00000000000..cb3cf73c449 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts @@ -0,0 +1,48 @@ +// This is an autogenerated file. Don't edit this file manually. +/** + * The following flags are available for the `store graphiql` command: + * @publicDocs + */ +export interface storegraphiql { + /** + * Allow GraphQL mutations to run against the target store. + * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS + */ + '--allow-mutations'?: '' + + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * Local port for the GraphiQL server. Must be between 1 and 65535. + * @environment SHOPIFY_FLAG_PORT + */ + '--port '?: string + + /** + * The myshopify.com domain of the store. + * @environment SHOPIFY_FLAG_STORE + */ + '-s, --store ': string + + /** + * The values for any GraphQL variables in your query or mutation, in JSON format. + * @environment SHOPIFY_FLAG_VARIABLES + */ + '-v, --variables '?: string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' + + /** + * The API version to use in GraphiQL. Defaults to the latest stable version. + * @environment SHOPIFY_FLAG_VERSION + */ + '--version '?: string +} diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 8c4b5fc61fa..493572e967a 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -1822,6 +1822,107 @@ "value": "export interface appgenerateextension {\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 * Choose a starting template for your extension, where applicable\n * @environment SHOPIFY_FLAG_FLAVOR\n */\n '--flavor '?: string\n\n /**\n * name of your Extension\n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Extension template\n * @environment SHOPIFY_FLAG_EXTENSION_TEMPLATE\n */\n '-t, --template '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, + "appgraphiql": { + "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "name": "appgraphiql", + "description": "The following flags are available for the `app graphiql` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--client-id ", + "value": "string", + "description": "The Client ID of your app.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path to your app directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--port ", + "value": "string", + "description": "Local port for the GraphiQL server. Must be between 1 and 65535.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PORT" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--reset", + "value": "''", + "description": "Reset all your settings.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_RESET" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--version ", + "value": "string", + "description": "The API version to use in GraphiQL. Defaults to the latest stable version.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERSION" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --config ", + "value": "string", + "description": "The name of the app configuration.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "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.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-v, --variables ", + "value": "string", + "description": "The values for any GraphQL variables in your query or mutation, in JSON format.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VARIABLES" + } + ], + "value": "export interface appgraphiql {\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 * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\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.\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": { "docs-shopify.dev/commands/interfaces/app-import-custom-data-definitions.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/app-import-custom-data-definitions.interface.ts", @@ -4631,6 +4732,79 @@ "value": "export interface storeexecute {\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 * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: 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.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } }, + "storegraphiql": { + "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts", + "name": "storegraphiql", + "description": "The following flags are available for the `store graphiql` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--allow-mutations", + "value": "''", + "description": "Allow GraphQL mutations to run against the target store.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_ALLOW_MUTATIONS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--port ", + "value": "string", + "description": "Local port for the GraphiQL server. Must be between 1 and 65535.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PORT" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--version ", + "value": "string", + "description": "The API version to use in GraphiQL. Defaults to the latest stable version.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERSION" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "The myshopify.com domain of the store.", + "environmentValue": "SHOPIFY_FLAG_STORE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-graphiql.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-v, --variables ", + "value": "string", + "description": "The values for any GraphQL variables in your query or mutation, in JSON format.", + "isOptional": true, + "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 * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\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.\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": { "docs-shopify.dev/commands/interfaces/store-info.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/store-info.interface.ts", diff --git a/packages/app/src/cli/commands/app/graphiql.test.ts b/packages/app/src/cli/commands/app/graphiql.test.ts new file mode 100644 index 00000000000..356af4f38fb --- /dev/null +++ b/packages/app/src/cli/commands/app/graphiql.test.ts @@ -0,0 +1,72 @@ +import AppGraphiQL from './graphiql.js' +import {openAppGraphiQL} from '../../services/app/graphiql.js' +import {prepareAppStoreContext} from '../../utilities/execute-command-helpers.js' +import { + testAppLinked, + testOrganization, + testOrganizationApp, + testOrganizationStore, + testProject, +} from '../../models/app/app.test-data.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/app/graphiql.js') +vi.mock('../../utilities/execute-command-helpers.js') + +describe('app graphiql command', () => { + const app = testAppLinked() + const remoteApp = testOrganizationApp() + const store = testOrganizationStore({shopDomain: 'shop.myshopify.com'}) + + beforeEach(() => { + vi.mocked(prepareAppStoreContext).mockResolvedValue({ + appContextResult: { + app, + remoteApp, + developerPlatformClient: remoteApp.developerPlatformClient, + organization: testOrganization(), + specifications: [], + project: testProject(), + activeConfig: {} as never, + }, + store, + }) + vi.mocked(openAppGraphiQL).mockResolvedValue() + }) + + test('prepares app/store context and opens GraphiQL', async () => { + const result = await AppGraphiQL.run([ + '--path', + '/tmp/app', + '--client-id', + 'client-id', + '--store', + 'shop', + '--port', + '9123', + '--variables', + '{"id":1}', + '--version', + '2024-10', + ]) + + expect(prepareAppStoreContext).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/tmp/app', + 'client-id': 'client-id', + store: 'shop.myshopify.com', + port: 9123, + variables: '{"id":1}', + version: '2024-10', + }), + ) + expect(openAppGraphiQL).toHaveBeenCalledWith({ + remoteApp, + store: 'shop.myshopify.com', + port: 9123, + variables: '{"id":1}', + apiVersion: '2024-10', + }) + expect(result).toEqual({app}) + }) +}) diff --git a/packages/app/src/cli/commands/app/graphiql.ts b/packages/app/src/cli/commands/app/graphiql.ts new file mode 100644 index 00000000000..e3bfdd9483b --- /dev/null +++ b/packages/app/src/cli/commands/app/graphiql.ts @@ -0,0 +1,62 @@ +import {appFlags} from '../../flags.js' +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 {Flags} from '@oclif/core' + +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.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --port 9123', + ] + + static flags = { + ...globalFlags, + ...appFlags, + store: Flags.string({ + char: 's', + description: + '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.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + }), + port: portFlag({ + description: 'Local port for the GraphiQL server.', + env: 'SHOPIFY_FLAG_PORT', + }), + variables: Flags.string({ + char: 'v', + description: 'The values for any GraphQL variables in your query or mutation, in JSON format.', + env: 'SHOPIFY_FLAG_VARIABLES', + }), + version: Flags.string({ + description: 'The API version to use in GraphiQL. Defaults to the latest stable version.', + env: 'SHOPIFY_FLAG_VERSION', + }), + } + + 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 {app: appContextResult.app} + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index f9d51c1261c..5bc4a14e67f 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -13,6 +13,7 @@ import EnvPull from './commands/app/env/pull.js' import EnvShow from './commands/app/env/show.js' import BulkExecute from './commands/app/bulk/execute.js' import Execute from './commands/app/execute.js' +import AppGraphiQL from './commands/app/graphiql.js' import FunctionBuild from './commands/app/function/build.js' import FunctionReplay from './commands/app/function/replay.js' import FunctionRun from './commands/app/function/run.js' @@ -62,6 +63,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:env:pull': EnvPull, 'app:env:show': EnvShow, 'app:execute': Execute, + 'app:graphiql': AppGraphiQL, 'app:bulk:execute': BulkExecute, 'app:function:build': FunctionBuild, 'app:function:replay': FunctionReplay, diff --git a/packages/app/src/cli/services/app/graphiql.test.ts b/packages/app/src/cli/services/app/graphiql.test.ts new file mode 100644 index 00000000000..0458579af07 --- /dev/null +++ b/packages/app/src/cli/services/app/graphiql.test.ts @@ -0,0 +1,83 @@ +import {openAppGraphiQL} from './graphiql.js' +import {createClientCredentialsTokenProvider} from '../dev/processes/graphiql-token-provider.js' +import {testOrganizationApp} from '../../models/app/app.test-data.js' +import {AbortController} from '@shopify/cli-kit/node/abort' +import {resolveGraphiQLKey} from '@shopify/cli-kit/node/graphiql/server' +import {runGraphiQLSession} from '@shopify/cli-kit/node/graphiql/session' +import {adminFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../dev/processes/graphiql-token-provider.js') +vi.mock('@shopify/cli-kit/node/graphiql/session') +vi.mock('@shopify/cli-kit/node/context/fqdn', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + adminFqdn: vi.fn(), + } +}) + +const mockedCreateTokenProvider = vi.mocked(createClientCredentialsTokenProvider) +const mockedRunGraphiQLSession = vi.mocked(runGraphiQLSession) +const mockedAdminFqdn = vi.mocked(adminFqdn) + +describe('openAppGraphiQL', () => { + beforeEach(() => { + mockedRunGraphiQLSession.mockResolvedValue() + mockedAdminFqdn.mockResolvedValue('admin.shopify.com') + mockedCreateTokenProvider.mockReturnValue({ + getToken: async () => 'client-credentials-token', + }) + }) + + test('starts GraphiQL with app context and a client credentials token provider', async () => { + const abortSignal = new AbortController().signal + const remoteApp = testOrganizationApp({apiKey: 'api-key', title: 'Test App', apiSecretKeys: [{secret: 'secret'}]}) + + await openAppGraphiQL({ + remoteApp, + store: 'shop.myshopify.com', + port: 4567, + abortSignal, + }) + + const key = resolveGraphiQLKey(undefined, 'secret', 'shop.myshopify.com') + expect(mockedCreateTokenProvider).toHaveBeenCalledWith({ + apiKey: 'api-key', + apiSecret: 'secret', + storeFqdn: 'shop.myshopify.com', + }) + expect(mockedRunGraphiQLSession).toHaveBeenCalledWith( + expect.objectContaining({ + port: 4567, + storeFqdn: 'shop.myshopify.com', + tokenProvider: {getToken: expect.any(Function)}, + key, + appContext: { + appName: 'Test App', + appUrl: 'https://admin.shopify.com/store/shop/apps/api-key?dev-console=show', + apiSecret: 'secret', + }, + abortSignal, + }), + ) + }) + + test('passes optional GraphiQL URL parameters to the shared session runner', async () => { + const remoteApp = testOrganizationApp({apiSecretKeys: [{secret: 'secret'}]}) + + await openAppGraphiQL({ + remoteApp, + store: 'shop.myshopify.com', + variables: '{"id":1}', + apiVersion: '2024-10', + }) + + expect(mockedRunGraphiQLSession).toHaveBeenCalledWith( + expect.objectContaining({ + variables: '{"id":1}', + apiVersion: '2024-10', + }), + ) + }) +}) diff --git a/packages/app/src/cli/services/app/graphiql.ts b/packages/app/src/cli/services/app/graphiql.ts new file mode 100644 index 00000000000..9389b778704 --- /dev/null +++ b/packages/app/src/cli/services/app/graphiql.ts @@ -0,0 +1,48 @@ +import {createClientCredentialsTokenProvider} from '../dev/processes/graphiql-token-provider.js' +import {OrganizationApp} from '../../models/organization.js' +import {buildAppURLForAdmin} from '../../utilities/app/app-url.js' +import {resolveGraphiQLKey} from '@shopify/cli-kit/node/graphiql/server' +import {runGraphiQLSession} from '@shopify/cli-kit/node/graphiql/session' +import {AbortSignal} from '@shopify/cli-kit/node/abort' +import {adminFqdn} from '@shopify/cli-kit/node/context/fqdn' + +interface OpenAppGraphiQLOptions { + remoteApp: OrganizationApp + store: string + port?: number + variables?: string + apiVersion?: string + /** + * Test-only seam: aborts the server-running loop without requiring a real SIGINT. + * In production, the command itself listens for SIGINT and exits. + */ + abortSignal?: AbortSignal +} + +export async function openAppGraphiQL(options: OpenAppGraphiQLOptions): Promise { + const apiSecret = options.remoteApp.apiSecretKeys[0]?.secret ?? '' + const key = resolveGraphiQLKey(undefined, apiSecret, options.store) + const tokenProvider = createClientCredentialsTokenProvider({ + apiKey: options.remoteApp.apiKey, + apiSecret, + storeFqdn: options.store, + }) + + const adminDomain = await adminFqdn() + const appUrl = buildAppURLForAdmin(options.store, options.remoteApp.apiKey, adminDomain) + + await runGraphiQLSession({ + port: options.port, + storeFqdn: options.store, + tokenProvider, + key, + appContext: { + appName: options.remoteApp.title, + appUrl, + apiSecret, + }, + variables: options.variables, + apiVersion: options.apiVersion, + abortSignal: options.abortSignal, + }) +} diff --git a/packages/cli-kit/src/public/node/graphiql/session.test.ts b/packages/cli-kit/src/public/node/graphiql/session.test.ts new file mode 100644 index 00000000000..36c2444ae5d --- /dev/null +++ b/packages/cli-kit/src/public/node/graphiql/session.test.ts @@ -0,0 +1,128 @@ +import {buildGraphiQLUrl, generateRandomGraphiQLKey, runGraphiQLSession, waitForGraphiQLAbort} from './session.js' +import {setupGraphiQLServer} from './server.js' +import {AbortController} from '../abort.js' +import {openURL} from '../system.js' +import {getAvailableTCPPort} from '../tcp.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./server.js', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + setupGraphiQLServer: vi.fn(), + } +}) +vi.mock('../tcp.js') +vi.mock('../system.js') + +const mockedSetupGraphiQLServer = vi.mocked(setupGraphiQLServer) +const mockedGetAvailableTCPPort = vi.mocked(getAvailableTCPPort) +const mockedOpenURL = vi.mocked(openURL) + +function abortAfter(controller: AbortController) { + setImmediate(() => controller.abort()) + return controller.signal +} + +describe('buildGraphiQLUrl', () => { + test('builds a keyed local GraphiQL URL with optional prefilled fields', () => { + const url = buildGraphiQLUrl({ + port: 4567, + key: 'key', + query: 'query { shop { name } }', + variables: '{"id":1}', + apiVersion: '2024-10', + }) + + const parsed = new URL(url) + expect(parsed.origin).toBe('http://localhost:4567') + expect(parsed.pathname).toBe('/graphiql') + expect(parsed.searchParams.get('key')).toBe('key') + expect(parsed.searchParams.get('query')).toBe('query { shop { name } }') + expect(parsed.searchParams.get('variables')).toBe('{"id":1}') + expect(parsed.searchParams.get('api_version')).toBe('2024-10') + }) +}) + +describe('generateRandomGraphiQLKey', () => { + test('generates a 64-character hexadecimal key', () => { + expect(generateRandomGraphiQLKey()).toMatch(/^[0-9a-f]{64}$/) + }) +}) + +describe('runGraphiQLSession', () => { + beforeEach(() => { + mockedGetAvailableTCPPort.mockResolvedValue(4567) + mockedOpenURL.mockResolvedValue(true) + mockedSetupGraphiQLServer.mockReturnValue({close: vi.fn()} as unknown as ReturnType) + }) + + test('starts the server, opens the keyed URL, waits for abort, and closes the server', async () => { + const controller = new AbortController() + const tokenProvider = {getToken: async () => 'token'} + const server = {close: vi.fn()} + mockedSetupGraphiQLServer.mockReturnValueOnce(server as unknown as ReturnType) + + 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), + }) + + 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('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() + }) +}) + +describe('waitForGraphiQLAbort', () => { + test('resolves immediately when the external signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + await expect(waitForGraphiQLAbort(controller.signal)).resolves.toBeUndefined() + }) +}) diff --git a/packages/cli-kit/src/public/node/graphiql/session.ts b/packages/cli-kit/src/public/node/graphiql/session.ts new file mode 100644 index 00000000000..7c700b85c17 --- /dev/null +++ b/packages/cli-kit/src/public/node/graphiql/session.ts @@ -0,0 +1,120 @@ +import {GraphiQLAppContext, setupGraphiQLServer, TokenProvider} from './server.js' +import {AbortController, AbortSignal} from '../abort.js' +import {OutputMessage, outputContent, outputInfo, outputToken, outputWarn} from '../output.js' +import {openURL} from '../system.js' +import {getAvailableTCPPort} from '../tcp.js' +import {randomBytes} from 'crypto' +import {Writable} from 'stream' + +export interface BuildGraphiQLUrlOptions { + port: number + key: string + query?: string + variables?: string + apiVersion?: string +} + +export interface RunGraphiQLSessionOptions { + storeFqdn: string + tokenProvider: TokenProvider + key: string + port?: number + query?: string + variables?: string + apiVersion?: string + appContext?: GraphiQLAppContext + protectMutations?: boolean + additionalReadyMessages?: OutputMessage[] + abortSignal?: AbortSignal + stdout?: Writable +} + +/** + * Generates a random key suitable for authenticating a single local GraphiQL session. + * + * @returns A 64-character hex string. + */ +export function generateRandomGraphiQLKey(): string { + return randomBytes(32).toString('hex') +} + +/** + * Builds the browser URL for a local GraphiQL session. + * + * @param options - The local port, key, and optional query string fields to prefill. + * @returns The fully-qualified local GraphiQL URL. + */ +export function buildGraphiQLUrl(options: BuildGraphiQLUrlOptions): string { + const url = new URL(`http://localhost:${options.port}/graphiql`) + url.searchParams.set('key', options.key) + if (options.query) url.searchParams.set('query', options.query) + if (options.variables) url.searchParams.set('variables', options.variables) + if (options.apiVersion) url.searchParams.set('api_version', options.apiVersion) + return url.toString() +} + +/** + * Runs a local GraphiQL session until the user interrupts it or the supplied abort signal fires. + * + * @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, + }) + + try { + outputInfo(outputContent`GraphiQL is running at ${outputToken.link(url)}`) + options.additionalReadyMessages?.forEach((message) => outputInfo(message)) + outputInfo('Press Ctrl+C to stop.') + + const opened = await openURL(url) + if (!opened) { + outputWarn('Browser did not open automatically. Open the URL above manually.') + } + + await waitForGraphiQLAbort(options.abortSignal) + } finally { + server.close() + } +} + +/** + * Resolves when the process receives SIGINT or when the supplied abort signal fires. + * + * @param externalSignal - Optional signal used by tests or callers to stop the session. + */ +export async function waitForGraphiQLAbort(externalSignal?: AbortSignal): Promise { + const controller = new AbortController() + + const onSigint = () => controller.abort() + process.once('SIGINT', onSigint) + + try { + await new Promise((resolve) => { + if (controller.signal.aborted || externalSignal?.aborted) { + resolve() + return + } + controller.signal.addEventListener('abort', () => resolve(), {once: true}) + externalSignal?.addEventListener('abort', () => controller.abort(), {once: true}) + }) + } finally { + process.removeListener('SIGINT', onSigint) + } +} diff --git a/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx b/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx index 8fc098ec9b8..36ee7cbedb4 100644 --- a/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx +++ b/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx @@ -231,9 +231,7 @@ export function graphiqlTemplate({
{}} icon={DisabledIcon}> -

- The server has been stopped. Restart dev from the CLI. -

+

The server has been stopped. Restart it from the CLI.

diff --git a/packages/cli/README.md b/packages/cli/README.md index d7ea5e809c4..4761088c8cc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -21,6 +21,7 @@ * [`shopify app function schema`](#shopify-app-function-schema) * [`shopify app function typegen`](#shopify-app-function-typegen) * [`shopify app generate extension`](#shopify-app-generate-extension) +* [`shopify app graphiql`](#shopify-app-graphiql) * [`shopify app import-custom-data-definitions`](#shopify-app-import-custom-data-definitions) * [`shopify app import-extensions`](#shopify-app-import-extensions) * [`shopify app info`](#shopify-app-info) @@ -83,6 +84,7 @@ * [`shopify store bulk execute`](#shopify-store-bulk-execute) * [`shopify store bulk status`](#shopify-store-bulk-status) * [`shopify store execute`](#shopify-store-execute) +* [`shopify store graphiql`](#shopify-store-graphiql) * [`shopify store info`](#shopify-store-info) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) @@ -755,6 +757,43 @@ DESCRIPTION your extension. ``` +## `shopify app graphiql` + +Open a local GraphiQL UI for your app and store. + +``` +USAGE + $ shopify app graphiql [--client-id | -c ] [--no-color] [--path ] [--port ] + [--reset | ] [-s ] [-v ] [--verbose] [--version ] + +FLAGS + -c, --config= [env: SHOPIFY_FLAG_APP_CONFIG] The name of the app configuration. + -s, --store= [env: SHOPIFY_FLAG_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. + -v, --variables= [env: SHOPIFY_FLAG_VARIABLES] The values for any GraphQL variables in your query or mutation, + in JSON format. + --client-id= [env: SHOPIFY_FLAG_CLIENT_ID] The Client ID of your app. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --path= [env: SHOPIFY_FLAG_PATH] The path to your app directory. + --port= [env: SHOPIFY_FLAG_PORT] Local port for the GraphiQL server. Must be between 1 and 65535. + --reset [env: SHOPIFY_FLAG_RESET] Reset all your settings. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + --version= [env: SHOPIFY_FLAG_VERSION] The API version to use in GraphiQL. Defaults to the latest stable + version. + +DESCRIPTION + Open a local GraphiQL UI for your app and store. + + Opens an authenticated Admin API GraphiQL UI for your app and selected store. + + The app must be installed on the store. + +EXAMPLES + $ shopify app graphiql --store shop.myshopify.com + + $ shopify app graphiql --store shop.myshopify.com --port 9123 +``` + ## `shopify app import-custom-data-definitions` Import metafield and metaobject definitions. @@ -2365,6 +2404,43 @@ EXAMPLES $ shopify store execute --store shop.myshopify.com --query "query { shop { name } }" --json ``` +## `shopify store graphiql` + +Open a local GraphiQL UI for a store. + +``` +USAGE + $ shopify store graphiql -s [--allow-mutations] [--no-color] [--port ] [-v ] [--verbose] + [--version ] + +FLAGS + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store. + -v, --variables= [env: SHOPIFY_FLAG_VARIABLES] The values for any GraphQL variables in your query or mutation, + in JSON format. + --allow-mutations [env: SHOPIFY_FLAG_ALLOW_MUTATIONS] Allow GraphQL mutations to run against the target store. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --port= [env: SHOPIFY_FLAG_PORT] Local port for the GraphiQL server. Must be between 1 and 65535. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + --version= [env: SHOPIFY_FLAG_VERSION] The API version to use in GraphiQL. Defaults to the latest stable + version. + +DESCRIPTION + Open a local GraphiQL UI for a store. + + Opens an authenticated Admin API GraphiQL UI for the specified store using previously stored app authentication. + + Run `shopify store auth` first to create stored auth for the store. + + Mutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data. + +EXAMPLES + $ shopify store graphiql --store shop.myshopify.com + + $ shopify store graphiql --store shop.myshopify.com --allow-mutations + + $ shopify store graphiql --store shop.myshopify.com --port 9123 +``` + ## `shopify store info` Surface metadata about a Shopify store. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 91f7b4d5b0e..c466479b61b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2154,6 +2154,122 @@ "strict": true, "summary": "Generate a new app Extension." }, + "app:graphiql": { + "aliases": [ + ], + "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.", + "examples": [ + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --port 9123" + ], + "flags": { + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "port": { + "description": "Local port for the GraphiQL server. Must be between 1 and 65535.", + "env": "SHOPIFY_FLAG_PORT", + "hasDynamicHelp": false, + "multiple": false, + "name": "port", + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "store": { + "char": "s", + "description": "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.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "type": "option" + }, + "variables": { + "char": "v", + "description": "The values for any GraphQL variables in your query or mutation, in JSON format.", + "env": "SHOPIFY_FLAG_VARIABLES", + "hasDynamicHelp": false, + "multiple": false, + "name": "variables", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + }, + "version": { + "description": "The API version to use in GraphiQL. Defaults to the latest stable version.", + "env": "SHOPIFY_FLAG_VERSION", + "hasDynamicHelp": false, + "multiple": false, + "name": "version", + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "app:graphiql", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Open a local GraphiQL UI for your app and store." + }, "app:import-custom-data-definitions": { "aliases": [ ], @@ -6476,6 +6592,89 @@ "strict": true, "summary": "Execute GraphQL queries and mutations on a store." }, + "store:graphiql": { + "aliases": [ + ], + "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.", + "examples": [ + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --allow-mutations", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --port 9123" + ], + "flags": { + "allow-mutations": { + "allowNo": false, + "description": "Allow GraphQL mutations to run against the target store.", + "env": "SHOPIFY_FLAG_ALLOW_MUTATIONS", + "name": "allow-mutations", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "port": { + "description": "Local port for the GraphiQL server. Must be between 1 and 65535.", + "env": "SHOPIFY_FLAG_PORT", + "hasDynamicHelp": false, + "multiple": false, + "name": "port", + "type": "option" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "required": true, + "type": "option" + }, + "variables": { + "char": "v", + "description": "The values for any GraphQL variables in your query or mutation, in JSON format.", + "env": "SHOPIFY_FLAG_VARIABLES", + "hasDynamicHelp": false, + "multiple": false, + "name": "variables", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + }, + "version": { + "description": "The API version to use in GraphiQL. Defaults to the latest stable version.", + "env": "SHOPIFY_FLAG_VERSION", + "hasDynamicHelp": false, + "multiple": false, + "name": "version", + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "store:graphiql", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Open a local GraphiQL UI for a store." + }, "store:info": { "aliases": [ ], diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 6ef0b09347c..c44b5255cda 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -25,6 +25,7 @@ │ │ └─ typegen │ ├─ generate │ │ └─ extension +│ ├─ graphiql │ ├─ import-custom-data-definitions │ ├─ import-extensions │ ├─ info @@ -102,6 +103,7 @@ │ │ ├─ execute │ │ └─ status │ ├─ execute +│ ├─ graphiql │ └─ info ├─ theme │ ├─ check diff --git a/packages/store/src/cli/commands/store/execute.test.ts b/packages/store/src/cli/commands/store/execute.test.ts index 18718299627..782df2c84e7 100644 --- a/packages/store/src/cli/commands/store/execute.test.ts +++ b/packages/store/src/cli/commands/store/execute.test.ts @@ -54,4 +54,11 @@ describe('store execute command', () => { expect(StoreExecute.flags['allow-mutations']).toBeDefined() expect(StoreExecute.flags.json).toBeDefined() }) + + test('requires --query or --query-file', async () => { + await expect(StoreExecute.run(['--store', 'shop.myshopify.com'])).rejects.toThrow() + + expect(executeStoreOperation).not.toHaveBeenCalled() + expect(writeOrOutputStoreExecuteResult).not.toHaveBeenCalled() + }) }) diff --git a/packages/store/src/cli/commands/store/graphiql.test.ts b/packages/store/src/cli/commands/store/graphiql.test.ts new file mode 100644 index 00000000000..af1a480e21d --- /dev/null +++ b/packages/store/src/cli/commands/store/graphiql.test.ts @@ -0,0 +1,46 @@ +import StoreGraphiQL from './graphiql.js' +import {openStoreGraphiQL} from '../../services/store/graphiql.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/store/graphiql.js', () => ({openStoreGraphiQL: vi.fn()})) +vi.mock('../../services/store/attribution.js') + +describe('store graphiql command', () => { + beforeEach(() => { + vi.mocked(openStoreGraphiQL).mockResolvedValue() + }) + + test('opens GraphiQL with mutations disabled by default', async () => { + await StoreGraphiQL.run(['--store', 'shop.myshopify.com']) + + expect(openStoreGraphiQL).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + port: undefined, + allowMutations: false, + variables: undefined, + apiVersion: undefined, + }) + }) + + test('forwards optional GraphiQL configuration', async () => { + await StoreGraphiQL.run([ + '--store', + 'shop.myshopify.com', + '--port', + '9123', + '--allow-mutations', + '--variables', + '{"id":"gid://shopify/Product/1"}', + '--version', + '2024-10', + ]) + + expect(openStoreGraphiQL).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + port: 9123, + allowMutations: true, + variables: '{"id":"gid://shopify/Product/1"}', + apiVersion: '2024-10', + }) + }) +}) diff --git a/packages/store/src/cli/commands/store/graphiql.ts b/packages/store/src/cli/commands/store/graphiql.ts new file mode 100644 index 00000000000..7351305ec4e --- /dev/null +++ b/packages/store/src/cli/commands/store/graphiql.ts @@ -0,0 +1,58 @@ +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 {Flags} from '@oclif/core' + +export default class StoreGraphiQL extends StoreCommand { + static summary = 'Open a local GraphiQL UI for a store.' + + static descriptionWithMarkdown = `Opens an authenticated Admin API GraphiQL UI for the specified store using previously stored app authentication. + +Run \`shopify store auth\` first to create stored auth for the store. + +Mutations are disabled by default. Re-run with \`--allow-mutations\` if you intend to modify store data.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --allow-mutations', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --port 9123', + ] + + static flags = { + ...globalFlags, + store: storeFlags.store, + port: portFlag({ + description: 'Local port for the GraphiQL server.', + env: 'SHOPIFY_FLAG_PORT', + }), + 'allow-mutations': Flags.boolean({ + description: 'Allow GraphQL mutations to run against the target store.', + env: 'SHOPIFY_FLAG_ALLOW_MUTATIONS', + default: false, + }), + variables: Flags.string({ + char: 'v', + description: 'The values for any GraphQL variables in your query or mutation, in JSON format.', + env: 'SHOPIFY_FLAG_VARIABLES', + }), + version: Flags.string({ + description: 'The API version to use in GraphiQL. Defaults to the latest stable version.', + env: 'SHOPIFY_FLAG_VERSION', + }), + } + + 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, + }) + } +} diff --git a/packages/store/src/cli/services/store/graphiql.test.ts b/packages/store/src/cli/services/store/graphiql.test.ts new file mode 100644 index 00000000000..8995cf31f97 --- /dev/null +++ b/packages/store/src/cli/services/store/graphiql.test.ts @@ -0,0 +1,95 @@ +import {openStoreGraphiQL} from './graphiql.js' +import {loadStoredStoreSession} from './auth/session-lifecycle.js' +import {AbortController} from '@shopify/cli-kit/node/abort' +import {generateRandomGraphiQLKey, runGraphiQLSession} from '@shopify/cli-kit/node/graphiql/session' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/graphiql/session') +vi.mock('./auth/session-lifecycle.js') + +const mockedRunGraphiQLSession = vi.mocked(runGraphiQLSession) +const mockedGenerateRandomGraphiQLKey = vi.mocked(generateRandomGraphiQLKey) +const mockedLoadSession = vi.mocked(loadStoredStoreSession) + +describe('openStoreGraphiQL', () => { + beforeEach(() => { + mockedRunGraphiQLSession.mockResolvedValue() + mockedGenerateRandomGraphiQLKey.mockReturnValue('generated-key') + mockedLoadSession.mockResolvedValue({ + store: 'shop.myshopify.com', + accessToken: 'stored-token', + } as unknown as Awaited>) + }) + + test('forwards configuration to the shared GraphiQL session runner', async () => { + const abortSignal = new AbortController().signal + + await openStoreGraphiQL({ + store: 'shop.myshopify.com', + port: 4567, + allowMutations: false, + abortSignal, + }) + + expect(mockedRunGraphiQLSession).toHaveBeenCalledWith( + expect.objectContaining({ + port: 4567, + storeFqdn: 'shop.myshopify.com', + key: 'generated-key', + protectMutations: true, + abortSignal, + }), + ) + }) + + test('protectMutations follows --allow-mutations', async () => { + await openStoreGraphiQL({ + store: 'shop.myshopify.com', + allowMutations: true, + }) + + expect(mockedRunGraphiQLSession).toHaveBeenCalledWith( + expect.objectContaining({ + protectMutations: false, + }), + ) + }) + + test('uses a TokenProvider backed by loadStoredStoreSession', async () => { + await openStoreGraphiQL({ + store: 'shop.myshopify.com', + }) + + const tokenProvider = mockedRunGraphiQLSession.mock.calls[0]![0].tokenProvider + await expect(tokenProvider.getToken()).resolves.toBe('stored-token') + expect(mockedLoadSession).toHaveBeenCalledWith('shop.myshopify.com') + }) + + test('passes prefilled query, variables, and apiVersion to the shared session runner', async () => { + await openStoreGraphiQL({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + variables: '{"id":1}', + apiVersion: '2024-10', + }) + + expect(mockedRunGraphiQLSession).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'query { shop { name } }', + variables: '{"id":1}', + apiVersion: '2024-10', + }), + ) + }) + + test('passes mutation status as an additional ready message', async () => { + await openStoreGraphiQL({ + store: 'shop.myshopify.com', + allowMutations: true, + }) + + expect(mockedRunGraphiQLSession.mock.calls[0]![0].additionalReadyMessages?.[0]).toMatchObject({ + value: expect.stringContaining('allowed'), + }) + }) +}) diff --git a/packages/store/src/cli/services/store/graphiql.ts b/packages/store/src/cli/services/store/graphiql.ts new file mode 100644 index 00000000000..1167e29b848 --- /dev/null +++ b/packages/store/src/cli/services/store/graphiql.ts @@ -0,0 +1,52 @@ +import {loadStoredStoreSession} from './auth/session-lifecycle.js' +import {TokenProvider} from '@shopify/cli-kit/node/graphiql/server' +import {generateRandomGraphiQLKey, runGraphiQLSession} from '@shopify/cli-kit/node/graphiql/session' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {AbortSignal} from '@shopify/cli-kit/node/abort' + +interface OpenStoreGraphiQLOptions { + store: string + port?: number + allowMutations?: boolean + query?: string + variables?: string + apiVersion?: string + /** + * Test-only seam: aborts the server-running loop without requiring a real SIGINT. + * In production, the command itself listens for SIGINT and exits. + */ + abortSignal?: AbortSignal +} + +/** + * Spins up a GraphiQL server pointed at `store` using credentials previously stored + * by `shopify store auth`, prints the URL, opens the browser, and waits for the + * process to be aborted (Ctrl+C) before shutting down. + */ +export async function openStoreGraphiQL(options: OpenStoreGraphiQLOptions): Promise { + const tokenProvider = createStoredSessionTokenProvider(options.store) + + const key = generateRandomGraphiQLKey() + + await runGraphiQLSession({ + port: options.port, + storeFqdn: options.store, + tokenProvider, + key, + protectMutations: !options.allowMutations, + query: options.query, + variables: options.variables, + apiVersion: options.apiVersion, + additionalReadyMessages: [ + outputContent`Mutations are ${options.allowMutations ? outputToken.green('allowed') : outputToken.yellow('blocked')}.`, + ], + abortSignal: options.abortSignal, + }) +} + +function createStoredSessionTokenProvider(store: string): TokenProvider { + return { + getToken: async () => (await loadStoredStoreSession(store)).accessToken, + refreshToken: async () => (await loadStoredStoreSession(store)).accessToken, + } +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 3d91e134b84..497c8c27196 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -6,6 +6,7 @@ import StoreBulkStatus from './cli/commands/store/bulk/status.js' import StoreCreateDev from './cli/commands/store/create/dev.js' import StoreCreatePreview from './cli/commands/store/create/preview.js' import StoreExecute from './cli/commands/store/execute.js' +import StoreGraphiQL from './cli/commands/store/graphiql.js' import StoreInfo from './cli/commands/store/info.js' import StoreList from './cli/commands/store/list.js' import StoreOpen from './cli/commands/store/open.js' @@ -21,6 +22,7 @@ const COMMANDS = { 'store:create:dev': StoreCreateDev, 'store:create:preview': StoreCreatePreview, 'store:execute': StoreExecute, + 'store:graphiql': StoreGraphiQL, 'store:info': StoreInfo, 'store:list': StoreList, 'store:open': StoreOpen,