Skip to content

Commit 87db939

Browse files
committed
Add stdin support for shopify app execute
1 parent e9bb7f5 commit 87db939

File tree

5 files changed

+175
-4
lines changed

5 files changed

+175
-4
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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import {AppLinkedInterface} from '../../models/app/app.js'
55
import {OrganizationApp} from '../../models/organization.js'
66
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
77
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
8+
import {readStdin} from '@shopify/cli-kit/node/system'
89
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
910
import {joinPath} from '@shopify/cli-kit/node/path'
1011
import {describe, test, expect, vi, beforeEach} from 'vitest'
1112

1213
vi.mock('./run-query.js')
1314
vi.mock('./run-mutation.js')
1415
vi.mock('@shopify/cli-kit/node/ui')
16+
vi.mock('@shopify/cli-kit/node/system')
1517
vi.mock('@shopify/cli-kit/node/session', async () => {
1618
const actual = await vi.importActual('@shopify/cli-kit/node/session')
1719
return {
@@ -335,4 +337,67 @@ describe('executeBulkOperation', () => {
335337
expect(runBulkOperationMutation).not.toHaveBeenCalled()
336338
})
337339
})
340+
341+
test('reads query from stdin when query flag is not provided', async () => {
342+
const stdinQuery = '{ products { edges { node { id } } } }'
343+
vi.mocked(readStdin).mockResolvedValue(stdinQuery)
344+
345+
const mockResponse = {
346+
bulkOperation: successfulBulkOperation,
347+
userErrors: [],
348+
}
349+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
350+
351+
await executeBulkOperation({
352+
app: mockApp,
353+
remoteApp: mockRemoteApp,
354+
storeFqdn,
355+
})
356+
357+
expect(readStdin).toHaveBeenCalled()
358+
expect(runBulkOperationQuery).toHaveBeenCalledWith({
359+
adminSession: mockAdminSession,
360+
query: stdinQuery,
361+
})
362+
})
363+
364+
test('prefers query flag over stdin when both are available', async () => {
365+
const flagQuery = 'query { orders { edges { node { id } } } }'
366+
const stdinQuery = '{ products { edges { node { id } } } }'
367+
vi.mocked(readStdin).mockResolvedValue(stdinQuery)
368+
369+
const mockResponse = {
370+
bulkOperation: successfulBulkOperation,
371+
userErrors: [],
372+
}
373+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
374+
375+
await executeBulkOperation({
376+
app: mockApp,
377+
remoteApp: mockRemoteApp,
378+
storeFqdn,
379+
query: flagQuery,
380+
})
381+
382+
expect(readStdin).not.toHaveBeenCalled()
383+
expect(runBulkOperationQuery).toHaveBeenCalledWith({
384+
adminSession: mockAdminSession,
385+
query: flagQuery,
386+
})
387+
})
388+
389+
test('throws error when no query is provided and stdin is empty', async () => {
390+
vi.mocked(readStdin).mockResolvedValue(undefined)
391+
392+
await expect(
393+
executeBulkOperation({
394+
app: mockApp,
395+
remoteApp: mockRemoteApp,
396+
storeFqdn,
397+
}),
398+
).rejects.toThrow('No query provided')
399+
400+
expect(runBulkOperationQuery).not.toHaveBeenCalled()
401+
expect(runBulkOperationMutation).not.toHaveBeenCalled()
402+
})
338403
})

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
@@ -6,18 +6,39 @@ import {renderSuccess, renderInfo, renderWarning} from '@shopify/cli-kit/node/ui
66
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
77
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
88
import {AbortError} from '@shopify/cli-kit/node/error'
9+
import {readStdin} from '@shopify/cli-kit/node/system'
910
import {parse} from 'graphql'
1011
import {readFile, fileExists} from '@shopify/cli-kit/node/fs'
1112

1213
interface ExecuteBulkOperationInput {
1314
app: AppLinkedInterface
1415
remoteApp: OrganizationApp
1516
storeFqdn: string
16-
query: string
17+
query?: string
1718
variables?: string[]
1819
variableFile?: string
1920
}
2021

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

3859
export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
39-
const {app, remoteApp, storeFqdn, query, variables, variableFile} = input
60+
const {app, remoteApp, storeFqdn, variables, variableFile} = input
61+
62+
const query = await resolveQuery(input.query)
4063

4164
renderInfo({
4265
headline: 'Starting bulk operation.',

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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'
56

67
vi.mock('which')
78
vi.mock('execa')
@@ -30,3 +31,52 @@ describe('captureOutput', () => {
3031
await expect(got).rejects.toThrowError('Skipped run of unsecure binary command found in the current directory.')
3132
})
3233
})
34+
35+
describe('isStdinPiped', () => {
36+
test('returns true when stdin is piped', () => {
37+
// Given
38+
Object.defineProperty(process.stdin, 'isTTY', {value: false, configurable: true})
39+
40+
// When
41+
const got = system.isStdinPiped()
42+
43+
// Then
44+
expect(got).toBe(true)
45+
})
46+
47+
test('returns false when stdin is interactive', () => {
48+
// Given
49+
Object.defineProperty(process.stdin, 'isTTY', {value: true, configurable: true})
50+
51+
// When
52+
const got = system.isStdinPiped()
53+
54+
// Then
55+
expect(got).toBe(false)
56+
})
57+
})
58+
59+
describe('readStdin', () => {
60+
test('returns undefined when stdin is not piped', async () => {
61+
// Given
62+
Object.defineProperty(process.stdin, 'isTTY', {value: true, configurable: true})
63+
64+
// When
65+
const got = await system.readStdin()
66+
67+
// Then
68+
expect(got).toBeUndefined()
69+
})
70+
71+
test('returns trimmed content when stdin is piped', async () => {
72+
// Given
73+
const mockStdin = Object.assign(Readable.from(['hello world']), {isTTY: false})
74+
vi.spyOn(process, 'stdin', 'get').mockReturnValue(mockStdin as unknown as typeof process.stdin)
75+
76+
// When
77+
const got = await system.readStdin()
78+
79+
// Then
80+
expect(got).toBe('hello world')
81+
})
82+
})

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,36 @@ export async function isWsl(): Promise<boolean> {
200200
const wsl = await import('is-wsl')
201201
return wsl.default
202202
}
203+
204+
/**
205+
* Check if stdin has piped data available (not a TTY).
206+
* This is useful for detecting when a command is receiving input via piping.
207+
*
208+
* @returns True if stdin is being piped, false if it's interactive.
209+
*/
210+
export function isStdinPiped(): boolean {
211+
return !process.stdin.isTTY
212+
}
213+
214+
/**
215+
* Reads all data from stdin and returns it as a string.
216+
* This is useful for commands that accept input via piping.
217+
*
218+
* @example
219+
* // Usage: echo "your query" | shopify app execute
220+
* const query = await readStdin()
221+
*
222+
* @returns A promise that resolves with the stdin content, or undefined if stdin is a TTY.
223+
*/
224+
export async function readStdin(): Promise<string | undefined> {
225+
if (!isStdinPiped()) {
226+
return undefined
227+
}
228+
229+
let data = ''
230+
process.stdin.setEncoding('utf8')
231+
for await (const chunk of process.stdin) {
232+
data += String(chunk)
233+
}
234+
return data.trim()
235+
}

0 commit comments

Comments
 (0)