Skip to content

Commit caadc8b

Browse files
authored
Merge pull request #8814 from Shopify/codex/json-app-execute
Add typed JSON output to app execute
2 parents 5d5a975 + 85f5cf1 commit caadc8b

12 files changed

Lines changed: 409 additions & 130 deletions

File tree

‎.changeset/app-execute-json.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/app': minor
3+
'@shopify/cli': minor
4+
---
5+
6+
Add typed JSON output to `app execute` with GraphQL error details and file receipts.

‎docs-shopify.dev/generated/generated_docs_data_v2.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1714,6 +1714,15 @@
17141714
"isOptional": true,
17151715
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
17161716
},
1717+
{
1718+
"filePath": "docs-shopify.dev/commands/interfaces/app-execute.interface.ts",
1719+
"syntaxKind": "PropertySignature",
1720+
"name": "-j, --json",
1721+
"value": "''",
1722+
"description": "Output the result as JSON. Automatically disables color output.",
1723+
"isOptional": true,
1724+
"environmentValue": "SHOPIFY_FLAG_JSON"
1725+
},
17171726
{
17181727
"filePath": "docs-shopify.dev/commands/interfaces/app-execute.interface.ts",
17191728
"syntaxKind": "PropertySignature",
@@ -1742,7 +1751,7 @@
17421751
"environmentValue": "SHOPIFY_FLAG_VARIABLES"
17431752
}
17441753
],
1745-
"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}"
1754+
"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}"
17461755
}
17471756
},
17481757
"appfunctionbuild": {
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import Execute from './execute.js'
2+
import {prepareExecuteContext} from '../../utilities/execute-command-helpers.js'
3+
import {createAdminSessionAsApp, resolveApiVersion} from '../../services/graphql/common.js'
4+
import {
5+
testAppLinked,
6+
testOrganization,
7+
testOrganizationApp,
8+
testOrganizationStore,
9+
testProject,
10+
} from '../../models/app/app.test-data.js'
11+
import {Config} from '@oclif/core'
12+
import {afterEach, beforeEach, expect, test, vi} from 'vitest'
13+
import {ClientError} from 'graphql-request'
14+
// eslint-disable-next-line @shopify/typescript-prefer-build-client-schema -- Local execution fixture without an introspection response.
15+
import {GraphQLError, buildSchema, graphql} from 'graphql'
16+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
17+
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
18+
import {handler} from '@shopify/cli-kit/node/error'
19+
import {unstyled} from '@shopify/cli-kit/node/output'
20+
import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs'
21+
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
22+
import {joinPath} from '@shopify/cli-kit/node/path'
23+
import * as ui from '@shopify/cli-kit/node/ui'
24+
25+
vi.mock('../../utilities/execute-command-helpers.js')
26+
vi.mock('../../services/graphql/common.js')
27+
vi.mock('@shopify/cli-kit/node/api/admin')
28+
29+
const query = 'query { shop { name } }'
30+
const app = testAppLinked()
31+
32+
beforeEach(() => {
33+
const remoteApp = testOrganizationApp()
34+
vi.mocked(prepareExecuteContext).mockResolvedValue({
35+
query,
36+
appContextResult: {
37+
app,
38+
remoteApp,
39+
organization: testOrganization(),
40+
developerPlatformClient: remoteApp.developerPlatformClient,
41+
project: testProject(),
42+
specifications: [],
43+
activeConfig: {} as never,
44+
},
45+
store: testOrganizationStore({shopDomain: 'shop.myshopify.com'}),
46+
})
47+
vi.mocked(createAdminSessionAsApp).mockResolvedValue({token: 'test-token', storeFqdn: 'shop.myshopify.com'})
48+
vi.mocked(resolveApiVersion).mockResolvedValue('2026-10')
49+
})
50+
51+
afterEach(() => {
52+
vi.unstubAllEnvs()
53+
})
54+
55+
async function runCommand(flags: string[]) {
56+
const argv = ['--query', query, '--store', 'shop.myshopify.com', ...flags]
57+
const command = new Execute(argv, await Config.load())
58+
const textTask = flags.includes('--json')
59+
? undefined
60+
: vi.spyOn(ui, 'renderSingleTask').mockImplementation(async ({task}) => task(() => {}))
61+
try {
62+
return await runWithCommandEventsForCommand(argv, () => command.run())
63+
} finally {
64+
textTask?.mockRestore()
65+
}
66+
}
67+
68+
test('preserves native GraphQL aliases, order, and UTF-8 on stdout and in output files', async () => {
69+
const response = await graphql({
70+
schema: buildSchema('type Query { name: String! nested: Query }'),
71+
source: 'query { last: name __proto__: name constructor: name nested { __proto__: name constructor: name } }',
72+
rootValue: {name: 'Café', nested: {name: 'Nested'}},
73+
})
74+
expect(response.errors).toBeUndefined()
75+
const extensions = JSON.parse('{"__proto__":{"trace":"preserved"},"constructor":false,"nullable":null,"empty":[]}')
76+
vi.mocked(adminRequestDoc).mockImplementation(async ({responseOptions}) => {
77+
responseOptions?.onResponse?.({data: response.data, extensions, status: 200, headers: new Headers()})
78+
return response.data
79+
})
80+
const json = JSON.stringify({data: response.data, extensions}, null, 2)
81+
const text = JSON.stringify(response.data, null, 2)
82+
83+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
84+
await runCommand(['--json'])
85+
expect(stdout()).toBe(`${json}\n`)
86+
expect(
87+
stderr()
88+
.trim()
89+
.split('\n')
90+
.map((line) => JSON.parse(line)),
91+
).toEqual([
92+
expect.objectContaining({type: 'progress', status: 'started', message: 'Authenticating'}),
93+
expect.objectContaining({type: 'progress', status: 'completed', message: 'Authenticating'}),
94+
expect.objectContaining({type: 'progress', status: 'started', message: 'Executing GraphQL operation'}),
95+
expect.objectContaining({type: 'progress', status: 'completed', message: 'Executing GraphQL operation'}),
96+
])
97+
})
98+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
99+
await runCommand([])
100+
expect(stdout()).toBe(`${text}\n`)
101+
expect(stderr()).toContain('Operation succeeded.')
102+
})
103+
await inTemporaryDirectory(async (directory) => {
104+
const path = joinPath(directory, 'result.json')
105+
await withCapturedStandardStreams(async ({stdout}) => {
106+
await runCommand(['--json', '--output-file', path])
107+
expect(JSON.parse(stdout())).toEqual({path, format: 'json'})
108+
})
109+
await expect(readFile(path)).resolves.toBe(json)
110+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
111+
await runCommand(['--output-file', path])
112+
expect(stdout()).toBe('')
113+
expect(stderr()).toContain('Operation succeeded.')
114+
expect(unstyled(stderr()).replace(/[│\s]/g, '')).toContain(`Resultswrittento${path}`)
115+
})
116+
await expect(readFile(path)).resolves.toBe(text)
117+
})
118+
})
119+
120+
test('uses one shared fatal error document and preserves an existing output file', async () => {
121+
await inTemporaryDirectory(async (directory) => {
122+
const outputFile = joinPath(directory, 'result.json')
123+
await writeFile(outputFile, 'existing result')
124+
const details = {
125+
errors: [new GraphQLError('Denied', {extensions: {code: 'ACCESS_DENIED'}})],
126+
data: {shop: null},
127+
extensions: {cost: {actualQueryCost: 1}},
128+
}
129+
vi.mocked(adminRequestDoc).mockRejectedValue(new ClientError({...details, status: 200}, {query}))
130+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
131+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
132+
await runCommand(['--json', '--output-file', outputFile]).catch(handler)
133+
expect(JSON.parse(stdout())).toEqual({
134+
error: {type: 'abort', message: 'GraphQL operation failed.', details: JSON.parse(JSON.stringify(details))},
135+
})
136+
expect(stderr()).not.toContain('GraphQL operation failed.')
137+
})
138+
await expect(readFile(outputFile)).resolves.toBe('existing result')
139+
})
140+
})
141+
142+
test('preserves the text error banner without a fatal error for a GraphQL failure', async () => {
143+
const errors = [new GraphQLError('Field invalidField does not exist')]
144+
vi.mocked(adminRequestDoc).mockRejectedValue(new ClientError({errors, status: 200}, {query}))
145+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
146+
await expect(runCommand([])).resolves.toEqual({app})
147+
expect(stdout()).toBe('')
148+
expect(stderr()).toContain('GraphQL operation failed.')
149+
expect(stderr()).toContain('invalidField')
150+
})
151+
})

