Skip to content

Commit 61ba34c

Browse files
committed
Centralize CLI command discovery
1 parent b1f2261 commit 61ba34c

7 files changed

Lines changed: 164 additions & 128 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,8 @@ http://127.0.0.1:8000/health
383383

384384
## CLI
385385

386+
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.
387+
386388
Show all commands or command-specific help:
387389

388390
```sh

REFACTORING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled
3232
- [x] Fluent query builder and attribute-driven database models.
3333
- [x] CLI diagnostics for tests, runtime info, environment, database, and modules.
3434
- [x] CLI help listing and command-specific usage.
35+
- [x] Centralized CLI command registry shared by the dispatcher and help command.
3536
- [x] Database migrations with create, migrate, status, and rollback commands.
3637
- [x] Removal of view storage and example page assets from runtime.
3738
- [x] Removal of legacy `application/controllers` and `application/routes.php`.

docs/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ <h2>Migrations</h2>
504504
<section id="cli">
505505
<h2>CLI</h2>
506506
<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>
507+
<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>
507508

508509
<pre><code>./shift help
509510
./shift help migrate

src/Console/CommandRegistry.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
namespace Shift\Console;
4+
5+
use Shift\Modules\ModuleLoader;
6+
7+
final class CommandRegistry
8+
{
9+
/**
10+
* @param list<array{dir: string, namespace: string}>|null $mappings
11+
*/
12+
public function __construct(private readonly ?array $mappings = null)
13+
{
14+
}
15+
16+
public static function default(): self
17+
{
18+
return new self();
19+
}
20+
21+
/**
22+
* @return array<string, class-string<CommandInterface>>
23+
*/
24+
public function all(): array
25+
{
26+
$commands = [];
27+
28+
foreach ($this->mappings() as $mapping) {
29+
if (!is_dir($mapping['dir'])) {
30+
continue;
31+
}
32+
33+
foreach (glob(rtrim($mapping['dir'], '/') . '/*.php') ?: [] as $file) {
34+
$className = pathinfo($file, PATHINFO_FILENAME);
35+
require_once $file;
36+
37+
$class = $mapping['namespace'] . $className;
38+
39+
if (class_exists($class) && is_subclass_of($class, CommandInterface::class)) {
40+
$commands[self::nameFromClass($className)] = $class;
41+
}
42+
}
43+
}
44+
45+
ksort($commands);
46+
47+
return $commands;
48+
}
49+
50+
/**
51+
* @return class-string<CommandInterface>|null
52+
*/
53+
public function find(string $command): ?string
54+
{
55+
return $this->all()[$this->normalize($command)] ?? null;
56+
}
57+
58+
public function normalize(string $command): string
59+
{
60+
$command = trim($command);
61+
62+
if ($command === '') {
63+
return '';
64+
}
65+
66+
if (!preg_match('/[:\-_]/', $command) && preg_match('/[A-Z]/', $command)) {
67+
return self::nameFromClass($command);
68+
}
69+
70+
$parts = preg_split('/[:\-_]/', $command, -1, PREG_SPLIT_NO_EMPTY) ?: [];
71+
$parts = array_map(static fn (string $part): string => strtolower($part), $parts);
72+
73+
return implode(':', $parts);
74+
}
75+
76+
public static function nameFromClass(string $class): string
77+
{
78+
$parts = explode('\\', $class);
79+
$shortClass = end($parts) ?: $class;
80+
$commandParts = preg_split('/(?=[A-Z])/', $shortClass, -1, PREG_SPLIT_NO_EMPTY) ?: [];
81+
$commandParts = array_map(static fn (string $part): string => strtolower($part), $commandParts);
82+
83+
return implode(':', $commandParts);
84+
}
85+
86+
/**
87+
* @return list<array{dir: string, namespace: string}>
88+
*/
89+
private function mappings(): array
90+
{
91+
if ($this->mappings !== null) {
92+
return $this->mappings;
93+
}
94+
95+
return array_merge(
96+
[
97+
[
98+
'dir' => APP_PATH . '/console/',
99+
'namespace' => 'AppConsole\\Commands\\',
100+
],
101+
[
102+
'dir' => APP_ROOT . '/src/Console/Commands/',
103+
'namespace' => 'Console\\Commands\\',
104+
],
105+
],
106+
(new ModuleLoader())->load()->getCommandMappings()
107+
);
108+
}
109+
}

src/Console/Commands/Help.php

Lines changed: 9 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010

1111
use Shift\Console\Cli;
1212
use Shift\Console\CommandInterface;
13-
use Shift\Modules\ModuleLoader;
13+
use Shift\Console\CommandRegistry;
1414

1515
class Help implements CommandInterface
1616
{
17+
public function __construct(private readonly CommandRegistry $registry = new CommandRegistry())
18+
{
19+
}
20+
1721
public function execute(mixed ...$args): void
1822
{
1923
$commandName = $args[0] ?? null;
@@ -29,15 +33,15 @@ public function execute(mixed ...$args): void
2933
private function displayHelpForCommand(string $command): void
3034
{
3135
$cli = new Cli();
32-
$class = $this->findCommandClass($this->normalizeCommandName($command));
36+
$class = $this->registry->find($command);
3337

3438
if ($class === null) {
3539
$cli->error('Command not found: ' . $command);
3640
return;
3741
}
3842

3943
$instance = new $class();
40-
$cli->info($this->classToCommand($this->shortClass($class)));
44+
$cli->info(CommandRegistry::nameFromClass($class));
4145
$cli->debug($instance->getDescription());
4246
$cli->debug($instance->getHelp());
4347
}
@@ -47,10 +51,10 @@ private function displayFullHelp(): void
4751
$cli = new Cli();
4852
$rows = [];
4953

50-
foreach ($this->commandClasses() as $className => $class) {
54+
foreach ($this->registry->all() as $command => $class) {
5155
$instance = new $class();
5256
$rows[] = [
53-
$this->classToCommand($className),
57+
$command,
5458
$instance->getDescription(),
5559
];
5660
}
@@ -69,78 +73,4 @@ public function getDescription(): string
6973
{
7074
return 'Show available commands.';
7175
}
72-
73-
private function findCommandClass(string $className): ?string
74-
{
75-
return $this->commandClasses()[$className] ?? null;
76-
}
77-
78-
/**
79-
* @return array<string, class-string<CommandInterface>>
80-
*/
81-
private function commandClasses(): array
82-
{
83-
$classes = [];
84-
85-
foreach ($this->mappings() as $mapping) {
86-
if (!is_dir($mapping['dir'])) {
87-
continue;
88-
}
89-
90-
foreach (glob($mapping['dir'] . '*.php') ?: [] as $file) {
91-
$className = pathinfo($file, PATHINFO_FILENAME);
92-
require_once $file;
93-
$class = $mapping['namespace'] . $className;
94-
95-
if (class_exists($class) && is_subclass_of($class, CommandInterface::class)) {
96-
$classes[$className] = $class;
97-
}
98-
}
99-
}
100-
101-
return $classes;
102-
}
103-
104-
/**
105-
* @return list<array{dir: string, namespace: string}>
106-
*/
107-
private function mappings(): array
108-
{
109-
return array_merge(
110-
[
111-
[
112-
'dir' => APP_PATH . '/console/',
113-
'namespace' => 'AppConsole\\Commands\\',
114-
],
115-
[
116-
'dir' => APP_ROOT . '/src/Console/Commands/',
117-
'namespace' => 'Console\\Commands\\',
118-
],
119-
],
120-
(new ModuleLoader())->load()->getCommandMappings()
121-
);
122-
}
123-
124-
private function normalizeCommandName(string $command): string
125-
{
126-
$parts = preg_split('/[:\-_]/', $command) ?: [];
127-
$parts = array_map(static fn (string $part): string => ucfirst($part), $parts);
128-
129-
return implode('', $parts);
130-
}
131-
132-
private function classToCommand(string $class): string
133-
{
134-
$parts = preg_split('/(?=[A-Z])/', $class, -1, PREG_SPLIT_NO_EMPTY) ?: [];
135-
$parts = array_map(static fn (string $part): string => strtolower($part), $parts);
136-
137-
return implode(':', $parts);
138-
}
139-
140-
private function shortClass(string $class): string
141-
{
142-
$parts = explode('\\', $class);
143-
144-
return end($parts) ?: $class;
145-
}
14676
}

src/Console/Shift.php

Lines changed: 12 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,14 @@
88

99
namespace Shift\Console;
1010

11-
use Shift\Modules\ModuleLoader;
12-
use ReflectionClass;
13-
use ReflectionException;
14-
1511
class Shift
1612
{
17-
protected string $_description = 'xd';
1813
private array $_args = [];
1914

20-
public function __construct(array $argv)
21-
{
15+
public function __construct(
16+
array $argv,
17+
private readonly CommandRegistry $registry = new CommandRegistry()
18+
) {
2219
$this->setArgs($argv);
2320
}
2421

@@ -47,58 +44,24 @@ public function setArgs(array $args): void
4744
$this->_args = $args;
4845
}
4946

50-
/**
51-
* @throws ReflectionException
52-
* @return void
53-
*/
5447
public function run(): void
5548
{
5649
$cli = new Cli();
50+
5751
if (count($this->_args) < 2) {
58-
$cli->error('Shift CLI needs at least one parameter');
52+
$cli->error('Usage: ./shift help');
5953
exit();
6054
}
61-
$commandName = $this->normalizeCommandName($this->_args[1]);
6255

63-
$mappings = [
64-
[
65-
'dir' => APP_PATH . '/console/',
66-
'namespace' => 'AppConsole\\Commands\\'
67-
],
68-
[
69-
'dir' => APP_ROOT . '/src/Console/Commands/',
70-
'namespace' => 'Console\\Commands\\'
71-
],
72-
];
73-
$mappings = array_merge(
74-
$mappings,
75-
(new ModuleLoader())->load()->getCommandMappings()
76-
);
77-
$found = false;
78-
foreach ($mappings as $mapping) {
79-
if (!$found && file_exists($mapping['dir'] . $commandName . '.php')) {
80-
require_once($mapping['dir'] . $commandName . '.php');
81-
$found = $mapping['namespace'] . $commandName;
82-
}
83-
}
84-
if (!$found) {
85-
$cli->error('Command ' . $commandName . ' not found');
56+
$command = $this->registry->find($this->_args[1]);
57+
58+
if ($command === null) {
59+
$cli->error('Command ' . $this->_args[1] . ' not found');
8660
exit();
8761
}
8862

89-
$cl = new $found();
90-
$class = new ReflectionClass($cl);
91-
$method = $class->getMethod('execute');
63+
$instance = new $command();
9264
$args = array_slice($this->_args, 2, count($this->_args));
93-
$method->invokeArgs($cl, $args);
94-
}
95-
96-
private function normalizeCommandName(string $command): string
97-
{
98-
$parts = preg_split('/[:\-_]/', $command) ?: [];
99-
$parts = array_map(static fn (string $part): string => ucfirst($part), $parts);
100-
101-
return implode('', $parts);
65+
$instance->execute(...$args);
10266
}
103-
10467
}

tests/Feature/CliRegistryTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
use Console\Commands\MigrateStatus;
4+
use Shift\Console\CommandRegistry;
5+
use Shift\Console\Shift;
6+
7+
return [
8+
'command registry discovers built-in and module commands' => function (): void {
9+
$registry = CommandRegistry::default();
10+
$commands = $registry->all();
11+
12+
assertSameValue(MigrateStatus::class, $commands['migrate:status'] ?? null, 'Registry should expose built-in commands by CLI name.');
13+
assertStringContains('Modules\\Health\\Commands\\Health', $commands['health'] ?? '', 'Registry should expose module commands.');
14+
},
15+
'command registry normalizes command names' => function (): void {
16+
$registry = CommandRegistry::default();
17+
18+
assertSameValue(MigrateStatus::class, $registry->find('migrate-status'), 'Dash command names should resolve.');
19+
assertSameValue(MigrateStatus::class, $registry->find('migrate_status'), 'Underscore command names should resolve.');
20+
assertSameValue(MigrateStatus::class, $registry->find('MigrateStatus'), 'Class-like command names should resolve.');
21+
},
22+
'cli dispatcher runs commands through the registry' => function (): void {
23+
ob_start();
24+
(new Shift(['shift', 'help', 'migrate:status']))->run();
25+
$output = ob_get_clean();
26+
27+
assertStringContains('migrate:status', $output, 'Dispatcher should run resolved commands.');
28+
assertStringContains('Usage: ./shift migrate:status', $output, 'Dispatcher should pass command arguments.');
29+
},
30+
];

0 commit comments

Comments
 (0)