From 61ba34cc0ad3ace251737cec39a5b5fc39b54c8e Mon Sep 17 00:00:00 2001 From: rtcoder Date: Wed, 17 Jun 2026 17:19:47 +0200 Subject: [PATCH] Centralize CLI command discovery --- README.md | 2 + REFACTORING.md | 1 + docs/index.html | 1 + src/Console/CommandRegistry.php | 109 ++++++++++++++++++++++++++++++ src/Console/Commands/Help.php | 88 +++--------------------- src/Console/Shift.php | 61 ++++------------- tests/Feature/CliRegistryTest.php | 30 ++++++++ 7 files changed, 164 insertions(+), 128 deletions(-) create mode 100644 src/Console/CommandRegistry.php create mode 100644 tests/Feature/CliRegistryTest.php diff --git a/README.md b/README.md index 266f7d7..da97790 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,8 @@ http://127.0.0.1:8000/health ## CLI +The CLI discovers framework, application, and module commands through the command registry. Command names are normalized, so `migrate:status`, `migrate-status`, and `migrate_status` resolve to the same command. + Show all commands or command-specific help: ```sh diff --git a/REFACTORING.md b/REFACTORING.md index ba91e8c..c97d8d9 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -32,6 +32,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] Fluent query builder and attribute-driven database models. - [x] CLI diagnostics for tests, runtime info, environment, database, and modules. - [x] CLI help listing and command-specific usage. +- [x] Centralized CLI command registry shared by the dispatcher and help command. - [x] Database migrations with create, migrate, status, and rollback commands. - [x] Removal of view storage and example page assets from runtime. - [x] Removal of legacy `application/controllers` and `application/routes.php`. diff --git a/docs/index.html b/docs/index.html index cdc455f..5fdb6e3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -504,6 +504,7 @@

Migrations

CLI

The CLI entry point is shift. Built-in commands live under Console\Commands, and module commands are loaded from module command mappings.

+

Shift\Console\CommandRegistry discovers framework, application, and module commands. It normalizes command names, so migrate:status, migrate-status, and migrate_status resolve to the same command.

./shift help
 ./shift help migrate