‎packages/app/src/cli/commands/app/execute.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {appFlags, operationFlags} from '../../flags.js'
22
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js'
33
import {executeOperation} from '../../services/execute-operation.js'
4+
import {appExecuteJsonOutputSchema} from '../../services/execute-operation/types.js'
5+
import {renderExecuteOperationResult} from '../../services/execute-operation/result.js'
46
import {prepareExecuteContext} from '../../utilities/execute-command-helpers.js'
5-
import {globalFlags} from '@shopify/cli-kit/node/cli'
7+
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
68

79
export default class Execute extends AppLinkedCommand {
810
static summary = 'Execute GraphQL queries and mutations.'
@@ -11,29 +13,34 @@ export default class Execute extends AppLinkedCommand {
1113
1214
For operations that process large amounts of data, use [\`bulk execute\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) instead.`
1315

16+
static get jsonOutputSchema() {
17+
return appExecuteJsonOutputSchema
18+
}
19+
1420
static description = this.descriptionForHelp()
1521

1622
static flags = {
1723
...globalFlags,
1824
...appFlags,
1925
...operationFlags,
26+
...jsonFlag,
2027
}
2128

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

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

27-
await executeOperation({
34+
const result = await executeOperation({
2835
organization: appContextResult.organization,
2936
remoteApp: appContextResult.remoteApp,
3037
store,
3138
query,
3239
variables: flags.variables,
3340
variableFile: flags['variable-file'],
34-
outputFile: flags['output-file'],
3541
...(flags.version && {version: flags.version}),
3642
})
43+
await renderExecuteOperationResult(result, flags.json ? 'json' : 'text', flags['output-file'])
3744

3845
return {app: appContextResult.app}
3946
}

0 commit comments

Comments
 (0)