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
6 changes: 6 additions & 0 deletions .changeset/app-execute-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/app': minor
'@shopify/cli': minor
---

Add typed JSON output to `app execute` with GraphQL error details and file receipts.
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,15 @@
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-execute.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-execute.interface.ts",
"syntaxKind": "PropertySignature",
Expand Down Expand Up @@ -1715,7 +1724,7 @@
"environmentValue": "SHOPIFY_FLAG_VARIABLES"
}
],
"value": "export interface appexecute {\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 file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file <value>'?: string\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query <value>'?: 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 <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 execute 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 * 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 <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 for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version <value>'?: string\n}"
"value": "export interface appexecute {\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 file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file <value>'?: string\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query <value>'?: 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 <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 execute 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 * 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 <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 for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version <value>'?: string\n}"
}
},
"appfunctionbuild": {
Expand Down
151 changes: 151 additions & 0 deletions packages/app/src/cli/commands/app/execute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import Execute from './execute.js'
import {prepareExecuteContext} from '../../utilities/execute-command-helpers.js'
import {createAdminSessionAsApp, resolveApiVersion} from '../../services/graphql/common.js'
import {
testAppLinked,
testOrganization,
testOrganizationApp,
testOrganizationStore,
testProject,
} from '../../models/app/app.test-data.js'
import {Config} from '@oclif/core'
import {afterEach, beforeEach, expect, test, vi} from 'vitest'
import {ClientError} from 'graphql-request'
// eslint-disable-next-line @shopify/typescript-prefer-build-client-schema -- Local execution fixture without an introspection response.
import {GraphQLError, buildSchema, graphql} from 'graphql'
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
import {handler} from '@shopify/cli-kit/node/error'
import {unstyled} from '@shopify/cli-kit/node/output'
import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {joinPath} from '@shopify/cli-kit/node/path'
import * as ui from '@shopify/cli-kit/node/ui'

vi.mock('../../utilities/execute-command-helpers.js')
vi.mock('../../services/graphql/common.js')
vi.mock('@shopify/cli-kit/node/api/admin')

const query = 'query { shop { name } }'
const app = testAppLinked()

beforeEach(() => {
const remoteApp = testOrganizationApp()
vi.mocked(prepareExecuteContext).mockResolvedValue({
query,
appContextResult: {
app,
remoteApp,
organization: testOrganization(),
developerPlatformClient: remoteApp.developerPlatformClient,
project: testProject(),
specifications: [],
activeConfig: {} as never,
},
store: testOrganizationStore({shopDomain: 'shop.myshopify.com'}),
})
vi.mocked(createAdminSessionAsApp).mockResolvedValue({token: 'test-token', storeFqdn: 'shop.myshopify.com'})
vi.mocked(resolveApiVersion).mockResolvedValue('2026-10')
})

afterEach(() => {
vi.unstubAllEnvs()
})

async function runCommand(flags: string[]) {
const argv = ['--query', query, '--store', 'shop.myshopify.com', ...flags]
const command = new Execute(argv, await Config.load())
const textTask = flags.includes('--json')
? undefined
: vi.spyOn(ui, 'renderSingleTask').mockImplementation(async ({task}) => task(() => {}))
try {
return await runWithCommandEventsForCommand(argv, () => command.run())
} finally {
textTask?.mockRestore()
}
}

test('preserves native GraphQL aliases, order, and UTF-8 on stdout and in output files', async () => {
const response = await graphql({
schema: buildSchema('type Query { name: String! nested: Query }'),
source: 'query { last: name __proto__: name constructor: name nested { __proto__: name constructor: name } }',
rootValue: {name: 'Café', nested: {name: 'Nested'}},
})
expect(response.errors).toBeUndefined()
const extensions = JSON.parse('{"__proto__":{"trace":"preserved"},"constructor":false,"nullable":null,"empty":[]}')
vi.mocked(adminRequestDoc).mockImplementation(async ({responseOptions}) => {
responseOptions?.onResponse?.({data: response.data, extensions, status: 200, headers: new Headers()})
return response.data
})
const json = JSON.stringify({data: response.data, extensions}, null, 2)
const text = JSON.stringify(response.data, null, 2)

await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand(['--json'])
expect(stdout()).toBe(`${json}\n`)
expect(
stderr()
.trim()
.split('\n')
.map((line) => JSON.parse(line)),
).toEqual([
expect.objectContaining({type: 'progress', status: 'started', message: 'Authenticating'}),
expect.objectContaining({type: 'progress', status: 'completed', message: 'Authenticating'}),
expect.objectContaining({type: 'progress', status: 'started', message: 'Executing GraphQL operation'}),
expect.objectContaining({type: 'progress', status: 'completed', message: 'Executing GraphQL operation'}),
])
})
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand([])
expect(stdout()).toBe(`${text}\n`)
expect(stderr()).toContain('Operation succeeded.')
})
await inTemporaryDirectory(async (directory) => {
const path = joinPath(directory, 'result.json')
await withCapturedStandardStreams(async ({stdout}) => {
await runCommand(['--json', '--output-file', path])
expect(JSON.parse(stdout())).toEqual({path, format: 'json'})
})
await expect(readFile(path)).resolves.toBe(json)
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand(['--output-file', path])
expect(stdout()).toBe('')
expect(stderr()).toContain('Operation succeeded.')
expect(unstyled(stderr()).replace(/[│\s]/g, '')).toContain(`Resultswrittento${path}`)
})
await expect(readFile(path)).resolves.toBe(text)
})
})

