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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ <h2>Migrations</h2>
<section id="cli">
<h2>CLI</h2>
<p>The CLI entry point is <code>shift</code>. Built-in commands live under <code>Console\Commands</code>, and module commands are loaded from module command mappings.</p>
<p><code>Shift\Console\CommandRegistry</code> discovers framework, application, and module commands. It normalizes command names, so <code>migrate:status</code>, <code>migrate-status</code>, and <code>migrate_status</code> resolve to the same command.</p>

<pre><code>./shift help
./shift help migrate
Expand Down
109 changes: 109 additions & 0 deletions src/Console/CommandRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

namespace Shift\Console;

use Shift\Modules\ModuleLoader;

final class CommandRegistry
{
/**
* @param list<array{dir: string, namespace: string}>|null $mappings
*/
public function __construct(private readonly ?array $mappings = null)
{
}

public static function default(): self
{
return new self();
}

/**
* @return array<string, class-string<CommandInterface>>
*/
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<CommandInterface>|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<array{dir: string, namespace: string}>
*/
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()
);
}
}
88 changes: 9 additions & 79 deletions src/Console/Commands/Help.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,15 +33,15 @@ 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);
return;
}

$instance = new $class();
$cli->info($this->classToCommand($this->shortClass($class)));
$cli->info(CommandRegistry::nameFromClass($class));
$cli->debug($instance->getDescription());
$cli->debug($instance->getHelp());
}
Expand All @@ -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(),
];
}
Expand All @@ -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<string, class-string<CommandInterface>>
*/
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<array{dir: string, namespace: string}>
*/
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;
}
}
61 changes: 12 additions & 49 deletions src/Console/Shift.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

}
30 changes: 30 additions & 0 deletions tests/Feature/CliRegistryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

use Console\Commands\MigrateStatus;
use Shift\Console\CommandRegistry;
use Shift\Console\Shift;

return [
'command registry discovers built-in and module commands' => 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.');
},
];
Loading