Skip to content

Commit e6c20a9

Browse files
ericlee878jordanverasamy
authored andcommitted
Add stdin support for shopify app execute
1 parent 386074f commit e6c20a9

File tree

6 files changed

+215
-6
lines changed

6 files changed

+215
-6
lines changed

packages/app/src/cli/flags.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ export const appFlags = {
3838
export const bulkOperationFlags = {
3939
query: Flags.string({
4040
char: 'q',
41-
description: 'The GraphQL query or mutation to run as a bulk operation.',
41+
description: 'The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.',
4242
env: 'SHOPIFY_FLAG_QUERY',
43-
required: true,
43+
required: false,
4444
}),
4545
variables: Flags.string({
4646
char: 'v',

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {BulkOperationRunMutationMutation} from '../../api/graphql/bulk-operation
88
import {renderSuccess, renderWarning, renderError} from '@shopify/cli-kit/node/ui'
99
import {OrganizationApp} from '../../models/organization.js'
1010
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
11+
import {readStdin} from '@shopify/cli-kit/node/system'
1112
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
1213
import {joinPath} from '@shopify/cli-kit/node/path'
1314
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
@@ -19,6 +20,7 @@ vi.mock('./watch-bulk-operation.js')
1920
vi.mock('./download-bulk-operation-results.js')
2021
vi.mock('@shopify/cli-kit/node/ui')
2122
vi.mock('@shopify/cli-kit/node/fs')
23+
vi.mock('@shopify/cli-kit/node/system')
2224
vi.mock('@shopify/cli-kit/node/session', async () => {
2325
const actual = await vi.importActual('@shopify/cli-kit/node/session')
2426
return {
@@ -459,4 +461,64 @@ describe('executeBulkOperation', () => {
459461
)
460462
},
461463
)
464+
465+
test('reads query from stdin when query flag is not provided', async () => {
466+
const stdinQuery = '{ products { edges { node { id } } } }'
467+
vi.mocked(readStdin).mockResolvedValue(stdinQuery)
468+
469+
const mockResponse = {
470+
bulkOperation: createdBulkOperation,
471+
userErrors: [],
472+
}
473+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
474+
475+
await executeBulkOperation({
476+
remoteApp: mockRemoteApp,
477+
storeFqdn,
478+
})
479+
480+
expect(readStdin).toHaveBeenCalled()
481+
expect(runBulkOperationQuery).toHaveBeenCalledWith({
482+
adminSession: mockAdminSession,
483+
query: stdinQuery,
484+
})
485+
})
486+
487+
test('prefers query flag over stdin when both are available', async () => {
488+
const flagQuery = 'query { orders { edges { node { id } } } }'
489+
const stdinQuery = '{ products { edges { node { id } } } }'
490+
vi.mocked(readStdin).mockResolvedValue(stdinQuery)
491+
492+
const mockResponse = {
493+
bulkOperation: createdBulkOperation,
494+
userErrors: [],
495+
}
496+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
497+
498+
await executeBulkOperation({
499+
remoteApp: mockRemoteApp,
500+
storeFqdn,
501+
query: flagQuery,
502+
})
503+
504+
expect(readStdin).not.toHaveBeenCalled()
505+
expect(runBulkOperationQuery).toHaveBeenCalledWith({
506+
adminSession: mockAdminSession,
507+
query: flagQuery,
508+
})
509+
})
510+
511+
test('throws error when no query is provided and stdin is empty', async () => {
512+
vi.mocked(readStdin).mockResolvedValue(undefined)
513+
514+
await expect(
515+
executeBulkOperation({
516+
remoteApp: mockRemoteApp,
517+
storeFqdn,
518+
}),
519+
).rejects.toThrow('No query provided')
520+
521+
expect(runBulkOperationQuery).not.toHaveBeenCalled()
522+
expect(runBulkOperationMutation).not.toHaveBeenCalled()
523+
})
462524
})

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,40 @@ import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/ou
88
import {OrganizationApp} from '../../models/organization.js'
99
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
1010
import {AbortError} from '@shopify/cli-kit/node/error'
11+
import {readStdin} from '@shopify/cli-kit/node/system'
1112
import {parse} from 'graphql'
1213
import {readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs'
1314

1415
interface ExecuteBulkOperationInput {
1516
remoteApp: OrganizationApp
1617
storeFqdn: string
17-
query: string
18+
query?: string
1819
variables?: string[]
1920
variableFile?: string
2021
watch?: boolean
2122
outputFile?: string
2223
}
2324

25+
/**
26+
* Resolves the query from the provided flag or stdin.
27+
* Follows the same pattern as parseVariablesToJsonl for consistency.
28+
*/
29+
async function resolveQuery(query?: string): Promise<string> {
30+
if (query) {
31+
return query
32+
}
33+
34+
const stdinContent = await readStdin()
35+
if (stdinContent) {
36+
return stdinContent
37+
}
38+
39+
throw new AbortError(
40+
'No query provided. Use the --query flag or pipe input via stdin.',
41+
'Example: echo "query { ... }" | shopify app execute',
42+
)
43+
}
44+
2445
async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise<string | undefined> {
2546
if (variables) {
2647
return variables.join('\n')
@@ -39,7 +60,9 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string
3960
}
4061

4162
export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
42-
const {remoteApp, storeFqdn, query, variables, variableFile, outputFile, watch = false} = input
63+
const {remoteApp, storeFqdn, variables, variableFile, outputFile, watch = false} = input
64+
65+
const query = await resolveQuery(input.query)
4366

4467
renderInfo({
4568
headline: 'Starting bulk operation.',

packages/cli-kit/src/public/node/system.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@ import * as system from './system.js'
22
import {execa} from 'execa'
33
import {describe, expect, test, vi} from 'vitest'
44
import which from 'which'
5+
import {Readable} from 'stream'
6+
import * as fs from 'fs'
57

68
vi.mock('which')
79
vi.mock('execa')
10+
vi.mock('fs', async (importOriginal) => {
11+
const actual = await importOriginal<typeof fs>()
12+
return {
13+
...actual,
14+
fstatSync: vi.fn(),
15+
}
16+
})
817

918
describe('captureOutput', () => {
1019
test('runs the command when it is not found in the current directory', async () => {
@@ -30,3 +39,77 @@ describe('captureOutput', () => {
3039
await expect(got).rejects.toThrowError('Skipped run of unsecure binary command found in the current directory.')
3140
})
3241
})
42+
43+
describe('isStdinPiped', () => {
44+
test('returns true when stdin is a FIFO (pipe)', () => {
45+
// Given
46+
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => true, isFile: () => false} as fs.Stats)
47+
48+
// When
49+
const got = system.isStdinPiped()
50+
51+
// Then
52+
expect(got).toBe(true)
53+
})
54+
55+
test('returns true when stdin is a file redirect', () => {
56+
// Given
57+
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => false, isFile: () => true} as fs.Stats)
58+
59+
// When
60+
const got = system.isStdinPiped()
61+
62+
// Then
63+
expect(got).toBe(true)
64+
})
65+
66+
test('returns false when stdin is a TTY (interactive)', () => {
67+
// Given
68+
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => false, isFile: () => false} as fs.Stats)
69+
70+
// When
71+
const got = system.isStdinPiped()
72+
73+
// Then
74+
expect(got).toBe(false)
75+
})
76+
77+
test('returns false when fstatSync throws (e.g., CI with no stdin)', () => {
78+
// Given
79+
vi.mocked(fs.fstatSync).mockImplementation(() => {
80+
throw new Error('EBADF')
81+
})
82+
83+
// When
84+
const got = system.isStdinPiped()
85+
86+
// Then
87+
expect(got).toBe(false)
88+
})
89+
})
90+
91+
describe('readStdin', () => {
92+
test('returns undefined when stdin is not piped', async () => {
93+
// Given
94+
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => false, isFile: () => false} as fs.Stats)
95+
96+
// When
97+
const got = await system.readStdin()
98+
99+
// Then
100+
expect(got).toBeUndefined()
101+
})
102+
103+
test('returns trimmed content when stdin is piped', async () => {
104+
// Given
105+
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => true, isFile: () => false} as fs.Stats)
106+
const mockStdin = Readable.from([' hello world '])
107+
vi.spyOn(process, 'stdin', 'get').mockReturnValue(mockStdin as unknown as typeof process.stdin)
108+
109+
// When
110+
const got = await system.readStdin()
111+
112+
// Then
113+
expect(got).toBe('hello world')
114+
})
115+
})

