Skip to content
Merged
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
14 changes: 14 additions & 0 deletions packages/app/src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export const bulkOperationFlags = {
description: 'The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.',
env: 'SHOPIFY_FLAG_QUERY',
required: false,
exclusive: ['query-file'],
}),
'query-file': Flags.string({
description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
env: 'SHOPIFY_FLAG_QUERY_FILE',
parse: async (input) => resolvePath(input),
exclusive: ['query'],
}),
variables: Flags.string({
char: 'v',
Expand Down Expand Up @@ -85,6 +92,13 @@ export const operationFlags = {
description: 'The GraphQL query or mutation, as a string.',
env: 'SHOPIFY_FLAG_QUERY',
required: false,
exclusive: ['query-file'],
}),
'query-file': Flags.string({
description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
env: 'SHOPIFY_FLAG_QUERY_FILE',
parse: async (input) => resolvePath(input),
exclusive: ['query'],
}),
variables: Flags.string({
char: 'v',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@ import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
import {extractBulkOperationId} from './bulk-operation-status.js'
import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js'
import {
createAdminSessionAsApp,
validateSingleOperation,
formatOperationInfo,
resolveApiVersion,
} from '../graphql/common.js'
import {createAdminSessionAsApp, formatOperationInfo, resolveApiVersion} from '../graphql/common.js'
import {OrganizationApp, Organization} from '../../models/organization.js'
import {renderSuccess, renderInfo, renderError, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui'
import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output'
Expand Down Expand Up @@ -71,7 +66,7 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr

const variablesJsonl = await parseVariablesToJsonl(variables, variableFile)

validateGraphQLDocument(query, variablesJsonl)
validateBulkOperationVariables(query, variablesJsonl)

renderInfo({
headline: 'Starting bulk operation.',
Expand Down Expand Up @@ -208,9 +203,10 @@ function resultsContainUserErrors(results: string): boolean {
})
}

function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): void {
validateSingleOperation(graphqlOperation)

/**
* Validates bulk operation-specific constraints for variables.
*/
function validateBulkOperationVariables(graphqlOperation: string, variablesJsonl?: string): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming this means that normal (non-bulk) GraphQL queries can have variables?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, this is only invoked for bulk operations

if (!isMutation(graphqlOperation) && variablesJsonl) {
throw new AbortError(
outputContent`The ${outputToken.yellow('--variables')} and ${outputToken.yellow(
Expand Down
9 changes: 1 addition & 8 deletions packages/app/src/cli/services/execute-operation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
createAdminSessionAsApp,
validateSingleOperation,
resolveApiVersion,
formatOperationInfo,
} from './graphql/common.js'
import {createAdminSessionAsApp, resolveApiVersion, formatOperationInfo} from './graphql/common.js'
import {OrganizationApp, Organization} from '../models/organization.js'
import {renderSuccess, renderError, renderInfo, renderSingleTask} from '@shopify/cli-kit/node/ui'
import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output'
Expand Down Expand Up @@ -89,8 +84,6 @@ export async function executeOperation(input: ExecuteOperationInput): Promise<vo

const parsedVariables = await parseVariables(variables, variableFile)

validateSingleOperation(query)

try {
const result = await renderSingleTask({
title: outputContent`Executing GraphQL operation`,
Expand Down
46 changes: 46 additions & 0 deletions packages/app/src/cli/utilities/execute-command-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import {prepareAppStoreContext, prepareExecuteContext} from './execute-command-helpers.js'
import {linkedAppContext} from '../services/app-context.js'
import {storeContext} from '../services/store-context.js'
import {validateSingleOperation} from '../services/graphql/common.js'
import {readFile, fileExists} from '@shopify/cli-kit/node/fs'
import {readStdinString} from '@shopify/cli-kit/node/system'
import {describe, test, expect, vi, beforeEach} from 'vitest'

vi.mock('../services/app-context.js')
vi.mock('../services/store-context.js')
vi.mock('@shopify/cli-kit/node/fs')
vi.mock('@shopify/cli-kit/node/system')
vi.mock('../services/graphql/common.js', () => ({
validateSingleOperation: vi.fn(),
}))

describe('prepareAppStoreContext', () => {
const mockFlags = {
Expand Down Expand Up @@ -170,4 +176,44 @@ describe('prepareExecuteContext', () => {
expect(linkedAppContext).toHaveBeenCalled()
expect(storeContext).toHaveBeenCalled()
})

test('reads query from file when query-file flag is provided', async () => {
const queryFileContent = 'query { shop { name } }'
vi.mocked(fileExists).mockResolvedValue(true)
vi.mocked(readFile).mockResolvedValue(queryFileContent as any)

const flagsWithQueryFile = {...mockFlags, query: undefined, 'query-file': '/path/to/query.graphql'}
const result = await prepareExecuteContext(flagsWithQueryFile)

expect(fileExists).toHaveBeenCalledWith('/path/to/query.graphql')
expect(readFile).toHaveBeenCalledWith('/path/to/query.graphql', {encoding: 'utf8'})
expect(result.query).toBe(queryFileContent)
expect(readStdinString).not.toHaveBeenCalled()
})

test('throws AbortError when query file does not exist', async () => {
vi.mocked(fileExists).mockResolvedValue(false)

const flagsWithQueryFile = {...mockFlags, query: undefined, 'query-file': '/path/to/nonexistent.graphql'}

await expect(prepareExecuteContext(flagsWithQueryFile)).rejects.toThrow('Query file not found')
expect(readFile).not.toHaveBeenCalled()
})

test('falls back to stdin when neither query nor query-file provided', async () => {
const stdinQuery = 'query { shop { name } }'
vi.mocked(readStdinString).mockResolvedValue(stdinQuery)

const flagsWithoutQueryOrFile = {...mockFlags, query: undefined}
const result = await prepareExecuteContext(flagsWithoutQueryOrFile)

expect(readStdinString).toHaveBeenCalled()
expect(result.query).toBe(stdinQuery)
})

test('validates GraphQL query using validateSingleOperation', async () => {
await prepareExecuteContext(mockFlags)

expect(validateSingleOperation).toHaveBeenCalledWith(mockFlags.query)
})
})
30 changes: 26 additions & 4 deletions packages/app/src/cli/utilities/execute-command-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {linkedAppContext, LoadedAppContextOutput} from '../services/app-context.js'
import {storeContext} from '../services/store-context.js'
import {validateSingleOperation} from '../services/graphql/common.js'
import {OrganizationStore} from '../models/organization.js'
import {readStdinString} from '@shopify/cli-kit/node/system'
import {AbortError} from '@shopify/cli-kit/node/error'
import {readFile, fileExists} from '@shopify/cli-kit/node/fs'
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'

interface AppStoreContextFlags {
path: string
Expand All @@ -19,6 +22,7 @@ interface AppStoreContext {

interface ExecuteCommandFlags extends AppStoreContextFlags {
query?: string
'query-file'?: string
}

interface ExecuteContext extends AppStoreContext {
Expand Down Expand Up @@ -51,7 +55,7 @@ export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promi

/**
* Prepares the execution context for GraphQL operations.
* Handles query input from flag or stdin, and sets up app and store contexts.
* Handles query input from flag, file, or stdin, validates GraphQL syntax, and sets up app and store contexts.
*
* @param flags - Command flags containing configuration options.
* @param commandName - Name of the command for error messages (e.g., 'execute', 'bulk execute').
Expand All @@ -61,14 +65,32 @@ export async function prepareExecuteContext(
flags: ExecuteCommandFlags,
commandName = 'execute',
): Promise<ExecuteContext> {
const query = flags.query ?? (await readStdinString())
let query: string | undefined

if (flags.query) {
query = flags.query
} else if (flags['query-file']) {
Comment on lines +70 to +72

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if a user submits both?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OCLIF should prevent that based on the flag config

const queryFile = flags['query-file']
if (!(await fileExists(queryFile))) {
throw new AbortError(
outputContent`Query file not found at ${outputToken.path(queryFile)}. Please check the path and try again.`,
)
}
query = await readFile(queryFile, {encoding: 'utf8'})
} else {
query = await readStdinString()
}

if (!query) {
throw new AbortError(
'No query provided. Use the --query flag or pipe input via stdin.',
`Example: echo "query { ... }" | shopify app ${commandName}`,
'No query provided. Use the --query flag, --query-file flag, or pipe input via stdin.',
`Example: shopify app ${commandName} --query-file query.graphql`,
)
}

// Validate GraphQL syntax and ensure single operation
validateSingleOperation(query)

const {appContextResult, store} = await prepareAppStoreContext(flags)

return {query, appContextResult, store}
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,26 @@
"char": "q",
"description": "The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.",
"env": "SHOPIFY_FLAG_QUERY",
"exclusive": [
"query-file"
],
"hasDynamicHelp": false,
"multiple": false,
"name": "query",
"required": false,
"type": "option"
},
"query-file": {
"description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
"env": "SHOPIFY_FLAG_QUERY_FILE",
"exclusive": [
"query"
],
"hasDynamicHelp": false,
"multiple": false,
"name": "query-file",
"type": "option"
},
"reset": {
"allowNo": false,
"description": "Reset all your settings.",
Expand Down Expand Up @@ -1186,12 +1200,26 @@
"char": "q",
"description": "The GraphQL query or mutation, as a string.",
"env": "SHOPIFY_FLAG_QUERY",
"exclusive": [
"query-file"
],
"hasDynamicHelp": false,
"multiple": false,
"name": "query",
"required": false,
"type": "option"
},
"query-file": {
"description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
"env": "SHOPIFY_FLAG_QUERY_FILE",
"exclusive": [
"query"
],
"hasDynamicHelp": false,
"multiple": false,
"name": "query-file",
"type": "option"
},
"reset": {
"allowNo": false,
"description": "Reset all your settings.",
Expand Down
Loading