From b4e7710844a2ba5ca25e75c9e05f1d4a06b0e1f0 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Mon, 24 Nov 2025 15:57:47 -0800 Subject: [PATCH] Add stdin support for shopify app execute --- packages/app/src/cli/commands/app/execute.ts | 12 ++- packages/app/src/cli/flags.ts | 4 +- .../cli-kit/src/public/node/system.test.ts | 83 +++++++++++++++++++ packages/cli-kit/src/public/node/system.ts | 41 +++++++++ packages/cli/oclif.manifest.json | 4 +- 5 files changed, 139 insertions(+), 5 deletions(-) diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index b8d081e673e..90957b1a05f 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -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.' @@ -21,6 +23,14 @@ export default class Execute extends AppLinkedCommand { async run(): Promise { 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'], @@ -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, diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index 70da9973bac..1cdcba90204 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -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', diff --git a/packages/cli-kit/src/public/node/system.test.ts b/packages/cli-kit/src/public/node/system.test.ts index 2c266263ec8..ae1c0530bca 100644 --- a/packages/cli-kit/src/public/node/system.test.ts +++ b/packages/cli-kit/src/public/node/system.test.ts @@ -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() + return { + ...actual, + fstatSync: vi.fn(), + } +}) describe('captureOutput', () => { test('runs the command when it is not found in the current directory', async () => { @@ -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') + }) +}) diff --git a/packages/cli-kit/src/public/node/system.ts b/packages/cli-kit/src/public/node/system.ts index b3582441015..ffeab2a6b5a 100644 --- a/packages/cli-kit/src/public/node/system.ts +++ b/packages/cli-kit/src/public/node/system.ts @@ -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 { @@ -200,3 +201,43 @@ export async function isWsl(): Promise { 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 { + if (!isStdinPiped()) { + return undefined + } + + let data = '' + process.stdin.setEncoding('utf8') + for await (const chunk of process.stdin) { + data += String(chunk) + } + return data.trim() +} diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 88c77c64647..d811be2a23b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -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": {