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
26 changes: 26 additions & 0 deletions .github/workflows/magento-compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,19 @@ jobs:
bin/magento m:st:c --help
bin/magento frontend:clean --help

echo "Test Theme Name Suggestions (non-interactive):"
echo "Testing BuildCommand with invalid theme name:"
bin/magento mageforge:theme:build Magent/lum || echo "Expected failure - BuildCommand"

echo "Testing WatchCommand with invalid theme name:"
bin/magento mageforge:theme:watch Magent/lum --help || echo "Expected failure - WatchCommand"

echo "Testing CleanCommand with invalid theme name:"
bin/magento mageforge:static:clean Magent/lum --dry-run || echo "Expected failure - CleanCommand"

echo "Testing TokensCommand with invalid theme name:"
bin/magento mageforge:hyva:tokens Magent/lum --help || echo "Expected failure - TokensCommand"


- name: Test Summary
run: |
Expand Down Expand Up @@ -299,6 +312,19 @@ jobs:
bin/magento m:st:c --help
bin/magento frontend:clean --help

echo "Test Theme Name Suggestions (non-interactive):"
echo "Testing BuildCommand with invalid theme name:"
bin/magento mageforge:theme:build Magent/lum || echo "Expected failure - BuildCommand"

echo "Testing WatchCommand with invalid theme name:"
bin/magento mageforge:theme:watch Magent/lum --help || echo "Expected failure - WatchCommand"

echo "Testing CleanCommand with invalid theme name:"
bin/magento mageforge:static:clean Magent/lum --dry-run || echo "Expected failure - CleanCommand"

echo "Testing TokensCommand with invalid theme name:"
bin/magento mageforge:hyva:tokens Magent/lum --help || echo "Expected failure - TokensCommand"


- name: Test Summary
run: |
Expand Down
204 changes: 204 additions & 0 deletions src/Console/Command/AbstractCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace OpenForgeProject\MageForge\Console\Command;

use Laravel\Prompts\SelectPrompt;
use Magento\Framework\Console\Cli;
use OpenForgeProject\MageForge\Service\ThemeSuggester;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
Expand All @@ -27,6 +29,16 @@ abstract class AbstractCommand extends Command
*/
protected SymfonyStyle $io;

/**
* @var array
*/
private array $originalEnv = [];

/**
* @var array
*/
private array $secureEnvStorage = [];

/**
* Get the command name with proper group structure
*
Expand Down Expand Up @@ -101,4 +113,196 @@ protected function isDebug(OutputInterface $output): bool
{
return $output->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG;
}

/**
* Handle invalid theme with interactive suggestions
*
* When a theme code is invalid, this method finds similar themes using Levenshtein distance
* and offers an interactive selection via Laravel Prompts (if terminal is interactive).
* In non-interactive environments, suggestions are displayed as text.
*
* @param string $invalidTheme The invalid theme code entered by user
* @param ThemeSuggester $themeSuggester Service to find similar themes
* @param OutputInterface $output Output interface for terminal detection
* @return string|null The selected theme code, or null if cancelled/no selection
*/
protected function handleInvalidThemeWithSuggestions(
string $invalidTheme,
ThemeSuggester $themeSuggester,
OutputInterface $output
): ?string {
$suggestions = $themeSuggester->findSimilarThemes($invalidTheme);

// No suggestions found
if (empty($suggestions)) {
$this->io->error("Theme '$invalidTheme' is not installed and no similar themes were found.");
return null;
}

// Check if terminal is interactive
if (!$this->isInteractiveTerminal($output)) {
// Non-interactive fallback: display suggestions as text
$this->io->error("Theme '$invalidTheme' is not installed.");
$this->io->writeln("\nDid you mean one of these?");
foreach ($suggestions as $suggestion) {
$this->io->writeln(" - $suggestion");
}
return null;
}

// Interactive mode: show prompt with suggestions
$this->io->error("Theme '$invalidTheme' is not installed.");
$this->io->newLine();

// Prepare options with "None of these" option
$options = array_merge($suggestions, ['None of these']);

// Set environment for Docker/DDEV compatibility
$this->setPromptEnvironment();

$prompt = new SelectPrompt(
label: 'Did you mean one of these themes?',
options: $options,
scroll: 10,
hint: 'Arrow keys to navigate, Enter to confirm'
);

try {
$selection = $prompt->prompt();
\Laravel\Prompts\Prompt::terminal()->restoreTty();
$this->resetPromptEnvironment();

// Check if user selected "None of these"
if ($selection === 'None of these') {
return null;
}

return $selection;
} catch (\Exception $e) {
$this->resetPromptEnvironment();
$this->io->error('Selection failed: ' . $e->getMessage());
return null;
}
}

/**
* Check if terminal is interactive (supports Laravel Prompts)
*
* @param OutputInterface $output
* @return bool
*/
private function isInteractiveTerminal(OutputInterface $output): bool
{
// Check if output supports ANSI
if (!$output->isDecorated()) {
return false;
}

// Check if STDIN is available
if (!defined('STDIN') || !is_resource(STDIN)) {
return false;
}

// Check for CI environments
$nonInteractiveEnvs = [
'CI',
'GITHUB_ACTIONS',
'GITLAB_CI',
'JENKINS_URL',
'TEAMCITY_VERSION',
];

foreach ($nonInteractiveEnvs as $env) {
if ($this->getEnvVar($env) || $this->getServerVar($env)) {
return false;
}
}

// Check if TTY is available
$sttyOutput = shell_exec('stty -g 2>/dev/null');
return !empty($sttyOutput);
}