test('uses one shared fatal error document and preserves an existing output file', async () => {
await inTemporaryDirectory(async (directory) => {
const outputFile = joinPath(directory, 'result.json')
await writeFile(outputFile, 'existing result')
const details = {
errors: [new GraphQLError('Denied', {extensions: {code: 'ACCESS_DENIED'}})],
data: {shop: null},
extensions: {cost: {actualQueryCost: 1}},
}
vi.mocked(adminRequestDoc).mockRejectedValue(new ClientError({...details, status: 200}, {query}))
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand(['--json', '--output-file', outputFile]).catch(handler)
expect(JSON.parse(stdout())).toEqual({
error: {type: 'abort', message: 'GraphQL operation failed.', details: JSON.parse(JSON.stringify(details))},
})
expect(stderr()).not.toContain('GraphQL operation failed.')
})
await expect(readFile(outputFile)).resolves.toBe('existing result')
})
})

test('preserves the text error banner without a fatal error for a GraphQL failure', async () => {
const errors = [new GraphQLError('Field invalidField does not exist')]
vi.mocked(adminRequestDoc).mockRejectedValue(new ClientError({errors, status: 200}, {query}))
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await expect(runCommand([])).resolves.toEqual({app})
expect(stdout()).toBe('')
expect(stderr()).toContain('GraphQL operation failed.')
expect(stderr()).toContain('invalidField')
})
})
13 changes: 10 additions & 3 deletions packages/app/src/cli/commands/app/execute.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {appFlags, operationFlags} from '../../flags.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js'
import {executeOperation} from '../../services/execute-operation.js'
import {appExecuteJsonOutputSchema} from '../../services/execute-operation/types.js'
import {renderExecuteOperationResult} from '../../services/execute-operation/result.js'
import {prepareExecuteContext} from '../../utilities/execute-command-helpers.js'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'

export default class Execute extends AppLinkedCommand {
static summary = 'Execute GraphQL queries and mutations.'
Expand All @@ -11,29 +13,34 @@ export default class Execute extends AppLinkedCommand {

For operations that process large amounts of data, use [\`bulk execute\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) instead.`

static get jsonOutputSchema() {
return appExecuteJsonOutputSchema
}

static description = this.descriptionForHelp()

static flags = {
...globalFlags,
...appFlags,
...operationFlags,
...jsonFlag,
}

async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(Execute)

const {query, appContextResult, store} = await prepareExecuteContext(flags)

await executeOperation({
const result = await executeOperation({
organization: appContextResult.organization,
remoteApp: appContextResult.remoteApp,
store,
query,
variables: flags.variables,
variableFile: flags['variable-file'],
outputFile: flags['output-file'],
...(flags.version && {version: flags.version}),
})
await renderExecuteOperationResult(result, flags.json ? 'json' : 'text', flags['output-file'])

return {app: appContextResult.app}
}
Expand Down
Loading
Loading