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 @@
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.');
+ },
+];