diff --git a/src/Console/CommandRegistry.php b/src/Console/CommandRegistry.php
new file mode 100644
index 0000000..eecc14e
--- /dev/null
+++ b/src/Console/CommandRegistry.php
@@ -0,0 +1,109 @@
+|null $mappings
+     */
+    public function __construct(private readonly ?array $mappings = null)
+    {
+    }
+
+    public static function default(): self
+    {
+        return new self();
+    }
+
+    /**
+     * @return array>
+     */
+    public function all(): array
+    {
+        $commands = [];
+
+        foreach ($this->mappings() as $mapping) {
+            if (!is_dir($mapping['dir'])) {
+                continue;
+            }
+
+            foreach (glob(rtrim($mapping['dir'], '/') . '/*.php') ?: [] as $file) {
+                $className = pathinfo($file, PATHINFO_FILENAME);
+                require_once $file;
+
+                $class = $mapping['namespace'] . $className;
+
+                if (class_exists($class) && is_subclass_of($class, CommandInterface::class)) {
+                    $commands[self::nameFromClass($className)] = $class;
+                }
+            }
+        }
+
+        ksort($commands);
+
+        return $commands;
+    }
+
+    /**
+     * @return class-string|null
+     */
+    public function find(string $command): ?string
+    {
+        return $this->all()[$this->normalize($command)] ?? null;
+    }
+
+    public function normalize(string $command): string
+    {
+        $command = trim($command);
+
+        if ($command === '') {
+            return '';
+        }
+
+        if (!preg_match('/[:\-_]/', $command) && preg_match('/[A-Z]/', $command)) {
+            return self::nameFromClass($command);
+        }
+
+        $parts = preg_split('/[:\-_]/', $command, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+        $parts = array_map(static fn (string $part): string => strtolower($part), $parts);
+
+        return implode(':', $parts);
+    }
+
+    public static function nameFromClass(string $class): string
+    {
+        $parts = explode('\\', $class);
+        $shortClass = end($parts) ?: $class;
+        $commandParts = preg_split('/(?=[A-Z])/', $shortClass, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+        $commandParts = array_map(static fn (string $part): string => strtolower($part), $commandParts);
+
+        return implode(':', $commandParts);
+    }
+
+    /**
+     * @return list
+     */
+    private function mappings(): array
+    {
+        if ($this->mappings !== null) {
+            return $this->mappings;
+        }
+
+        return array_merge(
+            [
+                [
+                    'dir' => APP_PATH . '/console/',
+                    'namespace' => 'AppConsole\\Commands\\',
+                ],
+                [
+                    'dir' => APP_ROOT . '/src/Console/Commands/',
+                    'namespace' => 'Console\\Commands\\',
+                ],
+            ],
+            (new ModuleLoader())->load()->getCommandMappings()
+        );
+    }
+}
diff --git a/src/Console/Commands/Help.php b/src/Console/Commands/Help.php
index 832014c..809e492 100644
--- a/src/Console/Commands/Help.php
+++ b/src/Console/Commands/Help.php
@@ -10,10 +10,14 @@
 
 use Shift\Console\Cli;
 use Shift\Console\CommandInterface;
-use Shift\Modules\ModuleLoader;
+use Shift\Console\CommandRegistry;
 
 class Help implements CommandInterface
 {
+    public function __construct(private readonly CommandRegistry $registry = new CommandRegistry())
+    {
+    }
+
     public function execute(mixed ...$args): void
     {
         $commandName = $args[0] ?? null;
@@ -29,7 +33,7 @@ public function execute(mixed ...$args): void
     private function displayHelpForCommand(string $command): void
     {
         $cli = new Cli();
-        $class = $this->findCommandClass($this->normalizeCommandName($command));
+        $class = $this->registry->find($command);
 
         if ($class === null) {
             $cli->error('Command not found: ' . $command);
@@ -37,7 +41,7 @@ private function displayHelpForCommand(string $command): void
         }
 
         $instance = new $class();
-        $cli->info($this->classToCommand($this->shortClass($class)));
+        $cli->info(CommandRegistry::nameFromClass($class));
         $cli->debug($instance->getDescription());
         $cli->debug($instance->getHelp());
     }
@@ -47,10 +51,10 @@ private function displayFullHelp(): void
         $cli = new Cli();
         $rows = [];
 
-        foreach ($this->commandClasses() as $className => $class) {
+        foreach ($this->registry->all() as $command => $class) {
             $instance = new $class();
             $rows[] = [
-                $this->classToCommand($className),
+                $command,
                 $instance->getDescription(),
             ];
         }
@@ -69,78 +73,4 @@ public function getDescription(): string
     {
         return 'Show available commands.';
     }
-
-    private function findCommandClass(string $className): ?string
-    {
-        return $this->commandClasses()[$className] ?? null;
-    }
-
-    /**
-     * @return array>
-     */
-    private function commandClasses(): array
-    {
-        $classes = [];
-
-        foreach ($this->mappings() as $mapping) {
-            if (!is_dir($mapping['dir'])) {
-                continue;
-            }
-
-            foreach (glob($mapping['dir'] . '*.php') ?: [] as $file) {
-                $className = pathinfo($file, PATHINFO_FILENAME);
-                require_once $file;
-                $class = $mapping['namespace'] . $className;
-
-                if (class_exists($class) && is_subclass_of($class, CommandInterface::class)) {
-                    $classes[$className] = $class;
-                }
-            }
-        }
-
-        return $classes;
-    }
-
-    /**
-     * @return list
-     */
-    private function mappings(): array
-    {
-        return array_merge(
-            [
-                [
-                    'dir' => APP_PATH . '/console/',
-                    'namespace' => 'AppConsole\\Commands\\',
-                ],
-                [
-                    'dir' => APP_ROOT . '/src/Console/Commands/',
-                    'namespace' => 'Console\\Commands\\',
-                ],
-            ],
-            (new ModuleLoader())->load()->getCommandMappings()
-        );
-    }
-
-    private function normalizeCommandName(string $command): string
-    {
-        $parts = preg_split('/[:\-_]/', $command) ?: [];
-        $parts = array_map(static fn (string $part): string => ucfirst($part), $parts);
-
-        return implode('', $parts);
-    }
-
-    private function classToCommand(string $class): string
-    {
-        $parts = preg_split('/(?=[A-Z])/', $class, -1, PREG_SPLIT_NO_EMPTY) ?: [];
-        $parts = array_map(static fn (string $part): string => strtolower($part), $parts);
-
-        return implode(':', $parts);
-    }
-
-    private function shortClass(string $class): string
-    {
-        $parts = explode('\\', $class);
-
-        return end($parts) ?: $class;
-    }
 }
diff --git a/src/Console/Shift.php b/src/Console/Shift.php
index fc12fe8..117060a 100644
--- a/src/Console/Shift.php
+++ b/src/Console/Shift.php
@@ -8,17 +8,14 @@
 
 namespace Shift\Console;
 
-use Shift\Modules\ModuleLoader;
-use ReflectionClass;
-use ReflectionException;
-
 class Shift
 {
-    protected string $_description = 'xd';
     private array $_args = [];
 
-    public function __construct(array $argv)
-    {
+    public function __construct(
+        array $argv,
+        private readonly CommandRegistry $registry = new CommandRegistry()
+    ) {
         $this->setArgs($argv);
     }
 
@@ -47,58 +44,24 @@ public function setArgs(array $args): void
         $this->_args = $args;
     }
 
-    /**
-     * @throws ReflectionException
-     * @return void
-     */
     public function run(): void
     {
         $cli = new Cli();
+
         if (count($this->_args) < 2) {
-            $cli->error('Shift CLI needs at least one parameter');
+            $cli->error('Usage: ./shift help');
             exit();
         }
-        $commandName = $this->normalizeCommandName($this->_args[1]);
 
-        $mappings = [
-            [
-                'dir' => APP_PATH . '/console/',
-                'namespace' => 'AppConsole\\Commands\\'
-            ],
-            [
-                'dir' => APP_ROOT . '/src/Console/Commands/',
-                'namespace' => 'Console\\Commands\\'
-            ],
-        ];
-        $mappings = array_merge(
-            $mappings,
-            (new ModuleLoader())->load()->getCommandMappings()
-        );
-        $found = false;
-        foreach ($mappings as $mapping) {
-            if (!$found && file_exists($mapping['dir'] . $commandName . '.php')) {
-                require_once($mapping['dir'] . $commandName . '.php');
-                $found = $mapping['namespace'] . $commandName;
-            }
-        }
-        if (!$found) {
-            $cli->error('Command ' . $commandName . ' not found');
+        $command = $this->registry->find($this->_args[1]);
+
+        if ($command === null) {
+            $cli->error('Command ' . $this->_args[1] . ' not found');
             exit();
         }
 
-        $cl = new $found();
-        $class = new ReflectionClass($cl);
-        $method = $class->getMethod('execute');
+        $instance = new $command();
         $args = array_slice($this->_args, 2, count($this->_args));
-        $method->invokeArgs($cl, $args);
-    }
-
-    private function normalizeCommandName(string $command): string
-    {
-        $parts = preg_split('/[:\-_]/', $command) ?: [];
-        $parts = array_map(static fn (string $part): string => ucfirst($part), $parts);
-
-        return implode('', $parts);
+        $instance->execute(...$args);
     }
-
 }
diff --git a/tests/Feature/CliRegistryTest.php b/tests/Feature/CliRegistryTest.php
new file mode 100644
index 0000000..b7325ac
--- /dev/null
+++ b/tests/Feature/CliRegistryTest.php
@@ -0,0 +1,30 @@
+ function (): void {
+        $registry = CommandRegistry::default();
+        $commands = $registry->all();
+
+        assertSameValue(MigrateStatus::class, $commands['migrate:status'] ?? null, 'Registry should expose built-in commands by CLI name.');
+        assertStringContains('Modules\\Health\\Commands\\Health', $commands['health'] ?? '', 'Registry should expose module commands.');
+    },
+    'command registry normalizes command names' => function (): void {
+        $registry = CommandRegistry::default();
+
+        assertSameValue(MigrateStatus::class, $registry->find('migrate-status'), 'Dash command names should resolve.');
+        assertSameValue(MigrateStatus::class, $registry->find('migrate_status'), 'Underscore command names should resolve.');
+        assertSameValue(MigrateStatus::class, $registry->find('MigrateStatus'), 'Class-like command names should resolve.');
+    },
+    'cli dispatcher runs commands through the registry' => function (): void {
+        ob_start();
+        (new Shift(['shift', 'help', 'migrate:status']))->run();
+        $output = ob_get_clean();
+
+        assertStringContains('migrate:status', $output, 'Dispatcher should run resolved commands.');
+        assertStringContains('Usage: ./shift migrate:status', $output, 'Dispatcher should pass command arguments.');
+    },
+];