UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

361 lines 12.3 kB
import { AbortError, ExternalError } from './error.js'; import { cwd, dirname } from './path.js'; import { treeKill } from './tree-kill.js'; import { isTruthy } from './context/utilities.js'; import { renderWarning } from './ui.js'; import { platformAndArch } from './os.js'; import { shouldDisplayColors, outputDebug } from '../../public/node/output.js'; import { execa, execaCommand } from 'execa'; import which from 'which'; import { delimiter } from 'pathe'; import { fstatSync } from 'fs'; /** * Opens a URL in the user's default browser. * * @param url - URL to open. * @returns A promise that resolves true if the URL was opened successfully, false otherwise. */ export async function openURL(url) { const externalOpen = await import('open'); try { await externalOpen.default(url); return true; // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { return false; } } /** * Runs a command asynchronously, aggregates the stdout data, and returns it. * * @param command - Command to be executed. * @param args - Arguments to pass to the command. * @param options - Optional settings for how to run the command. * @returns A promise that resolves with the aggregatted stdout of the command. */ export async function captureOutput(command, args, options) { const result = await buildExec(command, args, options); return result.stdout; } /** * Runs a command asynchronously and returns stdout, stderr, and exit code. * Unlike captureOutput, this function does NOT throw on non-zero exit codes. * * @param command - Command to be executed. * @param args - Arguments to pass to the command. * @param options - Optional settings for how to run the command. * @returns A promise that resolves with stdout, stderr, and exitCode. * * @example * ```typescript * const result = await captureOutputWithExitCode('ls', ['-la']) * if (result.exitCode !== 0) \{ * console.error('Command failed:', result.stderr) * \} * ``` */ export async function captureOutputWithExitCode(command, args, options) { const result = await buildExec(command, args, options, { reject: false }); return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode ?? 0, }; } /** * Parse a command string into an array of arguments, respecting quoted strings. * Handles both single and double quotes, preserving spaces within quoted sections. * * @param command - The command string to parse (e.g., 'ls -la "my folder"'). * @returns An array of command parts with quotes removed. * * @example * parseCommand('shopify theme push --theme "My Theme Name"') // ['shopify', 'theme', 'push', '--theme', 'My Theme Name'] */ function parseCommand(command) { const result = []; let current = ''; let inQuote = null; for (const char of command) { if (inQuote) { if (char === inQuote) { // End of quoted section inQuote = null; } else { current += char; } } else if (char === '"' || char === "'") { // Start of quoted section inQuote = char; } else if (char === ' ' || char === '\t') { // Whitespace outside quotes - end current token if (current) { result.push(current); current = ''; } } else { current += char; } } // Don't forget the last token if (current) { result.push(current); } return result; } /** * Runs a command string asynchronously and returns stdout, stderr, and exit code. * Parses the command string into command and arguments (handles quoted strings). * Unlike captureOutput, this function does NOT throw on non-zero exit codes. * * @param command - Full command string to be executed (e.g., 'ls -la "my folder"'). * @param options - Optional settings for how to run the command. * @returns A promise that resolves with stdout, stderr, and exitCode. * * @example * ```typescript * const result = await captureCommandWithExitCode('shopify theme push --theme "My Theme"') * if (result.exitCode !== 0) { * console.error('Command failed:', result.stderr) * } * ``` */ export async function captureCommandWithExitCode(command, options) { const env = options?.env ?? process.env; if (shouldDisplayColors()) { env.FORCE_COLOR = '1'; } const executionCwd = options?.cwd ?? cwd(); const [cmd, ...args] = parseCommand(command); if (!cmd) { return { stdout: '', stderr: 'Empty command', exitCode: 1 }; } checkCommandSafety(cmd, { cwd: executionCwd }); const result = await execa(cmd, args, { env, cwd: executionCwd, reject: false, }); return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode ?? 0, }; } /** * Runs a command string asynchronously (parses command and arguments from the string). * * @param command - Full command string to be executed (e.g., 'ls -la "my folder"'). * @param options - Optional settings for how to run the command. */ export async function execCommand(command, options) { const env = options?.env ?? process.env; if (shouldDisplayColors()) { env.FORCE_COLOR = '1'; } const executionCwd = options?.cwd ?? cwd(); try { await execaCommand(command, { env, cwd: executionCwd, stdin: options?.stdin, stdout: options?.stdout === 'inherit' ? 'inherit' : undefined, stderr: options?.stderr === 'inherit' ? 'inherit' : undefined, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (processError) { if (options?.externalErrorHandler) { await options.externalErrorHandler(processError); } else { const abortError = new ExternalError(processError.message, command, []); abortError.stack = processError.stack; throw abortError; } } } /** * Runs a command asynchronously. * * @param command - Command to be executed. * @param args - Arguments to pass to the command. * @param options - Optional settings for how to run the command. */ export async function exec(command, args, options) { if (options) { // Windows opens a new console window when running a command in the background, so we disable it. const runningOnWindows = platformAndArch().platform === 'windows'; options.background = runningOnWindows ? false : options.background; } const commandProcess = buildExec(command, args, options); if (options?.background) { commandProcess.unref(); } if (options?.stderr && options.stderr !== 'inherit') { commandProcess.stderr?.pipe(options.stderr, { end: false }); } if (options?.stdout && options.stdout !== 'inherit') { commandProcess.stdout?.pipe(options.stdout, { end: false }); } let aborted = false; options?.signal?.addEventListener('abort', () => { const pid = commandProcess.pid; if (pid) { outputDebug(`Killing process ${pid}: ${command} ${args.join(' ')}`); aborted = true; treeKill(pid, 'SIGTERM'); } }); try { await commandProcess; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (processError) { // Windows will throw an error whenever the process is killed, no matter the reason. // The aborted flag tell use that we killed it, so we can ignore the error. if (aborted) return; if (options?.externalErrorHandler) { await options.externalErrorHandler(processError); } else { const abortError = new ExternalError(processError.message, command, args); abortError.stack = processError.stack; throw abortError; } } } /** * Runs a command asynchronously. * * @param command - Command to be executed. * @param args - Arguments to pass to the command. * @param options - Optional settings for how to run the command. * @param execaOptions - Options passed directly to execa. * @returns A promise for a result with stdout and stderr properties. */ function buildExec(command, args, options, execaOptions) { const env = options?.env ?? process.env; if (shouldDisplayColors()) { env.FORCE_COLOR = '1'; } const executionCwd = options?.cwd ?? cwd(); checkCommandSafety(command, { cwd: executionCwd }); const commandProcess = execa(command, args, { env, cwd: executionCwd, input: options?.input, stdio: options?.background ? 'ignore' : options?.stdio, stdin: options?.stdin, stdout: options?.stdout === 'inherit' ? 'inherit' : undefined, stderr: options?.stderr === 'inherit' ? 'inherit' : undefined, // Setting this to false makes it possible to kill the main process // and all its sub-processes with Ctrl+C on Windows windowsHide: false, detached: options?.background, cleanup: !options?.background, ...execaOptions, }); outputDebug(`Running system process${options?.background ? ' in background' : ''}: · Command: ${command} ${args.join(' ')} · Working directory: ${executionCwd} `); return commandProcess; } function checkCommandSafety(command, _options) { const pathIncludingLocal = `${_options.cwd}${delimiter}${process.env.PATH}`; const commandPath = which.sync(command, { nothrow: true, path: pathIncludingLocal, }); if (commandPath && dirname(commandPath) === _options.cwd) { const headline = ['Skipped run of unsecure binary', { command }, 'found in the current directory.']; const body = 'Please remove that file or review your current PATH.'; renderWarning({ headline, body }); throw new AbortError(headline, body); } } /** * Waits for a given number of seconds. * * @param seconds - Number of seconds to wait. * @returns A Promise resolving after the number of seconds. */ export async function sleep(seconds) { return new Promise((resolve) => { setTimeout(resolve, 1000 * seconds); }); } /** * Check if the standard input and output streams support prompting. * * @returns True if the standard input and output streams support prompting. */ export function terminalSupportsPrompting() { if (isTruthy(process.env.CI)) { return false; } return Boolean(process.stdin.isTTY && process.stdout.isTTY); } /** * Check if the current environment is a CI environment. * * @returns True if the current environment is a CI environment. */ export function isCI() { return isTruthy(process.env.CI); } /** * Check if the current environment is a WSL environment. * * @returns True if the current environment is a WSL environment. */ export async function isWsl() { 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() { 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 readStdinString() { if (!isStdinPiped()) { return undefined; } let data = ''; process.stdin.setEncoding('utf8'); for await (const chunk of process.stdin) { data += String(chunk); } return data.trim(); } //# sourceMappingURL=system.js.map