diff --git a/.github/workflows/magento-compatibility.yml b/.github/workflows/magento-compatibility.yml index 4cff845d..1a44a6ea 100644 --- a/.github/workflows/magento-compatibility.yml +++ b/.github/workflows/magento-compatibility.yml @@ -15,10 +15,10 @@ jobs: fail-fast: false matrix: include: - - magento-version: "2.4.7-p9" + - magento-version: "2.4.7-p10" php-version: "8.3" search-engine-name: "opensearch" - - magento-version: "2.4.8-p4" + - magento-version: "2.4.8-p5" php-version: "8.4" search-engine-name: "opensearch" - magento-version: "2.4.9-beta1" @@ -147,6 +147,7 @@ jobs: bin/magento mageforge:theme:inspector --help bin/magento mageforge:hyva:compatibility:check --help bin/magento mageforge:hyva:tokens --help + bin/magento mageforge:theme:npm-check --help echo "Verify command aliases work:" bin/magento m:s:v --help @@ -156,10 +157,12 @@ jobs: bin/magento m:t:w --help bin/magento m:t:c --help bin/magento m:h:c:c --help + bin/magento m:t:nc --help bin/magento frontend:list --help bin/magento frontend:build --help bin/magento frontend:watch --help bin/magento frontend:clean --help + bin/magento frontend:npm-check --help bin/magento hyva:check --help bin/magento hyva:tokens --help diff --git a/src/Console/Command/Theme/NpmCheckCommand.php b/src/Console/Command/Theme/NpmCheckCommand.php new file mode 100644 index 00000000..4e81fd74 --- /dev/null +++ b/src/Console/Command/Theme/NpmCheckCommand.php @@ -0,0 +1,341 @@ +setName($this->getCommandName('theme', 'npm-check')) + ->setDescription('Checks npm dependencies of Magento themes for outdated packages and vulnerabilities') + ->addArgument( + 'themeCodes', + InputArgument::IS_ARRAY, + 'Theme codes to check (format: Vendor/theme, Vendor, ...)', + ) + ->setAliases(['m:t:nc', 'frontend:npm-check']); + } + + /** + * Execute command. + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function executeCommand(InputInterface $input, OutputInterface $output): int + { + /** @var array $themeCodes */ + $themeCodes = $input->getArgument('themeCodes'); + + if (!empty($themeCodes)) { + $themeCodes = $this->resolveVendorThemes($themeCodes, $this->themeList); + + if (empty($themeCodes)) { + return Command::SUCCESS; + } + } + + $isVerbose = $this->isVerbose($output); + + if (empty($themeCodes)) { + $themes = $this->themeList->getAllThemes(); + $options = array_values(array_map(fn($theme) => $theme->getCode(), $themes)); + + if (!$this->isInteractiveTerminal($output)) { + $this->io->info('No theme specified. Usage: bin/magento mageforge:theme:npm-check '); + return Command::SUCCESS; + } + + $this->setPromptEnvironment(); + + $prompt = new MultiSearchPrompt( + label: 'Select themes to check', + options: fn(string $value) => empty($value) + ? $options + : array_values(array_filter($options, fn($option) => stripos((string) $option, $value) !== false)), + placeholder: 'Type to search theme...', + hint: 'Type to search, arrow keys to navigate, Space to toggle, Enter to confirm', + required: false, + ); + + try { + $themeCodes = $prompt->prompt(); + \Laravel\Prompts\Prompt::terminal()->restoreTty(); + $this->resetPromptEnvironment(); + + if (empty($themeCodes)) { + $this->io->info('No themes selected.'); + return Command::SUCCESS; + } + } catch (\Exception $e) { + $this->resetPromptEnvironment(); + $this->io->error('Interactive mode failed: ' . $e->getMessage()); + return Command::SUCCESS; + } + } + + $checkedPaths = []; + + /** @var array $themeCodes */ + foreach ($themeCodes as $themeCode) { + $this->processThemeNpmCheck($themeCode, $checkedPaths, $output, $isVerbose); + } + + return Command::SUCCESS; + } + + /** + * Check npm dependencies for a single theme. + * + * @param string $themeCode + * @param array $checkedPaths Tracks already-checked npm paths for deduplication + * @param OutputInterface $output + * @param bool $isVerbose + * @return void + */ + private function processThemeNpmCheck( + string $themeCode, + array &$checkedPaths, + OutputInterface $output, + bool $isVerbose, + ): void { + $resolvedPath = $this->themePath->getPath($themeCode); + + if ($resolvedPath === null) { + $this->io->warning(sprintf('Theme "%s" not found. Skipping.', $themeCode)); + return; + } + + $npmPath = $this->getNpmPath($resolvedPath); + + if ($npmPath === null) { + $this->io->warning(sprintf('No package-lock.json found for theme "%s". Skipping.', $themeCode)); + return; + } + + // Deduplication: skip if this npm path was already processed + // (relevant when multiple MagentoStandard themes share the Magento root npm) + $rawPath = $this->fileDriver->getRealPath($npmPath); + $canonicalPath = is_string($rawPath) ? $rawPath : $npmPath; + if (in_array($canonicalPath, $checkedPaths, true)) { + $this->io->note(sprintf( + 'npm path "%s" was already checked (shared with another theme). Skipping "%s".', + $npmPath, + $themeCode, + )); + return; + } + $checkedPaths[] = $canonicalPath; + + $this->io->section(sprintf('npm dependencies: %s', $themeCode)); + + if ($isVerbose) { + $this->io->text(sprintf('npm path: %s', $npmPath)); + $this->io->newLine(); + } + + $isInteractive = $this->isInteractiveTerminal($output); + + $this->checkOutdated($npmPath, $output, $isVerbose, $isInteractive); + + $this->io->newLine(); + + $this->checkAudit($npmPath, $output, $isVerbose, $isInteractive); + } + + /** + * Check for outdated packages and optionally run npm update --latest. + * + * @param string $npmPath + * @param OutputInterface $output + * @param bool $isVerbose + * @param bool $isInteractive + * @return void + */ + private function checkOutdated( + string $npmPath, + OutputInterface $output, + bool $isVerbose, + bool $isInteractive, + ): void { + if ($isVerbose) { + $this->io->text('Checking for outdated packages...'); + } + + $outdated = $this->nodePackageManager->getOutdatedPackages($npmPath); + + if (empty($outdated)) { + $this->io->success('All packages are up to date.'); + return; + } + + $this->io->warning(sprintf('%d outdated package(s) found:', count($outdated))); + + $table = new Table($output); + $table->setHeaders(['Package', 'Current', 'Wanted', 'Latest']); + + foreach ($outdated as $packageName => $info) { + $packageInfo = is_array($info) ? $info : []; + $current = is_string($packageInfo['current'] ?? null) ? $packageInfo['current'] : '—'; + $wanted = is_string($packageInfo['wanted'] ?? null) ? $packageInfo['wanted'] : '—'; + $latest = is_string($packageInfo['latest'] ?? null) ? $packageInfo['latest'] : '—'; + $table->addRow([(string) $packageName, $current, $wanted, $latest]); + } + + $table->render(); + + if (!$isInteractive) { + return; + } + + $this->setPromptEnvironment(); + + try { + $runUpdate = confirm('Run npm update --latest?', default: false); + \Laravel\Prompts\Prompt::terminal()->restoreTty(); + } finally { + $this->resetPromptEnvironment(); + } + + if ($runUpdate) { + $this->nodePackageManager->runNpmUpdate($npmPath, $this->io, $isVerbose); + } + } + + /** + * Check npm audit and optionally run npm audit fix. + * + * @param string $npmPath + * @param OutputInterface $output + * @param bool $isVerbose + * @param bool $isInteractive + * @return void + */ + private function checkAudit( + string $npmPath, + OutputInterface $output, + bool $isVerbose, + bool $isInteractive, + ): void { + if ($isVerbose) { + $this->io->text('Running npm audit...'); + } + + $audit = $this->nodePackageManager->getAuditResults($npmPath); + $rawTotal = $audit['total'] ?? 0; + $total = is_int($rawTotal) ? $rawTotal : 0; + + if ($total === 0) { + $this->io->success('No vulnerabilities found.'); + return; + } + + $this->io->warning(sprintf('%d vulnerability/vulnerabilities found:', $total)); + + $table = new Table($output); + $table->setHeaders(['Severity', 'Count']); + + foreach (['critical', 'high', 'moderate', 'low', 'info'] as $severity) { + $rawCount = $audit[$severity] ?? 0; + $count = is_int($rawCount) ? $rawCount : 0; + if ($count > 0) { + $table->addRow([ucfirst($severity), $count]); + } + } + + $table->render(); + + if (!$isInteractive) { + return; + } + + $this->setPromptEnvironment(); + + try { + $runFix = confirm('Run npm audit fix?', default: false); + \Laravel\Prompts\Prompt::terminal()->restoreTty(); + } finally { + $this->resetPromptEnvironment(); + } + + if ($runFix) { + $this->nodePackageManager->runAuditFix($npmPath, $this->io, $isVerbose); + } + } + + /** + * Determine the npm path for the given theme path. + * + * Detection order: + * 1. web/tailwind/ (Hyvä / TailwindCSS themes) + * 2. Theme root (custom themes) + * 3. Magento root "." (MagentoStandard themes, detected via BuilderPool) + * + * @param string $themePath Absolute filesystem path to the theme + * @return string|null npm directory path, or null when no package-lock.json is found + */ + private function getNpmPath(string $themePath): ?string + { + $tailwindPath = $themePath . '/web/tailwind'; + if ($this->fileDriver->isExists($tailwindPath . '/package-lock.json')) { + return $tailwindPath; + } + + if ($this->fileDriver->isExists($themePath . '/package-lock.json')) { + return $themePath; + } + + $builder = $this->builderPool->getBuilder($themePath); + if ($builder !== null && $builder->getName() === 'MagentoStandard') { + if ($this->fileDriver->isExists('./package-lock.json')) { + return '.'; + } + } + + return null; + } +} diff --git a/src/Model/TemplateEngine/Decorator/InspectorHintsFactory.php b/src/Model/TemplateEngine/Decorator/InspectorHintsFactory.php new file mode 100644 index 00000000..9dacedf7 --- /dev/null +++ b/src/Model/TemplateEngine/Decorator/InspectorHintsFactory.php @@ -0,0 +1,38 @@ + $data + * @return InspectorHints + */ + public function create(array $data = []): InspectorHints + { + /** @var InspectorHints $instance */ + $instance = $this->objectManager->create(InspectorHints::class, $data); + return $instance; + } +} diff --git a/src/Service/NodePackageManager.php b/src/Service/NodePackageManager.php index 30129f13..57ed59d3 100644 --- a/src/Service/NodePackageManager.php +++ b/src/Service/NodePackageManager.php @@ -113,4 +113,101 @@ public function checkOutdatedPackages(string $path, SymfonyStyle $io): void } } } + + /** + * Return outdated packages as a parsed array. + * + * Uses "|| true" to suppress the non-zero exit code npm emits when packages are outdated. + * + * @param string $path + * @return array + */ + public function getOutdatedPackages(string $path): array + { + try { + $output = $this->shell->execute('cd %s && npm outdated --json || true', [$path]); + if (trim($output) === '' || trim($output) === '{}') { + return []; + } + $data = json_decode($output, true); + return is_array($data) ? $data : []; + } catch (\Exception) { + return []; + } + } + + /** + * Run "npm update --latest" in the given directory. + * + * @param string $path + * @param SymfonyStyle $io + * @param bool $isVerbose + * @return bool + */ + public function runNpmUpdate(string $path, SymfonyStyle $io, bool $isVerbose): bool + { + try { + $this->shell->execute('cd %s && npm update --latest', [$path]); + if ($isVerbose) { + $io->success('npm update --latest completed successfully.'); + } + return true; + } catch (\Exception $e) { + $io->error('npm update failed: ' . $e->getMessage()); + return false; + } + } + + /** + * Return npm audit vulnerability counts as a flat array. + * + * Keys: total, critical, high, moderate, low, info. + * Uses "|| true" to suppress the non-zero exit code npm emits when vulnerabilities exist. + * + * @param string $path + * @return array + */ + public function getAuditResults(string $path): array + { + try { + $output = $this->shell->execute('cd %s && npm audit --json || true', [$path]); + if (trim($output) === '') { + return []; + } + $data = json_decode($output, true); + if (!is_array($data)) { + return []; + } + $metadata = $data['metadata'] ?? null; + if (!is_array($metadata)) { + return []; + } + $vulnerabilities = $metadata['vulnerabilities'] ?? null; + return is_array($vulnerabilities) ? $vulnerabilities : []; + } catch (\Exception) { + return []; + } + } + + /** + * Run "npm audit fix" in the given directory. + * + * @param string $path + * @param SymfonyStyle $io + * @param bool $isVerbose + * @return bool + */ + public function runAuditFix(string $path, SymfonyStyle $io, bool $isVerbose): bool + { + try { + $this->shell->execute('cd %s && npm audit fix', [$path]); + if ($isVerbose) { + $io->success('npm audit fix completed successfully.'); + } + return true; + } catch (\Exception $e) { + $io->error('npm audit fix failed: ' . $e->getMessage()); + return false; + } + } } diff --git a/src/etc/di.xml b/src/etc/di.xml index 2fe48a73..56c180e8 100644 --- a/src/etc/di.xml +++ b/src/etc/di.xml @@ -25,6 +25,8 @@ OpenForgeProject\MageForge\Console\Command\Theme\TokensCommand OpenForgeProject\MageForge\Console\Command\Dev\InspectorCommand + + OpenForgeProject\MageForge\Console\Command\Theme\NpmCheckCommand