packages/cli-kit/src/public/node/system.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {shouldDisplayColors, outputDebug} from '../../public/node/output.js'
99
import {execa, ExecaChildProcess} from 'execa'
1010
import which from 'which'
1111
import {delimiter} from 'pathe'
12+
import {fstatSync} from 'fs'
1213
import type {Writable, Readable} from 'stream'
1314

1415
export interface ExecOptions {
@@ -200,3 +201,43 @@ export async function isWsl(): Promise<boolean> {
200201
const wsl = await import('is-wsl')
201202
return wsl.default
202203
}
204+
205+
/**
206+
* Check if stdin has piped data available.
207+
* This distinguishes between actual piped input (e.g., `echo "query" | cmd`)
208+
* and non-TTY environments without input (e.g., CI).
209+
*
210+
* @returns True if stdin is receiving piped data or file redirect, false otherwise.
211+
*/
212+
export function isStdinPiped(): boolean {
213+
try {
214+
const stats = fstatSync(0)
215+
return stats.isFIFO() || stats.isFile()
216+
// eslint-disable-next-line no-catch-all/no-catch-all
217+
} catch {
218+
return false
219+
}
220+
}
221+
222+
/**
223+
* Reads all data from stdin and returns it as a string.
224+
* This is useful for commands that accept input via piping.
225+
*
226+
* @example
227+
* // Usage: echo "your query" | shopify app execute
228+
* const query = await readStdin()
229+
*
230+
* @returns A promise that resolves with the stdin content, or undefined if stdin is a TTY.
231+
*/
232+
export async function readStdin(): Promise<string | undefined> {
233+
if (!isStdinPiped()) {
234+
return undefined
235+
}
236+
237+
let data = ''
238+
process.stdin.setEncoding('utf8')
239+
for await (const chunk of process.stdin) {
240+
data += String(chunk)
241+
}
242+
return data.trim()
243+
}

packages/cli/oclif.manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -865,12 +865,12 @@
865865
},
866866
"query": {
867867
"char": "q",
868-
"description": "The GraphQL query or mutation to run as a bulk operation.",
868+
"description": "The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.",
869869
"env": "SHOPIFY_FLAG_QUERY",
870870
"hasDynamicHelp": false,
871871
"multiple": false,
872872
"name": "query",
873-
"required": true,
873+
"required": false,
874874
"type": "option"
875875
},
876876
"reset": {

0 commit comments

Comments
 (0)