Skip to content

Commit fb2af80

Browse files
authored
Merge pull request #6663 from Shopify/11-24-Stdin-for-query-input-BulkOps-CLI
Add stdin support for `shopify app execute`
2 parents 500447d + b4e7710 commit fb2af80

File tree

5 files changed

+139
-5
lines changed

5 files changed

+139
-5
lines changed

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {linkedAppContext} from '../../services/app-context.js'
44
import {storeContext} from '../../services/store-context.js'
55
import {executeBulkOperation} from '../../services/bulk-operations/execute-bulk-operation.js'
66
import {globalFlags} from '@shopify/cli-kit/node/cli'
7+
import {readStdin} from '@shopify/cli-kit/node/system'
8+
import {AbortError} from '@shopify/cli-kit/node/error'
79

810
export default class Execute extends AppLinkedCommand {
911
static summary = 'Execute bulk operations.'
@@ -21,6 +23,14 @@ export default class Execute extends AppLinkedCommand {
2123
async run(): Promise<AppLinkedCommandOutput> {
2224
const {flags} = await this.parse(Execute)
2325

26+
const query = flags.query ?? (await readStdin())
27+
if (!query) {
28+
throw new AbortError(
29+
'No query provided. Use the --query flag or pipe input via stdin.',
30+
'Example: echo "query { ... }" | shopify app execute',
31+
)
32+
}
33+
2434
const appContextResult = await linkedAppContext({
2535
directory: flags.path,
2636
clientId: flags['client-id'],
@@ -37,7 +47,7 @@ export default class Execute extends AppLinkedCommand {
3747
await executeBulkOperation({
3848
remoteApp: appContextResult.remoteApp,
3949
storeFqdn: store.shopDomain,
40-
query: flags.query,
50+
query,
4151
variables: flags.variables,
4252
variableFile: flags['variable-file'],
4353
watch: flags.watch,

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/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
@@ -961,12 +961,12 @@
961961
},
962962
"query": {
963963
"char": "q",
964-
"description": "The GraphQL query or mutation to run as a bulk operation.",
964+
"description": "The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.",
965965
"env": "SHOPIFY_FLAG_QUERY",
966966
"hasDynamicHelp": false,
967967
"multiple": false,
968968
"name": "query",
969-
"required": true,
969+
"required": false,
970970
"type": "option"
971971
},
972972
"reset": {

0 commit comments

Comments
 (0)