/**
* Set environment variables for Laravel Prompts in Docker/DDEV
*
* @return void
*/
private function setPromptEnvironment(): void
{
// Store original values for restoration
$this->originalEnv = [
'COLUMNS' => $this->getEnvVar('COLUMNS'),
'LINES' => $this->getEnvVar('LINES'),
'TERM' => $this->getEnvVar('TERM'),
];

// Set terminal dimensions for proper rendering
$this->setEnvVar('COLUMNS', '100');
$this->setEnvVar('LINES', '40');
$this->setEnvVar('TERM', 'xterm-256color');
}

/**
* Reset environment variables to original state
*
* @return void
*/
private function resetPromptEnvironment(): void
{
foreach ($this->originalEnv as $key => $value) {
if ($value === null) {
$this->removeSecureEnvironmentValue($key);
} else {
$this->setEnvVar($key, $value);
}
}
}

/**
* Get environment variable value
*
* @param string $key
* @return string|null
*/
private function getEnvVar(string $key): ?string
{
return getenv($key) ?: null;
}

/**
* Get server variable value
*
* @param string $key
* @return string|null
*/
private function getServerVar(string $key): ?string
{
return $_SERVER[$key] ?? null;
}

/**
* Set environment variable securely
*
* @param string $key
* @param string $value
* @return void
*/
private function setEnvVar(string $key, string $value): void
{
$this->secureEnvStorage[$key] = $value;
putenv("$key=$value");
}

/**
* Remove environment variable securely
*
* @param string $key
* @return void
*/
private function removeSecureEnvironmentValue(string $key): void
{
unset($this->secureEnvStorage[$key]);
putenv($key);
}
}
57 changes: 44 additions & 13 deletions src/Console/Command/Static/CleanCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use OpenForgeProject\MageForge\Console\Command\AbstractCommand;
use OpenForgeProject\MageForge\Model\ThemeList;
use OpenForgeProject\MageForge\Model\ThemePath;
use OpenForgeProject\MageForge\Service\ThemeSuggester;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
Expand All @@ -29,11 +30,13 @@ class CleanCommand extends AbstractCommand
* @param Filesystem $filesystem
* @param ThemeList $themeList
* @param ThemePath $themePath
* @param ThemeSuggester $themeSuggester
*/
public function __construct(
private readonly Filesystem $filesystem,
private readonly ThemeList $themeList,
private readonly ThemePath $themePath
private readonly ThemePath $themePath,
private readonly ThemeSuggester $themeSuggester
) {
parent::__construct();
}
Expand Down Expand Up @@ -86,7 +89,7 @@ protected function executeCommand(InputInterface $input, OutputInterface $output
return Cli::RETURN_SUCCESS;
}

[$totalCleaned, $failedThemes] = $this->processThemes($themeCodes, $dryRun);
[$totalCleaned, $failedThemes] = $this->processThemes($themeCodes, $dryRun, $output);

$this->displaySummary($themeCodes, $totalCleaned, $failedThemes, $dryRun);

Expand Down Expand Up @@ -223,9 +226,10 @@ private function promptForThemes(array $options, array $themes): ?array
*
* @param array $themeCodes
* @param bool $dryRun
* @param OutputInterface $output
* @return array [totalCleaned, failedThemes]
*/
private function processThemes(array $themeCodes, bool $dryRun): array
private function processThemes(array $themeCodes, bool $dryRun, OutputInterface $output): array
{
$totalThemes = count($themeCodes);
$totalCleaned = 0;
Expand All @@ -234,15 +238,19 @@ private function processThemes(array $themeCodes, bool $dryRun): array
foreach ($themeCodes as $index => $themeName) {
$currentTheme = $index + 1;

if (!$this->validateTheme($themeName, $failedThemes)) {
// Validate and potentially correct theme name
$validatedTheme = $this->validateTheme($themeName, $failedThemes, $output);

if ($validatedTheme === null) {
continue;
}

$this->displayThemeHeader($themeName, $currentTheme, $totalThemes);
// Use validated/corrected theme name
$this->displayThemeHeader($validatedTheme, $currentTheme, $totalThemes);

$cleaned = $this->cleanThemeDirectories($themeName, $dryRun);
$cleaned = $this->cleanThemeDirectories($validatedTheme, $dryRun);

$this->displayThemeResult($themeName, $cleaned, $dryRun);
$this->displayThemeResult($validatedTheme, $cleaned, $dryRun);

$totalCleaned += $cleaned;
}
Expand All @@ -255,19 +263,42 @@ private function processThemes(array $themeCodes, bool $dryRun): array
*
* @param string $themeName
* @param array &$failedThemes
* @return bool
* @param OutputInterface $output
* @return string|null Theme code if valid or corrected, null if invalid
*/
private function validateTheme(string $themeName, array &$failedThemes): bool
private function validateTheme(string $themeName, array &$failedThemes, OutputInterface $output): ?string
{
$themePath = $this->themePath->getPath($themeName);

if ($themePath === null) {
$this->io->error(sprintf("Theme '%s' not found.", $themeName));
$failedThemes[] = $themeName;
return false;
// Try to suggest similar themes
$correctedTheme = $this->handleInvalidThemeWithSuggestions(
$themeName,
$this->themeSuggester,
$output
);

// If no theme was selected, mark as failed
if ($correctedTheme === null) {
$failedThemes[] = $themeName;
return null;
}

// Use the corrected theme code
$themePath = $this->themePath->getPath($correctedTheme);

// Double-check the corrected theme exists
if ($themePath === null) {
$this->io->error(sprintf("Theme '%s' not found.", $correctedTheme));
$failedThemes[] = $themeName;
return null;
}

$this->io->info("Using theme: $correctedTheme");
return $correctedTheme;
}

return true;
return $themeName;
}

/**
Expand Down
Loading