Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
327 changes: 327 additions & 0 deletions src/Service/NodeSetupValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
<?php

declare(strict_types=1);

namespace OpenForgeProject\MageForge\Service;

use Magento\Framework\Filesystem\Driver\File as FileDriver;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Laravel\Prompts\confirm;
/**
* Service for validating and restoring Node.js setup files for Magento Standard themes
*/
class NodeSetupValidator
{
private const REQUIRED_FILES = [
'package.json',
'Gruntfile.js',
'grunt-config.json',
];

/** @var array<string> Files that will be generated by npm install */
private const GENERATED_FILES = [
'package-lock.json',
];

private const REQUIRED_DIRECTORIES = [
'node_modules',
];

private const NODE_MODULES_DIR = 'node_modules/';

private const MAGENTO_BASE_PATH = 'vendor/magento/magento2-base';

/**
* Constructor
*
* @param FileDriver $fileDriver
* @param NodePackageManager $nodePackageManager
*/
public function __construct(
private readonly FileDriver $fileDriver,
private readonly NodePackageManager $nodePackageManager
) {
}

/**
* Validate Node.js setup files and offer to restore missing ones
*
* @param string $rootPath Root directory to check (usually '.')
* @param SymfonyStyle $io Console IO for output
* @param bool $isVerbose Whether to show verbose output
* @return bool True if validation passed or files were restored successfully
*/
public function validateAndRestore(string $rootPath, SymfonyStyle $io, bool $isVerbose): bool
{
$missing = $this->getMissingFiles($rootPath);

if (empty($missing)) {
if ($isVerbose) {
$io->success('All required Node.js setup files are present.');
}
return true;
}

// Separate source files from generated files/directories
$missingSourceFiles = $this->filterSourceFiles($missing);

// If only generated files/directories are missing, restore them directly without asking
if (empty($missingSourceFiles)) {
return $this->restoreGeneratedFilesAutomatically($rootPath, $missing, $io, $isVerbose);
}

// Ask user if they want to restore missing files
if (!$this->promptUserForRestoration($missing, $io)) {
$io->info('Skipping file restoration.');
return false;
}

// Restore missing files
return $this->restoreMissingFiles($rootPath, $missing, $io, $isVerbose);
}

/**
* Get list of missing files and directories
*
* @param string $rootPath Root directory to check
* @return array<string> List of missing file/directory names
*/
private function getMissingFiles(string $rootPath): array
{
$missing = [];

// Check required files
foreach (self::REQUIRED_FILES as $file) {
if (!$this->fileDriver->isExists($rootPath . '/' . $file)) {
$missing[] = $file;
}
}

// Check generated files
foreach (self::GENERATED_FILES as $file) {
if (!$this->fileDriver->isExists($rootPath . '/' . $file)) {
$missing[] = $file;
}
}

// Check required directories
foreach (self::REQUIRED_DIRECTORIES as $directory) {
if (!$this->fileDriver->isDirectory($rootPath . '/' . $directory)) {
$missing[] = $directory . '/';
}
}

return $missing;
}

/**
* Filter source files from the list of missing files
*
* Returns only files that need to be copied from Magento base
* (excludes generated files and directories)
*
* @param array<string> $missing List of missing files/directories
* @return array<string> List of source files that need to be restored
*/
private function filterSourceFiles(array $missing): array
{
return array_filter($missing, fn($item) => !$this->isGeneratedFileOrDirectory($item));
}

/**
* Check if a file or directory is generated (not a source file)
*
* @param string $item File or directory name
* @return bool True if the item is generated by npm install
*/
private function isGeneratedFileOrDirectory(string $item): bool
{
return in_array($item, self::GENERATED_FILES, true) || $item === self::NODE_MODULES_DIR;
}

/**
* Restore only generated files/directories automatically without user prompt
*
* @param string $rootPath Root directory where files should be restored
* @param array<string> $missing List of missing files/directories
* @param SymfonyStyle $io Console IO for output
* @param bool $isVerbose Whether to show verbose output
* @return bool True if restoration was successful
*/
private function restoreGeneratedFilesAutomatically(
string $rootPath,
array $missing,
SymfonyStyle $io,
bool $isVerbose
): bool {
if ($isVerbose) {
$io->note('Detected missing generated files/directories. Installing automatically...');
foreach ($missing as $item) {
$io->writeln(" - {$item}");
}
}
return $this->restoreMissingFiles($rootPath, $missing, $io, $isVerbose);
}

/**
* Prompt user to confirm file restoration
*
* @param array<string> $missing List of missing files/directories
* @param SymfonyStyle $io Console IO for output
* @return bool True if user confirms restoration
*/
private function promptUserForRestoration(array $missing, SymfonyStyle $io): bool
{
// Display missing files
$io->warning('The following required files/directories are missing:');
foreach ($missing as $item) {
$suffix = $this->isGeneratedFileOrDirectory($item)
? ' (will be generated by npm install)'
: '';
$io->writeln(" - {$item}{$suffix}");
}
$io->newLine();

// Ask user if they want to restore
return confirm(
label: 'Would you like to restore missing files from Magento base?',
default: true,
hint: 'This will copy the standard Magento files to your project root.'
);
Comment on lines +186 to +190
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Laravel Prompts confirm() function should be replaced with $io->confirm() to be consistent with how other service classes handle user prompts. Additionally, the second parameter should be set to true to match the default behavior shown in the Laravel Prompts call (default: true), or adjust as needed for the desired default behavior. See src/Service/DependencyChecker.php lines 52, 71, and 111 for examples of the correct pattern.

Copilot uses AI. Check for mistakes.
}

/**
* Restore missing files from Magento base installation
*
* @param string $rootPath Root directory where files should be restored
* @param array<string> $missing List of missing files/directories
* @param SymfonyStyle $io Console IO for output
* @param bool $isVerbose Whether to show verbose output
* @return bool True if restoration was successful
*/
private function restoreMissingFiles(
string $rootPath,
array $missing,
SymfonyStyle $io,
bool $isVerbose
): bool {
if (empty($missing)) {
return true;
}

$basePath = self::MAGENTO_BASE_PATH;

if (!$this->fileDriver->isDirectory($basePath)) {
$io->error(sprintf(
'Magento base directory not found at: %s',
$basePath
));
return false;
}

$restored = [];
$failed = [];

foreach ($missing as $item) {
// Skip generated files/directories - they will be created by npm install
if ($this->isGeneratedFileOrDirectory($item)) {
if ($isVerbose) {
$io->note("Skipping {$item} - will be generated by npm install");
}
continue;
}

$sourcePath = $basePath . '/' . $item;
$targetPath = $rootPath . '/' . $item;

if (!$this->fileDriver->isExists($sourcePath)) {
if ($isVerbose) {
$io->warning("Source file not found: {$sourcePath}");
}
$failed[] = $item;
continue;
}

try {
$this->fileDriver->copy($sourcePath, $targetPath);
$restored[] = $item;

if ($isVerbose) {
$io->writeln("✓ Restored: {$item}");
}
} catch (\Exception $e) {
$io->error("Failed to restore {$item}: " . $e->getMessage());
$failed[] = $item;
}
}

$this->displayRestorationSummary($restored, $failed, $io);

// If we restored any files or node_modules is missing, run npm install
if ($this->shouldRunNpmInstall($restored, $missing)) {
return $this->runNpmInstall($rootPath, $io, $isVerbose) && empty($failed);
}

return empty($failed);
}

/**
* Display summary of restoration results
*
* @param array<string> $restored List of successfully restored files
* @param array<string> $failed List of failed files
* @param SymfonyStyle $io Console IO for output
* @return void
*/
private function displayRestorationSummary(array $restored, array $failed, SymfonyStyle $io): void
{
if (!empty($restored)) {
$io->success(sprintf(
'Restored %d file(s): %s',
count($restored),
implode(', ', $restored)
));
}

if (!empty($failed)) {
$io->warning(sprintf(
'Failed to restore %d file(s): %s',
count($failed),
implode(', ', $failed)
));
}
}

/**
* Check if npm install should be run
*
* @param array<string> $restored List of restored files
* @param array<string> $missing List of missing files
* @return bool True if npm install should be run
*/
private function shouldRunNpmInstall(array $restored, array $missing): bool
{
return !empty($restored) || in_array(self::NODE_MODULES_DIR, $missing, true);
}

/**
* Run npm install to create node_modules and generated files
*
* @param string $rootPath Root directory
* @param SymfonyStyle $io Console IO for output
* @param bool $isVerbose Whether to show verbose output
* @return bool True if npm install was successful
*/
private function runNpmInstall(string $rootPath, SymfonyStyle $io, bool $isVerbose): bool
{
$io->newLine();
$io->text('Installing Node.js dependencies...');

if (!$this->nodePackageManager->installNodeModules($rootPath, $io, $isVerbose)) {
$io->error('Failed to install Node.js dependencies.');
return false;
}

return true;
}
}
11 changes: 10 additions & 1 deletion src/Service/ThemeBuilder/MagentoStandard/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use OpenForgeProject\MageForge\Service\CacheCleaner;
use OpenForgeProject\MageForge\Service\GruntTaskRunner;
use OpenForgeProject\MageForge\Service\NodePackageManager;
use OpenForgeProject\MageForge\Service\NodeSetupValidator;
use OpenForgeProject\MageForge\Service\StaticContentCleaner;
use OpenForgeProject\MageForge\Service\StaticContentDeployer;
use OpenForgeProject\MageForge\Service\SymlinkCleaner;
Expand All @@ -28,7 +29,8 @@ public function __construct(
private readonly CacheCleaner $cacheCleaner,
private readonly SymlinkCleaner $symlinkCleaner,
private readonly NodePackageManager $nodePackageManager,
private readonly GruntTaskRunner $gruntTaskRunner
private readonly GruntTaskRunner $gruntTaskRunner,
private readonly NodeSetupValidator $nodeSetupValidator
) {
}

Expand Down Expand Up @@ -90,6 +92,13 @@ private function processNodeSetup(
OutputInterface $output,
bool $isVerbose
): bool {
$rootPath = '.';

// Validate and restore Node.js setup files if needed
if (!$this->nodeSetupValidator->validateAndRestore($rootPath, $io, $isVerbose)) {
return false;
}

// Check if Node/Grunt setup exists
if (!$this->autoRepair($themePath, $io, $output, $isVerbose)) {
return false;
Expand Down
8 changes: 8 additions & 0 deletions src/etc/di.xml
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,12 @@
<argument name="fileDriver" xsi:type="object">Magento\Framework\Filesystem\Driver\File</argument>
</arguments>
</type>

<!-- Configure NodeSetupValidator Service -->
<type name="OpenForgeProject\MageForge\Service\NodeSetupValidator">
<arguments>
<argument name="fileDriver" xsi:type="object">Magento\Framework\Filesystem\Driver\File</argument>
<argument name="nodePackageManager" xsi:type="object">OpenForgeProject\MageForge\Service\NodePackageManager</argument>
</arguments>
</type>
</config>
Loading