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
12 changes: 11 additions & 1 deletion packages/app/src/cli/commands/app/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {linkedAppContext} from '../../services/app-context.js'
import {storeContext} from '../../services/store-context.js'
import {executeBulkOperation} from '../../services/bulk-operations/execute-bulk-operation.js'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {readStdin} from '@shopify/cli-kit/node/system'
import {AbortError} from '@shopify/cli-kit/node/error'

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

const query = flags.query ?? (await readStdin())
if (!query) {
throw new AbortError(
'No query provided. Use the --query flag or pipe input via stdin.',
'Example: echo "query { ... }" | shopify app execute',
)
}

const appContextResult = await linkedAppContext({
directory: flags.path,
clientId: flags['client-id'],
Expand All @@ -37,7 +47,7 @@ export default class Execute extends AppLinkedCommand {
await executeBulkOperation({
remoteApp: appContextResult.remoteApp,
storeFqdn: store.shopDomain,
query: flags.query,
query,
variables: flags.variables,
variableFile: flags['variable-file'],
watch: flags.watch,
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ export const appFlags = {
export const bulkOperationFlags = {
query: Flags.string({
char: 'q',
description: 'The GraphQL query or mutation to run as a bulk operation.',
description: 'The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.',
env: 'SHOPIFY_FLAG_QUERY',
required: true,
required: false,
}),
variables: Flags.string({
char: 'v',
Expand Down
83 changes: 83 additions & 0 deletions packages/cli-kit/src/public/node/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ import * as system from './system.js'
import {execa} from 'execa'
import {describe, expect, test, vi} from 'vitest'
import which from 'which'
import {Readable} from 'stream'
import * as fs from 'fs'

vi.mock('which')
vi.mock('execa')
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof fs>()
return {
...actual,
fstatSync: vi.fn(),
}
})

describe('captureOutput', () => {
test('runs the command when it is not found in the current directory', async () => {
Expand All @@ -30,3 +39,77 @@ describe('captureOutput', () => {
await expect(got).rejects.toThrowError('Skipped run of unsecure binary command found in the current directory.')
})
})

describe('isStdinPiped', () => {
test('returns true when stdin is a FIFO (pipe)', () => {
// Given
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => true, isFile: () => false} as fs.Stats)

// When
const got = system.isStdinPiped()

// Then
expect(got).toBe(true)
})

test('returns true when stdin is a file redirect', () => {
// Given
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => false, isFile: () => true} as fs.Stats)

// When
const got = system.isStdinPiped()

// Then
expect(got).toBe(true)
})

test('returns false when stdin is a TTY (interactive)', () => {
// Given
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => false, isFile: () => false} as fs.Stats)

// When
const got = system.isStdinPiped()

// Then
expect(got).toBe(false)
})

test('returns false when fstatSync throws (e.g., CI with no stdin)', () => {
// Given
vi.mocked(fs.fstatSync).mockImplementation(() => {
throw new Error('EBADF')
})

// When
const got = system.isStdinPiped()

// Then
expect(got).toBe(false)
})
})

describe('readStdin', () => {
test('returns undefined when stdin is not piped', async () => {
// Given
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => false, isFile: () => false} as fs.Stats)

// When
const got = await system.readStdin()

// Then
expect(got).toBeUndefined()
})

test('returns trimmed content when stdin is piped', async () => {
// Given
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => true, isFile: () => false} as fs.Stats)
const mockStdin = Readable.from([' hello world '])
vi.spyOn(process, 'stdin', 'get').mockReturnValue(mockStdin as unknown as typeof process.stdin)

// When
const got = await system.readStdin()

// Then
expect(got).toBe('hello world')
})
})
41 changes: 41 additions & 0 deletions packages/cli-kit/src/public/node/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {shouldDisplayColors, outputDebug} from '../../public/node/output.js'
import {execa, ExecaChildProcess} from 'execa'
import which from 'which'
import {delimiter} from 'pathe'
import {fstatSync} from 'fs'
import type {Writable, Readable} from 'stream'

export interface ExecOptions {
Expand Down Expand Up @@ -200,3 +201,43 @@ export async function isWsl(): Promise<boolean> {
const wsl = await import('is-wsl')
return wsl.default
}

/**
* Check if stdin has piped data available.
* This distinguishes between actual piped input (e.g., `echo "query" | cmd`)
* and non-TTY environments without input (e.g., CI).
*
* @returns True if stdin is receiving piped data or file redirect, false otherwise.
*/
export function isStdinPiped(): boolean {
try {
const stats = fstatSync(0)
return stats.isFIFO() || stats.isFile()
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
return false
}
}

/**
* Reads all data from stdin and returns it as a string.
* This is useful for commands that accept input via piping.
*
* @example
* // Usage: echo "your query" | shopify app execute
* const query = await readStdin()
*
* @returns A promise that resolves with the stdin content, or undefined if stdin is a TTY.
*/
export async function readStdin(): Promise<string | undefined> {
if (!isStdinPiped()) {
return undefined
}

let data = ''
process.stdin.setEncoding('utf8')
for await (const chunk of process.stdin) {
data += String(chunk)
}
return data.trim()
}
4 changes: 2 additions & 2 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -961,12 +961,12 @@
},
"query": {
"char": "q",
"description": "The GraphQL query or mutation to run as a bulk operation.",
"description": "The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.",
"env": "SHOPIFY_FLAG_QUERY",
"hasDynamicHelp": false,
"multiple": false,
"name": "query",
"required": true,
"required": false,
"type": "option"
},
"reset": {
Expand Down