diff --git a/README.md b/README.md index 0738a6a..b3980a1 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Please ensure that your Magento installation meets this requirement before insta | `mageforge:theme:list` | Lists all available themes | `m:t:l` | | `mageforge:theme:build` | Builds selected themes (CSS/TailwindCSS) | `m:t:b`, `frontend:build` | | `mageforge:theme:watch` | Starts watch mode for theme development | `m:t:w`, `frontend:watch` | +| `mageforge:hyva:tokens` | Generates Hyvä design tokens CSS from token definitions | `m:h:t` | --- diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index 2573363..c4f7c82 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -47,6 +47,86 @@ MageForge streamlines Hyvä theme development with: - Automatic TailwindCSS compilation - PurgeCSS optimization - Component scanning +- Design tokens support for consistent theming + +#### Hyvä Design Tokens + +The `mageforge:hyva:tokens` command allows you to generate CSS custom properties from design token definitions, making it easier to maintain consistent design systems across your Hyvä theme. + +**Basic Usage:** +```bash +bin/magento mageforge:hyva:tokens +``` + +**Configuration Options:** + +Create a `hyva.config.json` file in your theme's `web/tailwind` directory to customize token generation: + +1. **Using a token file (default format):** + ```json + { + "tokens": { + "src": "design.tokens.json" + } + } + ``` + + Then create `design.tokens.json`: + ```json + { + "colors": { + "primary": { + "lighter": "oklch(62.3% 0.214 259.815)", + "DEFAULT": "oklch(54.6% 0.245 262.881)", + "darker": "oklch(37.9% 0.146 265.522)" + } + }, + "spacing": { + "small": "8px", + "medium": "16px", + "large": "24px" + } + } + ``` + +2. **Using Figma tokens:** + ```json + { + "tokens": { + "src": "acme.figma-tokens.json", + "format": "figma" + } + } + ``` + +3. **Using inline token values:** + ```json + { + "tokens": { + "values": { + "colors": { + "primary": { + "lighter": "oklch(62.3% 0.214 259.815)", + "DEFAULT": "oklch(54.6% 0.245 262.881)", + "darker": "oklch(37.9% 0.146 265.522)" + } + } + } + } + } + ``` + +4. **Using custom CSS selector (for Tailwind v3):** + ```json + { + "tokens": { + "src": "design.tokens.json", + "cssSelector": ":root" + } + } + ``` + +The command generates `generated/hyva-tokens.css` with CSS custom properties that you can import in your Tailwind configuration. ### Custom Tailwind CSS Implementations diff --git a/docs/commands.md b/docs/commands.md index c4301e7..4e0fe63 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -111,7 +111,81 @@ bin/magento mageforge:system:check --- -### 5. VersionCommand (`mageforge:version`) +### 5. HyvaTokensCommand (`mageforge:hyva:tokens`) + +**Purpose**: Generates Hyvä design tokens CSS from token definitions. + +**File**: `/src/Console/Command/Hyva/TokensCommand.php` + +**Dependencies**: +- `ThemePath` - Service to resolve theme paths +- `ThemeList` - Service to retrieve theme information +- `TokenProcessor` - Service to process and generate token CSS +- `HyvaBuilder` - Service to detect Hyvä themes + +**Usage**: +```bash +bin/magento mageforge:hyva:tokens [] +``` + +**Implementation Details**: +- If no theme code is provided, displays an interactive prompt to select from available Hyvä themes +- Verifies that the selected theme is a Hyvä theme +- Reads configuration from `hyva.config.json` or uses defaults +- Supports multiple token sources: + - `design.tokens.json` file (default) + - Custom token file specified in configuration + - Inline token values in `hyva.config.json` + - Figma tokens format +- Generates `generated/hyva-tokens.css` in the theme's `web/tailwind` directory +- Supports customization via `hyva.config.json`: + - `tokens.src`: Source file path (default: `design.tokens.json`) + - `tokens.format`: Token format - `default` or `figma` (default: `default`) + - `tokens.cssSelector`: CSS selector for generated tokens (default: `@theme` for Tailwind v4, use `:root` for v3) + - `tokens.values`: Inline token definitions + +**Configuration Examples**: + +Using a Figma tokens file: +```json +{ + "tokens": { + "src": "acme.figma-tokens.json", + "format": "figma" + } +} +``` + +Using inline token values: +```json +{ + "tokens": { + "values": { + "colors": { + "primary": { + "lighter": "oklch(62.3% 0.214 259.815)", + "DEFAULT": "oklch(54.6% 0.245 262.881)", + "darker": "oklch(37.9% 0.146 265.522)" + } + } + } + } +} +``` + +Using custom CSS selector for Tailwind v3: +```json +{ + "tokens": { + "src": "design.tokens.json", + "cssSelector": ":root" + } +} +``` + +--- + +### 6. VersionCommand (`mageforge:version`) **Purpose**: Displays the current and latest version of the MageForge module. diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..3fc5120 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,17 @@ + + + MageForge PHP_CodeSniffer Configuration + + + src + + + */vendor/* + */generated/* + + + + + + + diff --git a/src/Console/Command/Hyva/TokensCommand.php b/src/Console/Command/Hyva/TokensCommand.php new file mode 100644 index 0000000..6300337 --- /dev/null +++ b/src/Console/Command/Hyva/TokensCommand.php @@ -0,0 +1,185 @@ +setName($this->getCommandName('hyva', 'tokens')) + ->setDescription('Generate Hyva design tokens CSS from token definitions') + ->addArgument( + 'themeCode', + InputArgument::OPTIONAL, + 'Theme code to generate tokens for (format: Vendor/theme)' + ); + } + + /** + * {@inheritdoc} + */ + protected function executeCommand(InputInterface $input, OutputInterface $_output): int + { + $themeCode = $input->getArgument('themeCode'); + + // If no theme code provided, select interactively + if (empty($themeCode)) { + $themeCode = $this->themeSelectionService->selectHyvaTheme($this->io); + if ($themeCode === null) { + return Command::FAILURE; + } + } + + // Validate theme + $themePath = $this->validateTheme($themeCode); + if ($themePath === null) { + return Command::FAILURE; + } + + // Process tokens and return result + return $this->processTokens($themeCode, $themePath); + } + + /** + * Validate theme exists and is a Hyva theme + * + * @param string $themeCode + * @return string|null + */ + private function validateTheme(string $themeCode): ?string + { + // Validate theme + $themePath = $this->themeSelectionService->validateTheme($themeCode, true); + if ($themePath === null) { + $this->io->error("Theme $themeCode is not installed or is not a Hyvä theme."); + return null; + } + + return $themePath; + } + + /** + * Process tokens and display results + * + * @param string $themeCode + * @param string $themePath + * @return int + */ + private function processTokens(string $themeCode, string $themePath): int + { + // Check if this is a vendor theme and inform user + if ($this->configReader->isVendorTheme($themePath)) { + $this->io->warning([ + 'This is a vendor theme. The generated CSS will be stored in:', + 'var/view_preprocessed/hyva-tokens/[vendor]/[theme]/', + '', + '⚠️ Important: This location is temporary and may be cleared by cache operations.', + 'Consider copying the tokens.css to your custom theme or project.', + ]); + $this->io->newLine(); + } + + $this->io->text("Processing design tokens for theme: $themeCode"); + $result = $this->tokenProcessor->process($themePath); + + if ($result['success']) { + return $this->handleSuccess($result, $themePath); + } + + return $this->handleFailure($result); + } + + /** + * Handle successful token processing + * + * @param array $result + * @param string $themePath + * @return int + */ + private function handleSuccess(array $result, string $themePath): int + { + $this->io->newLine(); + $this->io->success($result['message']); + $this->io->writeln("Output file: {$result['outputPath']}"); + $this->io->newLine(); + $this->io->text('ℹ️ Make sure to import this file in your Tailwind CSS configuration.'); + + if ($this->configReader->isVendorTheme($themePath)) { + $this->io->newLine(); + $this->io->note([ + 'Since this is a vendor theme, consider one of these options:', + '1. Copy the generated CSS to your custom theme', + '2. Reference it in your Tailwind config with an absolute path', + '3. Add it to your build process to regenerate after cache:clean', + ]); + } + + return Command::SUCCESS; + } + + /** + * Handle token processing failure + * + * @param array $result + * @return int + */ + private function handleFailure(array $result): int + { + $this->io->error($result['message']); + $this->io->newLine(); + $this->io->text('ℹ️ To use this command, you need one of the following:'); + $this->io->listing([ + 'A design.tokens.json file in the theme\'s web/tailwind directory', + 'A custom token file specified in hyva.config.json', + 'Inline token values in hyva.config.json', + ]); + $this->io->newLine(); + $this->io->text('Example hyva.config.json with inline tokens:'); + $this->io->text(<<getNodeVersion(); - $mysqlVersion = $this->getShortMysqlVersion(); - $dbType = $this->getDatabaseType(); - $osInfo = $this->getShortOsInfo(); + $nodeVersion = $this->systemInfoService->getNodeVersion(); + $mysqlVersion = $this->databaseInfoService->getMysqlVersion(); + $dbType = $this->databaseInfoService->getDatabaseType(); + $osInfo = $this->systemInfoService->getOsInfo(); $magentoVersion = $this->productMetadata->getVersion(); - $latestLtsNodeVersion = $this->escaper->escapeHtml($this->getLatestLtsNodeVersion()); - $composerVersion = $this->getComposerVersion(); - $npmVersion = $this->getNpmVersion(); - $gitVersion = $this->getGitVersion(); - $xdebugStatus = $this->getXdebugStatus(); - $redisStatus = $this->getRedisStatus(); - $searchEngineStatus = $this->getSearchEngineStatus(); - $phpExtensions = $this->getImportantPhpExtensions(); - $diskSpace = $this->getDiskSpace(); + $latestLtsNodeVersion = $this->escaper->escapeHtml($this->systemInfoService->getLatestLtsNodeVersion()); + $composerVersion = $this->systemInfoService->getComposerVersion(); + $npmVersion = $this->systemInfoService->getNpmVersion(); + $gitVersion = $this->systemInfoService->getGitVersion(); + $xdebugStatus = $this->systemInfoService->getXdebugStatus(); + $redisStatus = $this->systemInfoService->getRedisStatus(); + $searchEngineStatus = $this->searchEngineInfoService->getSearchEngineStatus(); + $phpExtensions = $this->systemInfoService->getImportantPhpExtensions(); + $diskSpace = $this->systemInfoService->getDiskSpace(); $nodeVersionDisplay = Comparator::lessThan($nodeVersion, $latestLtsNodeVersion) ? "$nodeVersion (Latest LTS: $latestLtsNodeVersion)" @@ -71,7 +78,7 @@ protected function executeCommand(InputInterface $input, OutputInterface $output $this->io->table( ['Component', 'Version/Status'], [ - ['PHP', $phpVersion . ' (Memory limit: ' . $this->getPhpMemoryLimit() . ')'], + ['PHP', $phpVersion . ' (Memory limit: ' . $this->systemInfoService->getPhpMemoryLimit() . ')'], new TableSeparator(), ['Composer', $composerVersion], new TableSeparator(), @@ -104,678 +111,4 @@ protected function executeCommand(InputInterface $input, OutputInterface $output return Cli::RETURN_SUCCESS; } - - /** - * Get Node.js version - * - * @return string - */ - private function getNodeVersion(): string - { - exec('node -v 2>/dev/null', $output, $returnCode); - return $returnCode === 0 && !empty($output) ? trim($output[0], 'v') : 'Not installed'; - } - - /** - * Get latest LTS Node.js version - * - * @return string - */ - private function getLatestLtsNodeVersion(): string - { - try { - $nodeData = file_get_contents(self::NODE_LTS_URL); - if ($nodeData === false) { - return 'Unknown'; - } - - $nodes = json_decode($nodeData, true); - if (!is_array($nodes)) { - return 'Unknown'; - } - - foreach ($nodes as $node) { - if (isset($node['lts']) && $node['lts'] !== false) { - return trim($node['version'], 'v'); - } - } - return 'Unknown'; - } catch (\Exception $e) { - return 'Unknown'; - } - } - - /** - * Get MySQL version - * - * @return string - */ - private function getShortMysqlVersion(): string - { - // Try different methods to get MySQL version - $version = $this->getMysqlVersionViaMagento(); - if (!empty($version)) { - return $version; - } - - $version = $this->getMysqlVersionViaClient(); - if (!empty($version)) { - return $version; - } - - $version = $this->getMysqlVersionViaPdo(); - if (!empty($version)) { - return $version; - } - - return 'Unknown'; - } - - /** - * Get MySQL version via Magento connection - * - * @return string|null - */ - private function getMysqlVersionViaMagento(): ?string - { - try { - $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); - $resource = $objectManager->get(\Magento\Framework\App\ResourceConnection::class); - $connection = $resource->getConnection(); - $version = $connection->fetchOne('SELECT VERSION()'); - - return !empty($version) ? $version : null; - } catch (\Exception $e) { - return null; - } - } - - /** - * Get MySQL version via command line client - * - * @return string|null - */ - private function getMysqlVersionViaClient(): ?string - { - exec('mysql --version 2>/dev/null', $output, $returnCode); - if ($returnCode === 0 && !empty($output)) { - $versionString = $output[0]; - preg_match('/Distrib ([0-9.]+)/', $versionString, $matches); - - return isset($matches[1]) ? $matches[1] : null; - } - - return null; - } - - /** - * Get MySQL version via PDO connection - * - * @return string|null - */ - private function getMysqlVersionViaPdo(): ?string - { - try { - $config = $this->getDatabaseConfig(); - - // Default values if nothing is found - $host = $config['host'] ?? 'localhost'; - $port = $config['port'] ?? '3306'; - $user = $config['user'] ?? 'root'; - $pass = $config['pass'] ?? ''; - - $dsn = "mysql:host=$host;port=$port"; - $pdo = new \PDO($dsn, $user, $pass, [\PDO::ATTR_TIMEOUT => 1]); - $version = $pdo->query('SELECT VERSION()')->fetchColumn(); - - return !empty($version) ? $version : null; - } catch (\Exception $e) { - return null; - } - } - - /** - * Get database configuration from environment variables - * - * @return array - */ - private function getDatabaseConfig(): array - { - $envMapping = [ - 'host' => ['DB_HOST', 'MYSQL_HOST', 'MAGENTO_DB_HOST'], - 'port' => ['DB_PORT', 'MYSQL_PORT', 'MAGENTO_DB_PORT', '3306'], - 'user' => ['DB_USER', 'MYSQL_USER', 'MAGENTO_DB_USER'], - 'pass' => ['DB_PASSWORD', 'MYSQL_PASSWORD', 'MAGENTO_DB_PASSWORD'], - 'name' => ['DB_NAME', 'MYSQL_DATABASE', 'MAGENTO_DB_NAME'], - ]; - - $config = []; - foreach ($envMapping as $key => $envVars) { - foreach ($envVars as $env) { - $value = $this->getEnvironmentVariable($env); - if ($value !== null) { - $config[$key] = $value; - break; - } - } - } - - return $config; - } - - /** - * Get database type - * - * @return string - */ - private function getDatabaseType(): string - { - return 'MySQL'; // Only MySQL is supported in the current version - } - - /** - * Get OS info - * - * @return string - */ - private function getShortOsInfo(): string - { - return php_uname('s') . ' ' . php_uname('r'); - } - - /** - * Get Composer version - * - * @return string - */ - private function getComposerVersion(): string - { - exec('composer --version 2>/dev/null', $output, $returnCode); - if ($returnCode !== 0 || empty($output)) { - return 'Not installed'; - } - - preg_match('/Composer version ([^ ]+)/', $output[0], $matches); - return isset($matches[1]) ? $matches[1] : 'Unknown'; - } - - /** - * Get NPM version - * - * @return string - */ - private function getNpmVersion(): string - { - exec('npm --version 2>/dev/null', $output, $returnCode); - return $returnCode === 0 && !empty($output) ? trim($output[0]) : 'Not installed'; - } - - /** - * Get Git version - * - * @return string - */ - private function getGitVersion(): string - { - exec('git --version 2>/dev/null', $output, $returnCode); - if ($returnCode !== 0 || empty($output)) { - return 'Not installed'; - } - - preg_match('/git version (.+)/', $output[0], $matches); - return isset($matches[1]) ? $matches[1] : 'Unknown'; - } - - /** - * Get Xdebug status - * - * @return string - */ - private function getXdebugStatus(): string - { - return extension_loaded('xdebug') ? 'Enabled' : 'Disabled'; - } - - /** - * Get Redis status - * - * @return string - */ - private function getRedisStatus(): string - { - return extension_loaded('redis') ? 'Enabled' : 'Disabled'; - } - - /** - * Get search engine status - * - * @return string - */ - private function getSearchEngineStatus(): string - { - // Method 1: Check Magento configuration - $magentoConfigResult = $this->getSearchEngineFromMagentoConfig(); - if ($magentoConfigResult) { - return $magentoConfigResult; - } - - // Method 2: Check PHP extensions - $extensionResult = $this->checkSearchEngineExtensions(); - if ($extensionResult) { - return $extensionResult; - } - - // Method 3: Check HTTP connection - $connectionResult = $this->checkSearchEngineConnections(); - if ($connectionResult) { - return $connectionResult; - } - - return 'Not Available'; - } - - /** - * Check search engine from Magento configuration - * - * @return string|null - */ - private function getSearchEngineFromMagentoConfig(): ?string - { - try { - $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); - - // First try via deployment config - $configResult = $this->checkSearchEngineViaDeploymentConfig($objectManager); - if ($configResult !== null) { - return $configResult; - } - - // Then try via engine resolver - $resolverResult = $this->checkSearchEngineViaEngineResolver($objectManager); - if ($resolverResult !== null) { - return $resolverResult; - } - } catch (\Exception $e) { - // Ignore general exceptions - } - - return null; - } - - /** - * Check search engine via Magento deployment config - * - * @param \Magento\Framework\ObjectManagerInterface $objectManager - * @return string|null - */ - private function checkSearchEngineViaDeploymentConfig($objectManager): ?string - { - try { - $deploymentConfig = $objectManager->get(\Magento\Framework\App\DeploymentConfig::class); - $engineConfig = $deploymentConfig->get('system/search/engine'); - - if (!empty($engineConfig)) { - $host = $deploymentConfig->get('system/search/engine_host') ?: 'localhost'; - $port = $deploymentConfig->get('system/search/engine_port') ?: '9200'; - - $url = "http://{$host}:{$port}"; - if ($this->testElasticsearchConnection($url)) { - return ucfirst($engineConfig) . ' (Magento config)'; - } - - return ucfirst($engineConfig) . ' (Configured but not reachable)'; - } - } catch (\Exception $e) { - // Ignore specific exceptions - } - - return null; - } - - /** - * Check search engine via Magento engine resolver - * - * @param \Magento\Framework\ObjectManagerInterface $objectManager - * @return string|null - */ - private function checkSearchEngineViaEngineResolver($objectManager): ?string - { - try { - $engineResolver = $objectManager->get(\Magento\Framework\Search\EngineResolverInterface::class); - if ($engineResolver) { - $currentEngine = $engineResolver->getCurrentSearchEngine(); - if (!empty($currentEngine)) { - return ucfirst($currentEngine) . ' (Magento config)'; - } - } - } catch (\Exception $e) { - // Ignore specific exceptions - } - - return null; - } - - /** - * Check for search engine PHP extensions - * - * @return string|null - */ - private function checkSearchEngineExtensions(): ?string - { - if (extension_loaded('elasticsearch')) { - return 'Elasticsearch Available (PHP Extension)'; - } - - if (extension_loaded('opensearch')) { - return 'OpenSearch Available (PHP Extension)'; - } - - return null; - } - - /** - * Check available search engine connections - * - * @return string|null - */ - private function checkSearchEngineConnections(): ?string - { - try { - $elasticHosts = $this->getSearchEngineHosts(); - - foreach ($elasticHosts as $url) { - $info = $this->testElasticsearchConnection($url); - if ($info !== false) { - return $this->formatSearchEngineVersion($info); - } - } - } catch (\Exception $e) { - // Ignore - } - - return null; - } - - /** - * Get potential search engine hosts - * - * @return array - */ - private function getSearchEngineHosts(): array - { - $elasticHosts = [ - 'http://localhost:9200', - 'http://127.0.0.1:9200', - 'http://elasticsearch:9200', - 'http://opensearch:9200' - ]; - - $envHosts = [ - 'ELASTICSEARCH_HOST', 'ES_HOST', 'OPENSEARCH_HOST' - ]; - - foreach ($envHosts as $envVar) { - $hostValue = $this->getEnvironmentVariable($envVar); - if (!empty($hostValue)) { - $port = $this->getEnvironmentVariable('ELASTICSEARCH_PORT') ?? - $this->getEnvironmentVariable('ES_PORT') ?? - $this->getEnvironmentVariable('OPENSEARCH_PORT') ?? '9200'; - $elasticHosts[] = "http://{$hostValue}:{$port}"; - } - } - - return $elasticHosts; - } - - /** - * Format search engine version output - * - * @param array $info - * @return string - */ - private function formatSearchEngineVersion(array $info): string - { - if (isset($info['version']['distribution']) && $info['version']['distribution'] === 'opensearch') { - return 'OpenSearch ' . $info['version']['number']; - } - - if (isset($info['version']['number'])) { - return 'Elasticsearch ' . $info['version']['number']; - } - - return 'Search Engine Available'; - } /** - * Test Elasticsearch connection and return version info - * - * @param string $url - * @return array|bool - */ - private function testElasticsearchConnection(string $url) - { - try { - // First attempt: Try using Magento's HTTP client - $magentoClientResult = $this->tryMagentoHttpClient($url); - if ($magentoClientResult !== null) { - return $magentoClientResult; - } - - // No fallback to native approaches anymore - rely on Magento's HTTP client only - // This avoids using discouraged functions - } catch (\Exception $e) { - // Ignore exceptions and return false - } - - return false; - } - - /** - * Try to connect using Magento's HTTP client - * - * @param string $url - * @return array|null - */ - private function tryMagentoHttpClient(string $url): ?array - { - try { - $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); - $httpClientFactory = $objectManager->get(\Magento\Framework\HTTP\ClientFactory::class); - $httpClient = $httpClientFactory->create(); - $httpClient->setTimeout(2); - $httpClient->get($url); - - $status = $httpClient->getStatus(); - $response = $httpClient->getBody(); - - if ($status === 200 && !empty($response)) { - $data = json_decode($response, true); - if (is_array($data)) { - return $data; - } - } - } catch (\Exception $e) { - // Ignore exceptions - } - - return null; - } - - /** - * Get important PHP extensions - * - * @return array - */ - private function getImportantPhpExtensions(): array - { - $extensions = []; - $requiredExtensions = [ - 'curl', 'dom', 'fileinfo', 'gd', 'intl', 'json', 'mbstring', - 'openssl', 'pdo_mysql', 'simplexml', 'soap', 'xml', 'zip' - ]; - - foreach ($requiredExtensions as $ext) { - $status = extension_loaded($ext) ? 'Enabled' : 'Disabled'; - $extensions[] = [$ext, $status]; - } - - return $extensions; - } - - /** - * Get PHP memory limit - * - * @return string - */ - private function getPhpMemoryLimit(): string - { - return ini_get('memory_limit'); - } - - /** - * Get disk space - * - * @return string - */ - private function getDiskSpace(): string - { - $totalSpace = disk_total_space('.'); - $freeSpace = disk_free_space('.'); - - $totalGB = round($totalSpace / 1024 / 1024 / 1024, 2); - $freeGB = round($freeSpace / 1024 / 1024 / 1024, 2); - $usedGB = round($totalGB - $freeGB, 2); - $usedPercent = round(($usedGB / $totalGB) * 100, 2); - - return "$usedGB GB / $totalGB GB ($usedPercent%)"; - } /** - * Safely get environment variable value - * - * @param string $name Environment variable name - * @param string|null $default Default value if not found - * @return string|null - */ - private function getEnvironmentVariable(string $name, ?string $default = null): ?string - { - // Try Magento-specific methods first - $magentoValue = $this->getMagentoEnvironmentValue($name); - if ($magentoValue !== null) { - return $magentoValue; - } - - // Try system environment variables - $systemValue = $this->getSystemEnvironmentValue($name); - if ($systemValue !== null) { - return $systemValue; - } - - return $default; - } - - /** - * Get environment variable from Magento - * - * @param string $name Environment variable name - * @return string|null - */ - private function getMagentoEnvironmentValue(string $name): ?string - { - try { - $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); - - // Try via deployment config - $deploymentValue = $this->getValueFromDeploymentConfig($objectManager, $name); - if ($deploymentValue !== null) { - return $deploymentValue; - } - - // Try via environment service - $serviceValue = $this->getValueFromEnvironmentService($objectManager, $name); - if ($serviceValue !== null) { - return $serviceValue; - } - } catch (\Exception $e) { - // Ignore exceptions - } - - return null; - } - - /** - * Get value from Magento deployment config - * - * @param \Magento\Framework\ObjectManagerInterface $objectManager - * @param string $name - * @return string|null - */ - private function getValueFromDeploymentConfig($objectManager, string $name): ?string - { - try { - $deploymentConfig = $objectManager->get(\Magento\Framework\App\DeploymentConfig::class); - $envValue = $deploymentConfig->get('system/default/environment/' . $name); - if ($envValue !== null) { - return (string)$envValue; - } - } catch (\Exception $e) { - // Ignore exceptions - } - - return null; - } - - /** - * Get value from Magento environment service - * - * @param \Magento\Framework\ObjectManagerInterface $objectManager - * @param string $name - * @return string|null - */ - private function getValueFromEnvironmentService($objectManager, string $name): ?string - { - try { - $environmentService = $objectManager->get(\Magento\Framework\App\EnvironmentInterface::class); - $method = 'get' . str_replace(' ', '', ucwords(str_replace('_', ' ', strtolower($name)))); - if (method_exists($environmentService, $method)) { - $value = $environmentService->$method(); - if ($value !== null) { - return (string)$value; - } - } - } catch (\Exception $e) { - // Ignore exceptions - } - - return null; - } - - /** - * Get environment variable from the system - * - * @param string $name Environment variable name - * @return string|null - */ - private function getSystemEnvironmentValue(string $name): ?string - { - // Use ini_get for certain system variables as a safer alternative - if (in_array($name, ['memory_limit', 'max_execution_time'])) { - $value = ini_get($name); - if ($value !== false) { - return $value; - } - } - - // Use Environment class if available (Magento 2.3+) - try { - $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); - $env = $objectManager->get(\Magento\Framework\App\Environment::class); - if (method_exists($env, 'getEnv')) { - $value = $env->getEnv($name); - if ($value !== false && $value !== null) { - return $value; - } - } - } catch (\Exception $e) { - // Continue with other methods - } - - return null; - } } diff --git a/src/Console/Command/Theme/BuildCommand.php b/src/Console/Command/Theme/BuildCommand.php index 9d2bd8c..0f30900 100644 --- a/src/Console/Command/Theme/BuildCommand.php +++ b/src/Console/Command/Theme/BuildCommand.php @@ -9,6 +9,7 @@ use OpenForgeProject\MageForge\Console\Command\AbstractCommand; use OpenForgeProject\MageForge\Model\ThemeList; use OpenForgeProject\MageForge\Model\ThemePath; +use OpenForgeProject\MageForge\Service\EnvironmentService; use OpenForgeProject\MageForge\Service\ThemeBuilder\BuilderPool; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; @@ -22,18 +23,17 @@ */ class BuildCommand extends AbstractCommand { - private array $originalEnv = []; - private array $secureEnvStorage = []; - /** * @param ThemePath $themePath * @param ThemeList $themeList * @param BuilderPool $builderPool + * @param EnvironmentService $environmentService */ public function __construct( private readonly ThemePath $themePath, private readonly ThemeList $themeList, - private readonly BuilderPool $builderPool + private readonly BuilderPool $builderPool, + private readonly EnvironmentService $environmentService, ) { parent::__construct(); } @@ -63,17 +63,18 @@ protected function executeCommand(InputInterface $input, OutputInterface $output if (empty($themeCodes)) { $themes = $this->themeList->getAllThemes(); + // phpcs:ignore Security.BadFunctions.CallbackFunctions -- array_map with safe callback is acceptable $options = array_map(fn($theme) => $theme->getCode(), $themes); // Check if we're in an interactive terminal environment - if (!$this->isInteractiveTerminal($output)) { + if (!$this->environmentService->isInteractiveTerminal()) { // 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 +89,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 +98,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); @@ -163,20 +164,32 @@ private function processBuildThemes( $currentTheme = $index + 1; // Show which theme is currently being built $themeNameCyan = sprintf("%s", $themeCode); - $spinner = new Spinner(sprintf("Building %s (%d of %d) ...", $themeNameCyan, $currentTheme, $totalThemes)); + $spinner = new Spinner( + sprintf("Building %s (%d of %d) ...", $themeNameCyan, $currentTheme, $totalThemes) + ); $success = false; - $spinner->spin(function() use ($themeCode, $io, $output, $isVerbose, &$successList, &$success) { + $spinner->spin(function () use ($themeCode, $io, $output, $isVerbose, &$successList, &$success) { $success = $this->processTheme($themeCode, $io, $output, $isVerbose, $successList); return true; }); if ($success) { // Show that the theme was successfully built - $io->writeln(sprintf(" Building %s (%d of %d) ... done", $themeNameCyan, $currentTheme, $totalThemes)); + $io->writeln(sprintf( + " Building %s (%d of %d) ... done", + $themeNameCyan, + $currentTheme, + $totalThemes + )); } else { // Show that an error occurred while building the theme - $io->writeln(sprintf(" Building %s (%d of %d) ... failed", $themeNameCyan, $currentTheme, $totalThemes)); + $io->writeln(sprintf( + " Building %s (%d of %d) ... failed", + $themeNameCyan, + $currentTheme, + $totalThemes + )); } } } @@ -274,269 +287,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/DatabaseInfoService.php b/src/Service/DatabaseInfoService.php new file mode 100644 index 0000000..40d5014 --- /dev/null +++ b/src/Service/DatabaseInfoService.php @@ -0,0 +1,273 @@ +getMysqlVersionViaMagento(); + if (!empty($version)) { + return $version; + } + + $version = $this->getMysqlVersionViaClient(); + if (!empty($version)) { + return $version; + } + + $version = $this->getMysqlVersionViaPdo(); + if (!empty($version)) { + return $version; + } + + return 'Unknown'; + } + + /** + * Get database type + * + * @return string + */ + public function getDatabaseType(): string + { + return 'MySQL'; // Only MySQL is supported in the current version + } + + /** + * Get MySQL version via Magento connection + * + * @return string|null + */ + private function getMysqlVersionViaMagento(): ?string + { + try { + $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); + $resource = $objectManager->get(\Magento\Framework\App\ResourceConnection::class); + $connection = $resource->getConnection(); + $version = $connection->fetchOne('SELECT VERSION()'); + + return !empty($version) ? $version : null; + } catch (\Exception $e) { + return null; + } + } + + /** + * Get MySQL version via command line client + * + * @return string|null + */ + private function getMysqlVersionViaClient(): ?string + { + // phpcs:ignore Security.BadFunctions.SystemExecFunctions -- exec with static command is safe + exec('mysql --version 2>/dev/null', $output, $returnCode); + if ($returnCode === 0 && !empty($output)) { + $versionString = $output[0]; + preg_match('/Distrib ([0-9.]+)/', $versionString, $matches); + + return isset($matches[1]) ? $matches[1] : null; + } + + return null; + } + + /** + * Get MySQL version via PDO connection + * + * @return string|null + */ + private function getMysqlVersionViaPdo(): ?string + { + try { + $config = $this->getDatabaseConfig(); + + // Default values if nothing is found + $host = $config['host'] ?? 'localhost'; + $port = $config['port'] ?? '3306'; + $user = $config['user'] ?? 'root'; + $pass = $config['pass'] ?? ''; + + $dsn = "mysql:host=$host;port=$port"; + $pdo = new \PDO($dsn, $user, $pass, [\PDO::ATTR_TIMEOUT => 1]); + $version = $pdo->query('SELECT VERSION()')->fetchColumn(); + + return !empty($version) ? $version : null; + } catch (\Exception $e) { + return null; + } + } + + /** + * Get database configuration from environment variables + * + * @return array + */ + private function getDatabaseConfig(): array + { + $envMapping = [ + 'host' => ['DB_HOST', 'MYSQL_HOST', 'MAGENTO_DB_HOST'], + 'port' => ['DB_PORT', 'MYSQL_PORT', 'MAGENTO_DB_PORT', '3306'], + 'user' => ['DB_USER', 'MYSQL_USER', 'MAGENTO_DB_USER'], + 'pass' => ['DB_PASSWORD', 'MYSQL_PASSWORD', 'MAGENTO_DB_PASSWORD'], + 'name' => ['DB_NAME', 'MYSQL_DATABASE', 'MAGENTO_DB_NAME'], + ]; + + $config = []; + foreach ($envMapping as $key => $envVars) { + foreach ($envVars as $env) { + $value = $this->getEnvironmentVariable($env); + if ($value !== null) { + $config[$key] = $value; + break; + } + } + } + + return $config; + } + + /** + * Safely get environment variable value + * + * @param string $name Environment variable name + * @param string|null $default Default value if not found + * @return string|null + */ + private function getEnvironmentVariable(string $name, ?string $default = null): ?string + { + // Try Magento-specific methods first + $magentoValue = $this->getMagentoEnvironmentValue($name); + if ($magentoValue !== null) { + return $magentoValue; + } + + // Try system environment variables + $systemValue = $this->getSystemEnvironmentValue($name); + if ($systemValue !== null) { + return $systemValue; + } + + return $default; + } + + /** + * Get environment variable from Magento + * + * @param string $name Environment variable name + * @return string|null + */ + private function getMagentoEnvironmentValue(string $name): ?string + { + try { + $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); + + // Try via deployment config + $deploymentValue = $this->getValueFromDeploymentConfig($objectManager, $name); + if ($deploymentValue !== null) { + return $deploymentValue; + } + + // Try via environment service + $serviceValue = $this->getValueFromEnvironmentService($objectManager, $name); + if ($serviceValue !== null) { + return $serviceValue; + } + } catch (\Exception $e) { + // Ignore exceptions + } + + return null; + } + + /** + * Get value from Magento deployment config + * + * @param \Magento\Framework\ObjectManagerInterface $objectManager + * @param string $name + * @return string|null + */ + private function getValueFromDeploymentConfig($objectManager, string $name): ?string + { + try { + $deploymentConfig = $objectManager->get(\Magento\Framework\App\DeploymentConfig::class); + $envValue = $deploymentConfig->get('system/default/environment/' . $name); + if ($envValue !== null) { + return (string)$envValue; + } + } catch (\Exception $e) { + // Ignore exceptions + } + + return null; + } + + /** + * Get value from Magento environment service + * + * @param \Magento\Framework\ObjectManagerInterface $objectManager + * @param string $name + * @return string|null + */ + private function getValueFromEnvironmentService($objectManager, string $name): ?string + { + try { + $environmentService = $objectManager->get(\Magento\Framework\App\EnvironmentInterface::class); + $method = 'get' . str_replace(' ', '', ucwords(str_replace('_', ' ', strtolower($name)))); + if (method_exists($environmentService, $method)) { + $value = $environmentService->$method(); + if ($value !== null) { + return (string)$value; + } + } + } catch (\Exception $e) { + // Ignore exceptions + } + + return null; + } + + /** + * Get environment variable from the system + * + * @param string $name Environment variable name + * @return string|null + */ + private function getSystemEnvironmentValue(string $name): ?string + { + // Use ini_get for certain system variables as a safer alternative + if (in_array($name, ['memory_limit', 'max_execution_time'])) { + $value = ini_get($name); + if ($value !== false) { + return $value; + } + } + + // Use Environment class if available (Magento 2.3+) + try { + $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); + $env = $objectManager->get(\Magento\Framework\App\Environment::class); + if (method_exists($env, 'getEnv')) { + $value = $env->getEnv($name); + if ($value !== false && $value !== null) { + return $value; + } + } + } catch (\Exception $e) { + // Continue with other methods + } + + return null; + } +} diff --git a/src/Service/DependencyChecker.php b/src/Service/DependencyChecker.php index 6a03d67..fdcc00f 100644 --- a/src/Service/DependencyChecker.php +++ b/src/Service/DependencyChecker.php @@ -7,7 +7,6 @@ use Magento\Framework\Filesystem\Driver\File; use Symfony\Component\Console\Style\SymfonyStyle; use Magento\Framework\Shell; - class DependencyChecker { private const PACKAGE_JSON = 'package.json'; diff --git a/src/Service/EnvironmentService.php b/src/Service/EnvironmentService.php new file mode 100644 index 0000000..e1f2303 --- /dev/null +++ b/src/Service/EnvironmentService.php @@ -0,0 +1,268 @@ +getEnvVar($env) || $this->getServerVar($env)) { + return false; + } + } + + // Additional check: try to detect if running in a proper TTY + // phpcs:ignore Magento2.Security.InsecureFunction -- Safe static 'stty -g' usage for TTY detection with error redirection; no user input + $sttyOutput = shell_exec('stty -g 2>/dev/null'); + return !empty($sttyOutput); + } + + /** + * Set environment for Laravel Prompts to work properly in Docker/DDEV + */ + public 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 + */ + public 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); + } + } + } + + /** + * Safely get environment variable with sanitization + */ + 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 + */ + 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 + */ + 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; + } + + /** + * 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(); + } + + /** + * Clear the environment variable cache + */ + private function clearEnvironmentCache(): void + { + // Reset our secure storage + $this->secureEnvStorage = []; + } +} diff --git a/src/Service/HyvaTokens/ConfigReader.php b/src/Service/HyvaTokens/ConfigReader.php new file mode 100644 index 0000000..cba3998 --- /dev/null +++ b/src/Service/HyvaTokens/ConfigReader.php @@ -0,0 +1,180 @@ +getConfigPath($themePath); + + // Default configuration + $config = [ + 'src' => self::DEFAULT_SOURCE, + 'format' => self::DEFAULT_FORMAT, + 'cssSelector' => self::DEFAULT_CSS_SELECTOR, + 'values' => null, + ]; + + if ($this->fileDriver->isExists($configPath)) { + $configContent = $this->fileDriver->fileGetContents($configPath); + + try { + $jsonConfig = json_decode($configContent, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \Exception("Invalid JSON in configuration file: " . $e->getMessage()); + } + + if (isset($jsonConfig['tokens'])) { + $tokensConfig = $jsonConfig['tokens']; + + // Override with config file values + if (isset($tokensConfig['src'])) { + $config['src'] = $tokensConfig['src']; + } + if (isset($tokensConfig['format'])) { + $config['format'] = $tokensConfig['format']; + } + if (isset($tokensConfig['cssSelector'])) { + $config['cssSelector'] = $tokensConfig['cssSelector']; + } + if (isset($tokensConfig['values'])) { + $config['values'] = $tokensConfig['values']; + } + } + } + + return $config; + } + + /** + * Get the path to hyva.config.json + * + * @param string $themePath + * @return string + */ + private function getConfigPath(string $themePath): string + { + return rtrim($themePath, '/') . '/web/tailwind/hyva.config.json'; + } + + /** + * Get the path to the token source file + * + * @param string $themePath + * @param string $sourceFile + * @return string + */ + public function getTokenSourcePath(string $themePath, string $sourceFile): string + { + return rtrim($themePath, '/') . '/web/tailwind/' . ltrim($sourceFile, '/'); + } + + /** + * Get the output path for generated CSS + * + * @param string $themePath + * @return string + */ + public function getOutputPath(string $themePath): string + { + // Check if theme is in vendor directory + if ($this->isVendorTheme($themePath)) { + return $this->getVendorThemeOutputPath($themePath); + } + + return rtrim($themePath, '/') . '/web/tailwind/generated/hyva-tokens.css'; + } + + /** + * Check if theme path is in vendor directory + * + * @param string $themePath + * @return bool + */ + public function isVendorTheme(string $themePath): bool + { + return str_contains($themePath, '/vendor/'); + } + + /** + * Get output path for vendor themes in var/view_preprocessed + * + * @param string $themePath + * @return string + */ + private function getVendorThemeOutputPath(string $themePath): string + { + // Extract theme identifier from path + // e.g., /path/to/vendor/hyva-themes/magento2-default-theme + // -> hyva-themes/magento2-default-theme + preg_match('#/vendor/([^/]+/[^/]+)#', $themePath, $matches); + + if (isset($matches[1])) { + $themeIdentifier = $matches[1]; + } else { + // Fallback: derive identifier from the last two path segments + $normalizedPath = str_replace('\\', '/', rtrim($themePath, '/')); + $pathSegments = explode('/', $normalizedPath); + $segmentCount = count($pathSegments); + + if ($segmentCount >= 2) { + $themeIdentifier = $pathSegments[$segmentCount - 2] + . '/' . $pathSegments[$segmentCount - 1]; + } else { + // Ensure a unique, deterministic identifier as a last resort + $themeIdentifier = 'theme_' . md5($themePath); + } + } + + $varPath = $this->directoryList->getPath(DirectoryList::VAR_DIR); + return $varPath + . '/view_preprocessed/hyva-tokens/' + . $themeIdentifier + . '/hyva-tokens.css'; + } + + /** + * Check if token source exists (file or inline values) + * + * @param string $themePath + * @param array $config + * @return bool + */ + public function hasTokenSource(string $themePath, array $config): bool + { + // Check for inline values + if (!empty($config['values'])) { + return true; + } + + // Check for source file + $sourcePath = $this->getTokenSourcePath($themePath, $config['src']); + return $this->fileDriver->isExists($sourcePath); + } +} diff --git a/src/Service/HyvaTokens/CssGenerator.php b/src/Service/HyvaTokens/CssGenerator.php new file mode 100644 index 0000000..91c1fc1 --- /dev/null +++ b/src/Service/HyvaTokens/CssGenerator.php @@ -0,0 +1,86 @@ + $value) { + $cssVarName = '--' . $name; + // Sanitize value to prevent CSS syntax issues + $sanitizedValue = $this->sanitizeCssValue($value); + $css .= " {$cssVarName}: {$sanitizedValue};\n"; + } + + $css .= "}\n"; + + return $css; + } + + /** + * Sanitize CSS value to prevent syntax issues + * + * @param string $value + * @return string + */ + private function sanitizeCssValue(string $value): string + { + // Remove newlines and control characters + $value = preg_replace('/[\r\n\t\x00-\x1F\x7F]/', '', $value); + + // Remove potentially problematic characters + // Allow: alphanumeric, spaces, parentheses, commas, periods, hyphens, underscores, + // percent signs, hash symbols, and forward slashes + $sanitized = preg_replace('/[^\w\s(),.%#\/-]/', '', $value); + + // Trim whitespace + return trim($sanitized ?? ''); + } + + /** + * Write CSS to file + * + * @param string $content + * @param string $outputPath + * @return bool + * @throws \Exception + */ + public function write(string $content, string $outputPath): bool + { + // Ensure the directory exists + $directory = \dirname($outputPath); + + if (!$this->fileDriver->isDirectory($directory)) { + $this->fileDriver->createDirectory($directory, 0750); + } + + try { + $this->fileDriver->filePutContents($outputPath, $content); + return true; + } catch (\Exception $e) { + throw new \Exception("Failed to write CSS file: " . $e->getMessage()); + } + } +} diff --git a/src/Service/HyvaTokens/TokenParser.php b/src/Service/HyvaTokens/TokenParser.php new file mode 100644 index 0000000..412c8ae --- /dev/null +++ b/src/Service/HyvaTokens/TokenParser.php @@ -0,0 +1,154 @@ +normalizeTokens($inlineValues, $format); + } + + // Otherwise, read from file + if ($filePath === null || !$this->fileDriver->isFile($filePath)) { + throw new \Exception("Token source file not found: " . ($filePath ?? 'null')); + } + + $content = $this->fileDriver->fileGetContents($filePath); + + try { + $tokens = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \Exception("Invalid JSON in token file: " . $e->getMessage()); + } + + return $this->normalizeTokens($tokens, $format); + } + + /** + * Normalize tokens to a flat structure + * + * @param array $tokens + * @param string $format + * @return array + */ + private function normalizeTokens(array $tokens, string $format): array + { + if ($format === 'figma') { + return $this->normalizeFigmaTokens($tokens); + } + + return $this->normalizeDefaultTokens($tokens); + } + + /** + * Normalize default format tokens to flat structure + * + * @param array $tokens + * @param string $prefix + * @return array + */ + private function normalizeDefaultTokens(array $tokens, string $prefix = ''): array + { + $flattened = []; + + foreach ($tokens as $key => $value) { + $currentKey = $prefix ? $prefix . '-' . $key : $key; + + if (is_array($value)) { + // Check if this is a token value with a special key (e.g., DEFAULT) + if (isset($value['DEFAULT']) || $this->isLeafNode($value)) { + foreach ($value as $subKey => $subValue) { + if ($subKey === 'DEFAULT') { + $flattened[$currentKey] = $subValue; + } else { + $flattened[$currentKey . '-' . $subKey] = $subValue; + } + } + } else { + // Recursively flatten nested structures + $flattened = array_merge( + $flattened, + $this->normalizeDefaultTokens($value, $currentKey) + ); + } + } else { + $flattened[$currentKey] = $value; + } + } + + return $flattened; + } + + /** + * Check if a node is a leaf node (contains actual token values) + * + * @param array $node + * @return bool + */ + private function isLeafNode(array $node): bool + { + foreach ($node as $value) { + if (is_array($value)) { + return false; + } + } + return true; + } + + /** + * Normalize Figma format tokens to flat structure + * + * @param array $tokens + * @param string $prefix + * @return array + */ + private function normalizeFigmaTokens(array $tokens, string $prefix = ''): array + { + $flattened = []; + + foreach ($tokens as $key => $value) { + $currentKey = $prefix ? $prefix . '-' . $key : $key; + + if (is_array($value)) { + // Figma tokens have a specific structure with $value or value keys + if (isset($value['$value'])) { + $flattened[$currentKey] = $value['$value']; + } elseif (isset($value['value'])) { + $flattened[$currentKey] = $value['value']; + } else { + // Recursively flatten nested structures + $flattened = array_merge( + $flattened, + $this->normalizeFigmaTokens($value, $currentKey) + ); + } + } + } + + return $flattened; + } +} diff --git a/src/Service/HyvaTokens/TokenProcessor.php b/src/Service/HyvaTokens/TokenProcessor.php new file mode 100644 index 0000000..faf3ddc --- /dev/null +++ b/src/Service/HyvaTokens/TokenProcessor.php @@ -0,0 +1,80 @@ +configReader->getConfig($themePath); + + // Check if token source exists + if (!$this->configReader->hasTokenSource($themePath, $config)) { + return [ + 'success' => false, + 'message' => "No token source found. Create a {$config['src']} file " . + "or add 'values' to hyva.config.json", + 'outputPath' => null, + ]; + } + + // Determine source path or inline values + $sourcePath = null; + $inlineValues = $config['values']; + + if ($inlineValues === null) { + $sourcePath = $this->configReader->getTokenSourcePath($themePath, $config['src']); + } + + // Parse tokens + $tokens = $this->tokenParser->parse($sourcePath, $inlineValues, $config['format']); + + if (empty($tokens)) { + return [ + 'success' => false, + 'message' => 'No tokens found in source', + 'outputPath' => null, + ]; + } + + // Generate CSS + $css = $this->cssGenerator->generate($tokens, $config['cssSelector']); + + // Write to output file + $outputPath = $this->configReader->getOutputPath($themePath); + $this->cssGenerator->write($css, $outputPath); + + return [ + 'success' => true, + 'message' => "Successfully generated tokens CSS", + 'outputPath' => $outputPath, + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => 'Error processing tokens: ' . $e->getMessage(), + 'outputPath' => null, + ]; + } + } +} diff --git a/src/Service/SearchEngineInfoService.php b/src/Service/SearchEngineInfoService.php new file mode 100644 index 0000000..e857e48 --- /dev/null +++ b/src/Service/SearchEngineInfoService.php @@ -0,0 +1,274 @@ +getSearchEngineFromMagentoConfig(); + if ($magentoConfigResult) { + return $magentoConfigResult; + } + + // Method 2: Check PHP extensions + $extensionResult = $this->checkSearchEngineExtensions(); + if ($extensionResult) { + return $extensionResult; + } + + // Method 3: Check HTTP connection + $connectionResult = $this->checkSearchEngineConnections(); + if ($connectionResult) { + return $connectionResult; + } + + return 'Not Available'; + } + + /** + * Check search engine from Magento configuration + * + * @return string|null + */ + private function getSearchEngineFromMagentoConfig(): ?string + { + try { + $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); + + // First try via deployment config + $configResult = $this->checkSearchEngineViaDeploymentConfig($objectManager); + if ($configResult !== null) { + return $configResult; + } + + // Then try via engine resolver + $resolverResult = $this->checkSearchEngineViaEngineResolver($objectManager); + if ($resolverResult !== null) { + return $resolverResult; + } + } catch (\Exception $e) { + // Ignore general exceptions + } + + return null; + } + + /** + * Check search engine via Magento deployment config + * + * @param \Magento\Framework\ObjectManagerInterface $objectManager + * @return string|null + */ + private function checkSearchEngineViaDeploymentConfig($objectManager): ?string + { + try { + $deploymentConfig = $objectManager->get(\Magento\Framework\App\DeploymentConfig::class); + $engineConfig = $deploymentConfig->get('system/search/engine'); + + if (!empty($engineConfig)) { + $host = $deploymentConfig->get('system/search/engine_host') ?: 'localhost'; + $port = $deploymentConfig->get('system/search/engine_port') ?: '9200'; + + $url = "http://{$host}:{$port}"; + if ($this->testElasticsearchConnection($url)) { + return ucfirst($engineConfig) . ' (Magento config)'; + } + + return ucfirst($engineConfig) . ' (Configured but not reachable)'; + } + } catch (\Exception $e) { + // Ignore specific exceptions + } + + return null; + } + + /** + * Check search engine via Magento engine resolver + * + * @param \Magento\Framework\ObjectManagerInterface $objectManager + * @return string|null + */ + private function checkSearchEngineViaEngineResolver($objectManager): ?string + { + try { + $engineResolver = $objectManager->get(\Magento\Framework\Search\EngineResolverInterface::class); + if ($engineResolver) { + $currentEngine = $engineResolver->getCurrentSearchEngine(); + if (!empty($currentEngine)) { + return ucfirst($currentEngine) . ' (Magento config)'; + } + } + } catch (\Exception $e) { + // Ignore specific exceptions + } + + return null; + } + + /** + * Check for search engine PHP extensions + * + * @return string|null + */ + private function checkSearchEngineExtensions(): ?string + { + if (extension_loaded('elasticsearch')) { + return 'Elasticsearch Available (PHP Extension)'; + } + + if (extension_loaded('opensearch')) { + return 'OpenSearch Available (PHP Extension)'; + } + + return null; + } + + /** + * Check available search engine connections + * + * @return string|null + */ + private function checkSearchEngineConnections(): ?string + { + try { + $elasticHosts = $this->getSearchEngineHosts(); + + foreach ($elasticHosts as $url) { + $info = $this->testElasticsearchConnection($url); + if ($info !== false) { + return $this->formatSearchEngineVersion($info); + } + } + } catch (\Exception $e) { + // Ignore + } + + return null; + } + + /** + * Get potential search engine hosts + * + * @return array + */ + private function getSearchEngineHosts(): array + { + $elasticHosts = [ + 'http://localhost:9200', + 'http://127.0.0.1:9200', + 'http://elasticsearch:9200', + 'http://opensearch:9200' + ]; + + $envHosts = [ + 'ELASTICSEARCH_HOST', 'ES_HOST', 'OPENSEARCH_HOST' + ]; + + foreach ($envHosts as $envVar) { + $hostValue = $this->getEnvironmentVariable($envVar); + if (!empty($hostValue)) { + $port = $this->getEnvironmentVariable('ELASTICSEARCH_PORT') ?? + $this->getEnvironmentVariable('ES_PORT') ?? + $this->getEnvironmentVariable('OPENSEARCH_PORT') ?? '9200'; + $elasticHosts[] = "http://{$hostValue}:{$port}"; + } + } + + return $elasticHosts; + } + + /** + * Format search engine version output + * + * @param array $info + * @return string + */ + private function formatSearchEngineVersion(array $info): string + { + if (isset($info['version']['distribution']) && $info['version']['distribution'] === 'opensearch') { + return 'OpenSearch ' . $info['version']['number']; + } + + if (isset($info['version']['number'])) { + return 'Elasticsearch ' . $info['version']['number']; + } + + return 'Search Engine Available'; + } + + /** + * Test Elasticsearch connection and return version info + * + * @param string $url + * @return array|bool + */ + private function testElasticsearchConnection(string $url) + { + try { + $httpClient = $this->httpClientFactory->create(); + $httpClient->setTimeout(2); + $httpClient->get($url); + + $status = $httpClient->getStatus(); + $response = $httpClient->getBody(); + + if ($status === 200 && !empty($response)) { + $data = json_decode($response, true); + if (is_array($data)) { + return $data; + } + } + } catch (\Exception $e) { + // Ignore exceptions + } + + return false; + } + + /** + * Get environment variable value + * + * @param string $name Environment variable name + * @return string|null + */ + private function getEnvironmentVariable(string $name): ?string + { + try { + $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); + $env = $objectManager->get(\Magento\Framework\App\Environment::class); + if (method_exists($env, 'getEnv')) { + $value = $env->getEnv($name); + if ($value !== false && $value !== null) { + return $value; + } + } + } catch (\Exception $e) { + // Ignore + } + + return null; + } +} diff --git a/src/Service/StaticContentDeployer.php b/src/Service/StaticContentDeployer.php index 5d99bae..e9f6a02 100644 --- a/src/Service/StaticContentDeployer.php +++ b/src/Service/StaticContentDeployer.php @@ -36,6 +36,7 @@ public function deploy( $io->text('Deploying static content...'); } + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- escapeshellarg is the correct function for sanitizing shell arguments $sanitizedThemeCode = escapeshellarg($themeCode); $shellOutput = $this->shell->execute( "php bin/magento setup:static-content:deploy -t %s -f --quiet", diff --git a/src/Service/SystemInfoService.php b/src/Service/SystemInfoService.php new file mode 100644 index 0000000..a1eb1c7 --- /dev/null +++ b/src/Service/SystemInfoService.php @@ -0,0 +1,180 @@ +/dev/null', $output, $returnCode); + return $returnCode === 0 && !empty($output) ? trim($output[0], 'v') : 'Not installed'; + } + + /** + * Get latest LTS Node.js version + * + * @return string + */ + public function getLatestLtsNodeVersion(): string + { + try { + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- file_get_contents with static HTTPS URL is safe + $nodeData = file_get_contents(self::NODE_LTS_URL); + if ($nodeData === false) { + return 'Unknown'; + } + + $nodes = json_decode($nodeData, true); + if (!is_array($nodes)) { + return 'Unknown'; + } + + foreach ($nodes as $node) { + if (isset($node['lts']) && $node['lts'] !== false) { + return trim($node['version'], 'v'); + } + } + return 'Unknown'; + } catch (\Exception $e) { + return 'Unknown'; + } + } + + /** + * Get Composer version + * + * @return string + */ + public function getComposerVersion(): string + { + // phpcs:ignore Security.BadFunctions.SystemExecFunctions -- exec with static command is safe + exec('composer --version 2>/dev/null', $output, $returnCode); + if ($returnCode !== 0 || empty($output)) { + return 'Not installed'; + } + + preg_match('/Composer version ([^ ]+)/', $output[0], $matches); + return isset($matches[1]) ? $matches[1] : 'Unknown'; + } + + /** + * Get NPM version + * + * @return string + */ + public function getNpmVersion(): string + { + // phpcs:ignore Security.BadFunctions.SystemExecFunctions -- exec with static command is safe + exec('npm --version 2>/dev/null', $output, $returnCode); + return $returnCode === 0 && !empty($output) ? trim($output[0]) : 'Not installed'; + } + + /** + * Get Git version + * + * @return string + */ + public function getGitVersion(): string + { + // phpcs:ignore Security.BadFunctions.SystemExecFunctions -- exec with static command is safe + exec('git --version 2>/dev/null', $output, $returnCode); + if ($returnCode !== 0 || empty($output)) { + return 'Not installed'; + } + + preg_match('/git version (.+)/', $output[0], $matches); + return isset($matches[1]) ? $matches[1] : 'Unknown'; + } + + /** + * Get Xdebug status + * + * @return string + */ + public function getXdebugStatus(): string + { + return extension_loaded('xdebug') ? 'Enabled' : 'Disabled'; + } + + /** + * Get Redis status + * + * @return string + */ + public function getRedisStatus(): string + { + return extension_loaded('redis') ? 'Enabled' : 'Disabled'; + } + + /** + * Get OS info + * + * @return string + */ + public function getOsInfo(): string + { + return php_uname('s') . ' ' . php_uname('r'); + } + + /** + * Get important PHP extensions + * + * @return array + */ + public function getImportantPhpExtensions(): array + { + $extensions = []; + $requiredExtensions = [ + 'curl', 'dom', 'fileinfo', 'gd', 'intl', 'json', 'mbstring', + 'openssl', 'pdo_mysql', 'simplexml', 'soap', 'xml', 'zip' + ]; + + foreach ($requiredExtensions as $ext) { + $status = extension_loaded($ext) ? 'Enabled' : 'Disabled'; + $extensions[] = [$ext, $status]; + } + + return $extensions; + } + + /** + * Get PHP memory limit + * + * @return string + */ + public function getPhpMemoryLimit(): string + { + return ini_get('memory_limit'); + } + + /** + * Get disk space + * + * @return string + */ + public function getDiskSpace(): string + { + $totalSpace = disk_total_space('.'); + $freeSpace = disk_free_space('.'); + + $totalGB = round($totalSpace / 1024 / 1024 / 1024, 2); + $freeGB = round($freeSpace / 1024 / 1024 / 1024, 2); + $usedGB = round($totalGB - $freeGB, 2); + $usedPercent = round(($usedGB / $totalGB) * 100, 2); + + return "$usedGB GB / $totalGB GB ($usedPercent%)"; + } +} diff --git a/src/Service/ThemeBuilder/HyvaThemes/Builder.php b/src/Service/ThemeBuilder/HyvaThemes/Builder.php index 7bc5f33..369346b 100644 --- a/src/Service/ThemeBuilder/HyvaThemes/Builder.php +++ b/src/Service/ThemeBuilder/HyvaThemes/Builder.php @@ -73,6 +73,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; @@ -115,6 +116,7 @@ private function buildTheme(string $themePath, SymfonyStyle $io, bool $isVerbose // 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 { @@ -125,10 +127,12 @@ private function buildTheme(string $themePath, SymfonyStyle $io, bool $isVerbose if ($isVerbose) { $io->success('Hyvä theme build completed successfully.'); } + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary to restore original directory chdir($currentDir); return true; } catch (\Exception $e) { $io->error('Failed to build Hyvä theme: ' . $e->getMessage()); + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary to restore original directory chdir($currentDir); return false; } @@ -163,6 +167,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 { @@ -179,10 +184,12 @@ private function installNodeModules(string $tailwindPath, SymfonyStyle $io, bool $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; } @@ -194,6 +201,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 { @@ -206,6 +214,7 @@ private function checkOutdatedPackages(string $tailwindPath, SymfonyStyle $io): // 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); } @@ -225,12 +234,21 @@ 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()); return false; + } finally { + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary to restore original directory + chdir($currentDir); } return true; diff --git a/src/Service/ThemeBuilder/MagentoStandard/Builder.php b/src/Service/ThemeBuilder/MagentoStandard/Builder.php index 2b81156..832d414 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; @@ -193,7 +194,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..03e4e7e 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; @@ -134,6 +138,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 +153,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 +170,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 +181,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,12 +206,21 @@ 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()); return false; + } finally { + // phpcs:ignore MEQP1.Security.DiscouragedFunction -- chdir is necessary to restore original directory + chdir($currentDir); } return true; diff --git a/src/Service/ThemeSelectionService.php b/src/Service/ThemeSelectionService.php new file mode 100644 index 0000000..cd89fec --- /dev/null +++ b/src/Service/ThemeSelectionService.php @@ -0,0 +1,152 @@ +getHyvaThemes(); + + if (empty($hyvaThemes)) { + $io->error('No Hyvä themes found in this installation.'); + return null; + } + + // Check if we're in an interactive terminal environment + if (!$this->environmentService->isInteractiveTerminal()) { + $this->displayAvailableThemes($io, $hyvaThemes); + return null; + } + + return $this->promptForTheme($io, $hyvaThemes); + } + + /** + * Validate theme exists and optionally check if it's a Hyva theme + * + * @param string $themeCode + * @param bool $requireHyva + * @return string|null + */ + public function validateTheme(string $themeCode, bool $requireHyva = false): ?string + { + $themePath = $this->themePath->getPath($themeCode); + if ($themePath === null) { + return null; + } + + if ($requireHyva && !$this->hyvaBuilder->detect($themePath)) { + return null; + } + + return $themePath; + } + + /** + * Get list of Hyva themes + * + * @return array + */ + private function getHyvaThemes(): array + { + $allThemes = $this->themeList->getAllThemes(); + $hyvaThemes = []; + + foreach ($allThemes as $theme) { + $themePath = $this->themePath->getPath($theme->getCode()); + if ($themePath && $this->hyvaBuilder->detect($themePath)) { + $hyvaThemes[] = $theme; + } + } + + return $hyvaThemes; + } + + /** + * Display available themes for non-interactive environments + * + * @param SymfonyStyle $io + * @param array $hyvaThemes + * @return void + */ + private function displayAvailableThemes(SymfonyStyle $io, array $hyvaThemes): void + { + $io->info('Available Hyvä themes:'); + foreach ($hyvaThemes as $theme) { + $io->writeln(' - ' . $theme->getCode()); + } + $io->newLine(); + $io->info('Usage: bin/magento mageforge:hyva:tokens '); + } + + /** + * Prompt user to select a theme + * + * @param SymfonyStyle $io + * @param array $hyvaThemes + * @return string|null + */ + private function promptForTheme(SymfonyStyle $io, array $hyvaThemes): ?string + { + $options = []; + foreach ($hyvaThemes as $theme) { + $options[] = $theme->getCode(); + } + + // Set environment variables for Laravel Prompts + $this->environmentService->setPromptEnvironment(); + + $themeCodePrompt = new SelectPrompt( + label: 'Select Hyvä theme to generate tokens for', + options: $options, + hint: 'Arrow keys to navigate, Enter to confirm' + ); + + try { + $selectedTheme = $themeCodePrompt->prompt(); + \Laravel\Prompts\Prompt::terminal()->restoreTty(); + + // Reset environment + $this->environmentService->resetPromptEnvironment(); + + return $selectedTheme; + } catch (\Exception $e) { + // Reset environment on exception + $this->environmentService->resetPromptEnvironment(); + $io->error('Interactive mode failed: ' . $e->getMessage()); + return null; + } + } +} diff --git a/src/etc/di.xml b/src/etc/di.xml index a30e947..9d16891 100644 --- a/src/etc/di.xml +++ b/src/etc/di.xml @@ -24,6 +24,9 @@ OpenForgeProject\MageForge\Console\Command\Theme\WatchCommand + OpenForgeProject\MageForge\Console\Command\Hyva\TokensCommand