From da9266e6af06efc918e71a9fcbb4ad57a4f33564 Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Fri, 19 Dec 2025 08:14:00 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20terminal=20enviro?= =?UTF-8?q?nment=20services=20for=20improved=20interactivity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Console/Command/Theme/BuildCommand.php | 284 +----------------- .../Terminal/InteractiveTerminalChecker.php | 49 +++ .../Terminal/TerminalEnvironmentService.php | 213 +++++++++++++ 3 files changed, 273 insertions(+), 273 deletions(-) create mode 100644 src/Service/Terminal/InteractiveTerminalChecker.php create mode 100644 src/Service/Terminal/TerminalEnvironmentService.php diff --git a/src/Console/Command/Theme/BuildCommand.php b/src/Console/Command/Theme/BuildCommand.php index 9d2bd8c..26891fa 100644 --- a/src/Console/Command/Theme/BuildCommand.php +++ b/src/Console/Command/Theme/BuildCommand.php @@ -9,6 +9,8 @@ use OpenForgeProject\MageForge\Console\Command\AbstractCommand; use OpenForgeProject\MageForge\Model\ThemeList; use OpenForgeProject\MageForge\Model\ThemePath; +use OpenForgeProject\MageForge\Service\Terminal\InteractiveTerminalChecker; +use OpenForgeProject\MageForge\Service\Terminal\TerminalEnvironmentService; use OpenForgeProject\MageForge\Service\ThemeBuilder\BuilderPool; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; @@ -22,18 +24,19 @@ */ class BuildCommand extends AbstractCommand { - private array $originalEnv = []; - private array $secureEnvStorage = []; - /** * @param ThemePath $themePath * @param ThemeList $themeList * @param BuilderPool $builderPool + * @param TerminalEnvironmentService $environmentService + * @param InteractiveTerminalChecker $terminalChecker */ public function __construct( private readonly ThemePath $themePath, private readonly ThemeList $themeList, - private readonly BuilderPool $builderPool + private readonly BuilderPool $builderPool, + private readonly TerminalEnvironmentService $environmentService, + private readonly InteractiveTerminalChecker $terminalChecker ) { parent::__construct(); } @@ -66,14 +69,14 @@ protected function executeCommand(InputInterface $input, OutputInterface $output $options = array_map(fn($theme) => $theme->getCode(), $themes); // Check if we're in an interactive terminal environment - if (!$this->isInteractiveTerminal($output)) { + if (!$this->terminalChecker->isInteractiveTerminal($output)) { // Fallback for non-interactive environments $this->displayAvailableThemes($this->io); return Command::SUCCESS; } // Set environment variables for Laravel Prompts - $this->setPromptEnvironment(); + $this->environmentService->setPromptEnvironment(); $themeCodesPrompt = new MultiSelectPrompt( label: 'Select themes to build', @@ -88,7 +91,7 @@ protected function executeCommand(InputInterface $input, OutputInterface $output \Laravel\Prompts\Prompt::terminal()->restoreTty(); // Reset environment - $this->resetPromptEnvironment(); + $this->environmentService->resetPromptEnvironment(); // If no themes selected, show available themes if (empty($themeCodes)) { @@ -97,7 +100,7 @@ protected function executeCommand(InputInterface $input, OutputInterface $output } } catch (\Exception $e) { // Reset environment on exception - $this->resetPromptEnvironment(); + $this->environmentService->resetPromptEnvironment(); // Fallback if prompt fails $this->io->error('Interactive mode failed: ' . $e->getMessage()); $this->displayAvailableThemes($this->io); @@ -274,269 +277,4 @@ private function displayBuildSummary(SymfonyStyle $io, array $successList, float $io->newLine(); } - - /** - * Safely get environment variable with sanitization - * Uses secure method to avoid direct superglobal access - */ - private function getEnvVar(string $name): ?string - { - // Use a secure method to check environment variables - $value = $this->getSecureEnvironmentValue($name); - - if ($value === null || $value === '') { - return null; - } - - // Apply specific sanitization based on variable type - return $this->sanitizeEnvironmentValue($name, $value); - } - - /** - * Securely retrieve environment variable without direct superglobal access - */ - private function getSecureEnvironmentValue(string $name): ?string - { - // Validate the variable name first - if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $name)) { - return null; - } - - // Create a safe way to access environment without direct $_ENV access - $envVars = $this->getCachedEnvironmentVariables(); - return $envVars[$name] ?? null; - } - - /** - * Cache and filter environment variables safely - */ - private function getCachedEnvironmentVariables(): array - { - static $cachedEnv = null; - - if ($cachedEnv === null) { - $cachedEnv = []; - // Only cache the specific variables we need - $allowedVars = ['COLUMNS', 'LINES', 'TERM', 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', 'TEAMCITY_VERSION']; - - foreach ($allowedVars as $var) { - // Check secure storage first - if (isset($this->secureEnvStorage[$var])) { - $cachedEnv[$var] = $this->secureEnvStorage[$var]; - } else { - // Use array_key_exists to safely check without triggering warnings - $globalEnv = filter_input_array(INPUT_ENV) ?: []; - if (array_key_exists($var, $globalEnv)) { - $cachedEnv[$var] = (string) $globalEnv[$var]; - } - } - } - } - - return $cachedEnv; - } - - /** - * Sanitize environment value based on variable type - */ - private function sanitizeEnvironmentValue(string $name, string $value): ?string - { - return match($name) { - 'COLUMNS', 'LINES' => $this->sanitizeNumericValue($value), - 'TERM' => $this->sanitizeTermValue($value), - 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI' => $this->sanitizeBooleanValue($value), - 'JENKINS_URL', 'TEAMCITY_VERSION' => $this->sanitizeAlphanumericValue($value), - default => $this->sanitizeAlphanumericValue($value) - }; - } - - /** - * Sanitize numeric values (COLUMNS, LINES) - */ - private function sanitizeNumericValue(string $value): ?string - { - $filtered = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 9999]]); - return $filtered !== false ? (string) $filtered : null; - } - - /** - * Sanitize terminal type values - */ - private function sanitizeTermValue(string $value): ?string - { - $sanitized = preg_replace('/[^a-zA-Z0-9\-]/', '', $value); - return (strlen($sanitized) > 0 && strlen($sanitized) <= 50) ? $sanitized : null; - } - - /** - * Sanitize boolean-like values - */ - private function sanitizeBooleanValue(string $value): ?string - { - $cleaned = strtolower(trim($value)); - return in_array($cleaned, ['1', 'true', 'yes', 'on'], true) ? $cleaned : null; - } - - /** - * Sanitize alphanumeric values - */ - private function sanitizeAlphanumericValue(string $value): ?string - { - $sanitized = preg_replace('/[^\w\-.]/', '', $value); - return (strlen($sanitized) > 0 && strlen($sanitized) <= 255) ? $sanitized : null; - } - - /** - * Safely get server variable with sanitization - * Uses secure method to avoid direct superglobal access - */ - private function getServerVar(string $name): ?string - { - // Validate the variable name first - if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $name)) { - return null; - } - - // Use filter_input to safely access server variables without deprecated filter - $value = filter_input(INPUT_SERVER, $name); - - if ($value === null || $value === false || $value === '') { - return null; - } - - // Apply additional sanitization - return $this->sanitizeAlphanumericValue((string) $value); - } - - /** - * Safely set environment variable with validation - * Avoids direct $_ENV access and putenv usage - */ - private function setEnvVar(string $name, string $value): void - { - // Validate input parameters - if (empty($name) || !is_string($name)) { - return; - } - - // Validate variable name - if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $name)) { - return; - } - - // Sanitize the value based on variable type - $sanitizedValue = $this->sanitizeEnvironmentValue($name, $value); - - if ($sanitizedValue !== null) { - // Store in our safe cache instead of direct $_ENV manipulation - $this->setSecureEnvironmentValue($name, $sanitizedValue); - } - } - - /** - * Securely store environment variable without direct superglobal access - */ - private function setSecureEnvironmentValue(string $name, string $value): void - { - // For this implementation, we'll store values in a class property - // to avoid direct manipulation of superglobals - if (!isset($this->secureEnvStorage)) { - $this->secureEnvStorage = []; - } - $this->secureEnvStorage[$name] = $value; - } - - /** - * Clear the environment variable cache - */ - private function clearEnvironmentCache(): void - { - // Reset our secure storage - $this->secureEnvStorage = []; - } /** - * Check if the current environment supports interactive terminal input - * - * @param OutputInterface $output - * @return bool - */ - private function isInteractiveTerminal(OutputInterface $output): bool - { - // Check if output is decorated (supports ANSI codes) - if (!$output->isDecorated()) { - return false; - } - - // Check if STDIN is available and readable - if (!defined('STDIN') || !is_resource(STDIN)) { - return false; - } - - // Check for common non-interactive environments - $nonInteractiveEnvs = [ - 'CI', - 'GITHUB_ACTIONS', - 'GITLAB_CI', - 'JENKINS_URL', - 'TEAMCITY_VERSION', - ]; - - foreach ($nonInteractiveEnvs as $env) { - if ($this->getEnvVar($env) || $this->getServerVar($env)) { - return false; - } - } - - // Additional check: try to detect if running in a proper TTY - // This is a safer alternative to posix_isatty() - $sttyOutput = shell_exec('stty -g 2>/dev/null'); - return !empty($sttyOutput); - } - - /** - * Set environment for Laravel Prompts to work properly in Docker/DDEV - */ - private function setPromptEnvironment(): void - { - // Store original values for reset - $this->originalEnv = [ - 'COLUMNS' => $this->getEnvVar('COLUMNS'), - 'LINES' => $this->getEnvVar('LINES'), - 'TERM' => $this->getEnvVar('TERM'), - ]; - - // Set terminal environment variables using safe method - $this->setEnvVar('COLUMNS', '100'); - $this->setEnvVar('LINES', '40'); - $this->setEnvVar('TERM', 'xterm-256color'); - } - - /** - * Reset terminal environment after prompts - * Uses secure method without direct $_ENV or putenv - */ - private function resetPromptEnvironment(): void - { - // Reset environment variables to original state using secure methods - foreach ($this->originalEnv as $key => $value) { - if ($value === null) { - // Remove from our secure cache - $this->removeSecureEnvironmentValue($key); - } else { - // Restore original value using secure method - $this->setEnvVar($key, $value); - } - } - } - - /** - * Securely remove environment variable from cache - */ - private function removeSecureEnvironmentValue(string $name): void - { - // Remove the specific variable from our secure storage - unset($this->secureEnvStorage[$name]); - - // Clear the static cache to force refresh on next access - $this->clearEnvironmentCache(); - } } diff --git a/src/Service/Terminal/InteractiveTerminalChecker.php b/src/Service/Terminal/InteractiveTerminalChecker.php new file mode 100644 index 0000000..492559c --- /dev/null +++ b/src/Service/Terminal/InteractiveTerminalChecker.php @@ -0,0 +1,49 @@ +isDecorated()) { + return false; + } + + if (!defined('STDIN') || !is_resource(STDIN)) { + return false; + } + + $nonInteractiveEnvs = [ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'JENKINS_URL', + 'TEAMCITY_VERSION', + ]; + + foreach ($nonInteractiveEnvs as $env) { + if ($this->environmentService->getEnvVar($env) || $this->environmentService->getServerVar($env)) { + return false; + } + } + + $sttyOutput = shell_exec('stty -g 2>/dev/null'); + return !empty($sttyOutput); + } +} diff --git a/src/Service/Terminal/TerminalEnvironmentService.php b/src/Service/Terminal/TerminalEnvironmentService.php new file mode 100644 index 0000000..95f366b --- /dev/null +++ b/src/Service/Terminal/TerminalEnvironmentService.php @@ -0,0 +1,213 @@ +getSecureEnvironmentValue($name); + + if ($value === null || $value === '') { + return null; + } + + return $this->sanitizeEnvironmentValue($name, $value); + } + + /** + * Safely get server variable with sanitization + */ + public function getServerVar(string $name): ?string + { + if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $name)) { + return null; + } + + $value = filter_input(INPUT_SERVER, $name); + + if ($value === null || $value === false || $value === '') { + return null; + } + + return $this->sanitizeAlphanumericValue((string) $value); + } + + /** + * Safely set environment variable with validation + */ + public function setEnvVar(string $name, string $value): void + { + if (empty($name) || !is_string($name)) { + return; + } + + if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $name)) { + return; + } + + $sanitizedValue = $this->sanitizeEnvironmentValue($name, $value); + + if ($sanitizedValue !== null) { + $this->setSecureEnvironmentValue($name, $sanitizedValue); + } + } + + /** + * Set environment for Laravel Prompts + */ + public function setPromptEnvironment(): void + { + $this->originalEnv = [ + 'COLUMNS' => $this->getEnvVar('COLUMNS'), + 'LINES' => $this->getEnvVar('LINES'), + 'TERM' => $this->getEnvVar('TERM'), + ]; + + $this->setEnvVar('COLUMNS', '100'); + $this->setEnvVar('LINES', '40'); + $this->setEnvVar('TERM', 'xterm-256color'); + } + + /** + * Reset terminal environment after prompts + */ + public function resetPromptEnvironment(): void + { + foreach ($this->originalEnv as $key => $value) { + if ($value === null) { + $this->removeSecureEnvironmentValue($key); + } else { + $this->setEnvVar($key, $value); + } + } + } + + /** + * Securely retrieve environment variable + */ + private function getSecureEnvironmentValue(string $name): ?string + { + if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $name)) { + return null; + } + + $envVars = $this->getCachedEnvironmentVariables(); + return $envVars[$name] ?? null; + } + + /** + * Cache and filter environment variables safely + */ + private function getCachedEnvironmentVariables(): array + { + static $cachedEnv = null; + + if ($cachedEnv === null) { + $cachedEnv = []; + $allowedVars = ['COLUMNS', 'LINES', 'TERM', 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', 'TEAMCITY_VERSION']; + + foreach ($allowedVars as $var) { + if (isset($this->secureEnvStorage[$var])) { + $cachedEnv[$var] = $this->secureEnvStorage[$var]; + } else { + $globalEnv = filter_input_array(INPUT_ENV) ?: []; + if (array_key_exists($var, $globalEnv)) { + $cachedEnv[$var] = (string) $globalEnv[$var]; + } + } + } + } + + return $cachedEnv; + } + + /** + * Sanitize environment value based on variable type + */ + private function sanitizeEnvironmentValue(string $name, string $value): ?string + { + return match($name) { + 'COLUMNS', 'LINES' => $this->sanitizeNumericValue($value), + 'TERM' => $this->sanitizeTermValue($value), + 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI' => $this->sanitizeBooleanValue($value), + 'JENKINS_URL', 'TEAMCITY_VERSION' => $this->sanitizeAlphanumericValue($value), + default => $this->sanitizeAlphanumericValue($value) + }; + } + + /** + * Sanitize numeric values + */ + private function sanitizeNumericValue(string $value): ?string + { + $filtered = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 9999]]); + return $filtered !== false ? (string) $filtered : null; + } + + /** + * Sanitize terminal type values + */ + private function sanitizeTermValue(string $value): ?string + { + $sanitized = preg_replace('/[^a-zA-Z0-9\-]/', '', $value); + return (strlen($sanitized) > 0 && strlen($sanitized) <= 50) ? $sanitized : null; + } + + /** + * Sanitize boolean-like values + */ + private function sanitizeBooleanValue(string $value): ?string + { + $cleaned = strtolower(trim($value)); + return in_array($cleaned, ['1', 'true', 'yes', 'on'], true) ? $cleaned : null; + } + + /** + * Sanitize alphanumeric values + */ + private function sanitizeAlphanumericValue(string $value): ?string + { + $sanitized = preg_replace('/[^\w\-.]/', '', $value); + return (strlen($sanitized) > 0 && strlen($sanitized) <= 255) ? $sanitized : null; + } + + /** + * Securely store environment variable + */ + private function setSecureEnvironmentValue(string $name, string $value): void + { + if (!isset($this->secureEnvStorage)) { + $this->secureEnvStorage = []; + } + $this->secureEnvStorage[$name] = $value; + } + + /** + * Securely remove environment variable from cache + */ + private function removeSecureEnvironmentValue(string $name): void + { + unset($this->secureEnvStorage[$name]); + $this->clearEnvironmentCache(); + } + + /** + * Clear the environment variable cache + */ + private function clearEnvironmentCache(): void + { + $this->secureEnvStorage = []; + } +} From f554435ae69a3192af1121fd5321f83ce9a3f072 Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Fri, 19 Dec 2025 08:21:07 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20verbose=20output?= =?UTF-8?q?=20for=20theme=20auto-repair=20process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ThemeBuilder/HyvaThemes/Builder.php | 4 +++ .../ThemeBuilder/MagentoStandard/Builder.php | 10 +++++++- .../ThemeBuilder/TailwindCSS/Builder.php | 25 +++++++++++++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/Service/ThemeBuilder/HyvaThemes/Builder.php b/src/Service/ThemeBuilder/HyvaThemes/Builder.php index 7bc5f33..bc3f8c1 100644 --- a/src/Service/ThemeBuilder/HyvaThemes/Builder.php +++ b/src/Service/ThemeBuilder/HyvaThemes/Builder.php @@ -138,6 +138,10 @@ public function autoRepair(string $themePath, SymfonyStyle $io, OutputInterface { $tailwindPath = rtrim($themePath, '/') . '/web/tailwind'; + if ($isVerbose) { + $io->writeln(sprintf('Auto-repairing theme at: %s', $themePath), OutputInterface::VERBOSITY_VERBOSE); + } + // Check for node_modules directory if (!$this->fileDriver->isDirectory($tailwindPath . '/node_modules')) { if (!$this->installNodeModules($tailwindPath, $io, $isVerbose)) { diff --git a/src/Service/ThemeBuilder/MagentoStandard/Builder.php b/src/Service/ThemeBuilder/MagentoStandard/Builder.php index 2b81156..eb8ec8a 100644 --- a/src/Service/ThemeBuilder/MagentoStandard/Builder.php +++ b/src/Service/ThemeBuilder/MagentoStandard/Builder.php @@ -66,6 +66,7 @@ public function build(string $themePath, SymfonyStyle $io, OutputInterface $outp } // Deploy static content + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- basename is safe here for extracting theme name from validated path $themeCode = basename($themePath); if (!$this->staticContentDeployer->deploy($themeCode, $io, $output, $isVerbose)) { return false; @@ -81,6 +82,10 @@ public function build(string $themePath, SymfonyStyle $io, OutputInterface $outp public function autoRepair(string $themePath, SymfonyStyle $io, OutputInterface $output, bool $isVerbose): bool { + if ($isVerbose) { + $io->writeln(sprintf('Auto-repairing Magento standard theme at: %s', $themePath), OutputInterface::VERBOSITY_VERBOSE); + } + // Check for node_modules in root if (!$this->installNodeModulesIfMissing($io, $isVerbose)) { return false; @@ -193,7 +198,10 @@ public function watch(string $themePath, SymfonyStyle $io, OutputInterface $outp } try { - passthru('node_modules/.bin/grunt watch'); + if ($isVerbose) { + $io->text('Starting watch mode...'); + } + $this->shell->execute('node_modules/.bin/grunt watch'); } catch (\Exception $e) { $io->error('Failed to start watch mode: ' . $e->getMessage()); return false; diff --git a/src/Service/ThemeBuilder/TailwindCSS/Builder.php b/src/Service/ThemeBuilder/TailwindCSS/Builder.php index 3e03a7e..f6ee8f9 100644 --- a/src/Service/ThemeBuilder/TailwindCSS/Builder.php +++ b/src/Service/ThemeBuilder/TailwindCSS/Builder.php @@ -73,6 +73,7 @@ public function build(string $themePath, SymfonyStyle $io, OutputInterface $outp // Change to tailwind directory and run build $currentDir = getcwd(); + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary for npm to run in correct context chdir($tailwindPath); try { @@ -85,13 +86,16 @@ public function build(string $themePath, SymfonyStyle $io, OutputInterface $outp } } catch (\Exception $e) { $io->error('Failed to build custom TailwindCSS theme: ' . $e->getMessage()); + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary to restore original directory chdir($currentDir); return false; } + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary to restore original directory chdir($currentDir); // Deploy static content + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- basename is safe here for extracting theme name from validated path $themeCode = basename($themePath); if (!$this->staticContentDeployer->deploy($themeCode, $io, $output, $isVerbose)) { return false; @@ -109,6 +113,10 @@ public function autoRepair(string $themePath, SymfonyStyle $io, OutputInterface { $tailwindPath = rtrim($themePath, '/') . '/web/tailwind'; + if ($isVerbose) { + $io->writeln(sprintf('Auto-repairing theme at: %s', $themePath), OutputInterface::VERBOSITY_VERBOSE); + } + // Check for node_modules directory if (!$this->fileDriver->isDirectory($tailwindPath . '/node_modules')) { if (!$this->installNodeModules($tailwindPath, $io, $isVerbose)) { @@ -134,6 +142,7 @@ private function installNodeModules(string $tailwindPath, SymfonyStyle $io, bool } $currentDir = getcwd(); + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary for npm to run in correct context chdir($tailwindPath); try { @@ -148,10 +157,12 @@ private function installNodeModules(string $tailwindPath, SymfonyStyle $io, bool if ($isVerbose) { $io->success('Node modules installed successfully.'); } + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary to restore original directory chdir($currentDir); return true; } catch (\Exception $e) { $io->error('Failed to install node modules: ' . $e->getMessage()); + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary to restore original directory chdir($currentDir); return false; } @@ -163,6 +174,7 @@ private function installNodeModules(string $tailwindPath, SymfonyStyle $io, bool private function checkOutdatedPackages(string $tailwindPath, SymfonyStyle $io): void { $currentDir = getcwd(); + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary for npm to run in correct context chdir($tailwindPath); try { $outdated = $this->shell->execute('npm outdated --json'); @@ -173,6 +185,7 @@ private function checkOutdatedPackages(string $tailwindPath, SymfonyStyle $io): } catch (\Exception $e) { // Ignore errors from npm outdated as it returns non-zero when packages are outdated } + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary to restore original directory chdir($currentDir); } @@ -197,11 +210,19 @@ public function watch(string $themePath, SymfonyStyle $io, OutputInterface $outp return false; } + $currentDir = getcwd(); + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary for npm to run in correct context + chdir($tailwindPath); + try { - chdir($tailwindPath); - passthru('npm run watch'); + if ($isVerbose) { + $io->text('Starting watch mode...'); + } + $this->shell->execute('npm run watch'); } catch (\Exception $e) { $io->error('Failed to start watch mode: ' . $e->getMessage()); + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary to restore original directory + chdir($currentDir); return false; }