diff --git a/README.md b/README.md index 22a31b1..118a8ba 100644 --- a/README.md +++ b/README.md @@ -147,10 +147,11 @@ $request->post('name'); $request->input('name'); $request->getJson(); $request->getHeader('Authorization'); +$request->getRequestId(); $request->routeParam('id'); ``` -Malformed JSON bodies are returned as `400 Bad Request`. +If the request does not include `X-Request-Id`, ShiftPHP generates one. The same id is emitted on every response as `X-Request-Id` and included in structured exception logs. Malformed JSON bodies are returned as `400 Bad Request`. ## Validation and DTOs @@ -405,13 +406,37 @@ 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. +The CLI discovers framework, application, and module commands through the command registry. Command names are declared with `#[Command]` attributes and can include aliases and groups. Command names are normalized, so `migrate:status`, `migrate-status`, and `migrate_status` resolve to the same command. + +```php +use Shift\Console\Attributes\Command; +use Shift\Console\CommandInterface; + +#[Command('billing:sync', aliases: ['sync-billing'], group: 'modules')] +final class SyncBilling implements CommandInterface +{ + public function execute(mixed ...$args): void + { + } + + public function getHelp(): string + { + return 'Usage: ./shift billing:sync'; + } + + public function getDescription(): string + { + return 'Sync billing data.'; + } +} +``` Show all commands or command-specific help: ```sh ./shift help ./shift help migrate +./shift help ms ``` List registered API routes: @@ -441,6 +466,7 @@ Inspect framework/runtime information: Check local environment and database configuration: ```sh +./shift doctor ./shift env:check ./shift db:check ``` diff --git a/REFACTORING.md b/REFACTORING.md index b262977..c9eb3f7 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -33,9 +33,12 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [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] CLI command metadata through attributes, aliases, and grouped help. +- [x] `shift doctor` project diagnostics. - [x] Database migrations with create, migrate, status, and rollback commands. - [x] Module discovery cache for production. - [x] Structured exception logging with JSON file logger and service container override. +- [x] Request id lifecycle with generated `X-Request-Id` response headers and log context. - [x] Removal of view storage and example page assets from runtime. - [x] Removal of legacy `application/controllers` and `application/routes.php`. - [x] Domain-oriented framework namespaces: @@ -161,5 +164,4 @@ Internal errors return a generic `500` message unless `display_errors` is enable ## Next -- [ ] CLI command aliases and richer command metadata. -- [ ] Basic package-quality checks, for example static analysis and coding style. +- [ ] Static analysis and coding style checks. diff --git a/application/modules/Health/Commands/Health.php b/application/modules/Health/Commands/Health.php index 9c13580..5cf0dc7 100644 --- a/application/modules/Health/Commands/Health.php +++ b/application/modules/Health/Commands/Health.php @@ -6,6 +6,7 @@ use Shift\Console\CommandInterface; use Modules\Health\Services\HealthService; +#[\Shift\Console\Attributes\Command('health', group: 'modules')] class Health implements CommandInterface { public function execute(mixed ...$args): void diff --git a/docs/index.html b/docs/index.html index a107b84..91390c0 100644 --- a/docs/index.html +++ b/docs/index.html @@ -275,9 +275,11 @@
If a request does not include X-Request-Id, ShiftPHP generates one. The same id is emitted on every response as X-Request-Id and included in structured exception logs.
getJson() returns an empty array for an empty body. Malformed JSON throws an HTTP error and is returned as 400 Bad Request.
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\Console\CommandRegistry discovers framework, application, and module commands. Commands can declare their name, aliases, and help group with #[Command]. It normalizes command names, so migrate:status, migrate-status, and migrate_status resolve to the same command.
use Shift\Console\Attributes\Command;
+use Shift\Console\CommandInterface;
+
+#[Command('billing:sync', aliases: ['sync-billing'], group: 'modules')]
+final class SyncBilling implements CommandInterface
+{
+ public function execute(mixed ...$args): void
+ {
+ }
+
+ public function getHelp(): string
+ {
+ return 'Usage: ./shift billing:sync';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Sync billing data.';
+ }
+}
./shift help
./shift help migrate
+./shift help ms
+./shift doctor
./shift route:list
./shift test
./shift health
diff --git a/src/App.php b/src/App.php
index 73261b1..bb0fe2b 100755
--- a/src/App.php
+++ b/src/App.php
@@ -77,7 +77,7 @@ public function start(): void
$response = JsonResponse::error('Internal Server Error', 500);
}
- $this->emitter->emit($response);
+ $this->emitter->emit($this->withRequestId($response));
}
public function middleware(MiddlewareInterface|callable|string $middleware): self
@@ -398,4 +398,13 @@ private function logException(Throwable $exception, int $statusCode): void
} catch (Throwable) {
}
}
+
+ private function withRequestId(Response $response): Response
+ {
+ return new Response(
+ $response->getContent(),
+ $response->getStatusCode(),
+ ['X-Request-Id' => $this->request->getRequestId()] + $response->getHeaders()
+ );
+ }
}
diff --git a/src/Console/Attributes/Command.php b/src/Console/Attributes/Command.php
new file mode 100644
index 0000000..9bfaf04
--- /dev/null
+++ b/src/Console/Attributes/Command.php
@@ -0,0 +1,19 @@
+ $aliases
+ */
+ public function __construct(
+ public readonly string $name,
+ public readonly array $aliases = [],
+ public readonly string $group = 'general'
+ ) {
+ }
+}
diff --git a/src/Console/CommandDefinition.php b/src/Console/CommandDefinition.php
new file mode 100644
index 0000000..5a5297e
--- /dev/null
+++ b/src/Console/CommandDefinition.php
@@ -0,0 +1,23 @@
+ $class
+ * @param list $aliases
+ */
+ public function __construct(
+ public readonly string $name,
+ public readonly string $class,
+ public readonly array $aliases = [],
+ public readonly string $group = 'general'
+ ) {
+ }
+
+ public function instantiate(): CommandInterface
+ {
+ return new $this->class();
+ }
+}
diff --git a/src/Console/CommandRegistry.php b/src/Console/CommandRegistry.php
index eecc14e..e53782c 100644
--- a/src/Console/CommandRegistry.php
+++ b/src/Console/CommandRegistry.php
@@ -2,6 +2,8 @@
namespace Shift\Console;
+use ReflectionClass;
+use Shift\Console\Attributes\Command;
use Shift\Modules\ModuleLoader;
final class CommandRegistry
@@ -23,7 +25,18 @@ public static function default(): self
*/
public function all(): array
{
- $commands = [];
+ return array_map(
+ static fn (CommandDefinition $definition): string => $definition->class,
+ $this->definitions()
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function definitions(): array
+ {
+ $definitions = [];
foreach ($this->mappings() as $mapping) {
if (!is_dir($mapping['dir'])) {
@@ -37,14 +50,15 @@ public function all(): array
$class = $mapping['namespace'] . $className;
if (class_exists($class) && is_subclass_of($class, CommandInterface::class)) {
- $commands[self::nameFromClass($className)] = $class;
+ $definition = $this->definitionFor($class, $className);
+ $definitions[$definition->name] = $definition;
}
}
}
- ksort($commands);
+ ksort($definitions);
- return $commands;
+ return $definitions;
}
/**
@@ -52,7 +66,20 @@ public function all(): array
*/
public function find(string $command): ?string
{
- return $this->all()[$this->normalize($command)] ?? null;
+ return $this->findDefinition($command)?->class;
+ }
+
+ public function findDefinition(string $command): ?CommandDefinition
+ {
+ $normalized = $this->normalize($command);
+
+ foreach ($this->definitions() as $definition) {
+ if ($definition->name === $normalized || in_array($normalized, $definition->aliases, true)) {
+ return $definition;
+ }
+ }
+
+ return null;
}
public function normalize(string $command): string
@@ -83,6 +110,41 @@ public static function nameFromClass(string $class): string
return implode(':', $commandParts);
}
+ /**
+ * @param class-string $class
+ */
+ private function definitionFor(string $class, string $fallbackClassName): CommandDefinition
+ {
+ $reflection = new ReflectionClass($class);
+ $attributes = $reflection->getAttributes(Command::class);
+
+ if ($attributes !== []) {
+ /** @var Command $attribute */
+ $attribute = $attributes[0]->newInstance();
+
+ return new CommandDefinition(
+ $this->normalize($attribute->name),
+ $class,
+ $this->normalizeList($attribute->aliases),
+ $attribute->group
+ );
+ }
+
+ return new CommandDefinition(self::nameFromClass($fallbackClassName), $class);
+ }
+
+ /**
+ * @param list $values
+ * @return list
+ */
+ private function normalizeList(array $values): array
+ {
+ return array_values(array_filter(array_map(
+ fn (string $value): string => $this->normalize($value),
+ $values
+ )));
+ }
+
/**
* @return list
*/
diff --git a/src/Console/Commands/About.php b/src/Console/Commands/About.php
index 6f1d14c..8932a56 100644
--- a/src/Console/Commands/About.php
+++ b/src/Console/Commands/About.php
@@ -6,6 +6,7 @@
use Shift\Console\Cli;
use Shift\Console\CommandInterface;
+#[\Shift\Console\Attributes\Command('about', group: 'diagnostics')]
class About implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/CacheClear.php b/src/Console/Commands/CacheClear.php
index 9e34597..5c5e078 100644
--- a/src/Console/Commands/CacheClear.php
+++ b/src/Console/Commands/CacheClear.php
@@ -6,6 +6,7 @@
use Shift\Console\CommandInterface;
use Shift\Modules\ModuleLoader;
+#[\Shift\Console\Attributes\Command('cache:clear', aliases: ['cc'], group: 'cache')]
class CacheClear implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/CacheModules.php b/src/Console/Commands/CacheModules.php
index 964f72e..f6ca944 100644
--- a/src/Console/Commands/CacheModules.php
+++ b/src/Console/Commands/CacheModules.php
@@ -6,6 +6,7 @@
use Shift\Console\CommandInterface;
use Shift\Modules\ModuleLoader;
+#[\Shift\Console\Attributes\Command('cache:modules', aliases: ['cm'], group: 'cache')]
class CacheModules implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/CacheStatus.php b/src/Console/Commands/CacheStatus.php
index fc420ca..595a89e 100644
--- a/src/Console/Commands/CacheStatus.php
+++ b/src/Console/Commands/CacheStatus.php
@@ -6,6 +6,7 @@
use Shift\Console\CommandInterface;
use Shift\Modules\ModuleLoader;
+#[\Shift\Console\Attributes\Command('cache:status', aliases: ['cs'], group: 'cache')]
class CacheStatus implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/CreateCommand.php b/src/Console/Commands/CreateCommand.php
index f7fb4db..58f99ef 100644
--- a/src/Console/Commands/CreateCommand.php
+++ b/src/Console/Commands/CreateCommand.php
@@ -6,6 +6,7 @@
use Shift\Console\Generator\GeneratesFiles;
use Shift\Console\Generator\NameFormatter;
+#[\Shift\Console\Attributes\Command('create:command', group: 'make')]
class CreateCommand implements CommandInterface
{
use GeneratesFiles;
diff --git a/src/Console/Commands/CreateController.php b/src/Console/Commands/CreateController.php
index 60729d8..d474e61 100644
--- a/src/Console/Commands/CreateController.php
+++ b/src/Console/Commands/CreateController.php
@@ -6,6 +6,7 @@
use Shift\Console\Generator\GeneratesFiles;
use Shift\Console\Generator\NameFormatter;
+#[\Shift\Console\Attributes\Command('create:controller', group: 'make')]
class CreateController implements CommandInterface
{
use GeneratesFiles;
diff --git a/src/Console/Commands/CreateDto.php b/src/Console/Commands/CreateDto.php
index c0a85d5..236859d 100644
--- a/src/Console/Commands/CreateDto.php
+++ b/src/Console/Commands/CreateDto.php
@@ -6,6 +6,7 @@
use Shift\Console\Generator\GeneratesFiles;
use Shift\Console\Generator\NameFormatter;
+#[\Shift\Console\Attributes\Command('create:dto', group: 'make')]
class CreateDto implements CommandInterface
{
use GeneratesFiles;
diff --git a/src/Console/Commands/CreateMiddleware.php b/src/Console/Commands/CreateMiddleware.php
index 8aaba41..d5f3b81 100644
--- a/src/Console/Commands/CreateMiddleware.php
+++ b/src/Console/Commands/CreateMiddleware.php
@@ -6,6 +6,7 @@
use Shift\Console\Generator\GeneratesFiles;
use Shift\Console\Generator\NameFormatter;
+#[\Shift\Console\Attributes\Command('create:middleware', group: 'make')]
class CreateMiddleware implements CommandInterface
{
use GeneratesFiles;
diff --git a/src/Console/Commands/CreateMigration.php b/src/Console/Commands/CreateMigration.php
index b3a168c..a6e68dd 100644
--- a/src/Console/Commands/CreateMigration.php
+++ b/src/Console/Commands/CreateMigration.php
@@ -8,6 +8,7 @@
use Shift\Console\Generator\NameFormatter;
use Shift\Console\Generator\StubRenderer;
+#[\Shift\Console\Attributes\Command('create:migration', group: 'database')]
class CreateMigration implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/CreateModel.php b/src/Console/Commands/CreateModel.php
index 8e2f683..2a23a17 100644
--- a/src/Console/Commands/CreateModel.php
+++ b/src/Console/Commands/CreateModel.php
@@ -6,6 +6,7 @@
use Shift\Console\Generator\GeneratesFiles;
use Shift\Console\Generator\NameFormatter;
+#[\Shift\Console\Attributes\Command('create:model', group: 'make')]
class CreateModel implements CommandInterface
{
use GeneratesFiles;
diff --git a/src/Console/Commands/CreateModule.php b/src/Console/Commands/CreateModule.php
index 49e688a..b32d100 100644
--- a/src/Console/Commands/CreateModule.php
+++ b/src/Console/Commands/CreateModule.php
@@ -6,6 +6,7 @@
use Shift\Console\Generator\GeneratesFiles;
use Shift\Console\Generator\NameFormatter;
+#[\Shift\Console\Attributes\Command('create:module', group: 'make')]
class CreateModule implements CommandInterface
{
use GeneratesFiles;
diff --git a/src/Console/Commands/CreateService.php b/src/Console/Commands/CreateService.php
index 23a4639..dbb7cab 100644
--- a/src/Console/Commands/CreateService.php
+++ b/src/Console/Commands/CreateService.php
@@ -6,6 +6,7 @@
use Shift\Console\Generator\GeneratesFiles;
use Shift\Console\Generator\NameFormatter;
+#[\Shift\Console\Attributes\Command('create:service', group: 'make')]
class CreateService implements CommandInterface
{
use GeneratesFiles;
diff --git a/src/Console/Commands/DbCheck.php b/src/Console/Commands/DbCheck.php
index 593174b..fb5eaff 100644
--- a/src/Console/Commands/DbCheck.php
+++ b/src/Console/Commands/DbCheck.php
@@ -9,6 +9,7 @@
use Shift\Database\DatabaseException;
use Throwable;
+#[\Shift\Console\Attributes\Command('db:check', group: 'diagnostics')]
class DbCheck implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/Doctor.php b/src/Console/Commands/Doctor.php
new file mode 100644
index 0000000..8a3d660
--- /dev/null
+++ b/src/Console/Commands/Doctor.php
@@ -0,0 +1,181 @@
+phpVersion(),
+ $this->extensions(),
+ $this->composerConfig(),
+ $this->phpLint(),
+ $this->testSuite(),
+ $this->environment(),
+ $this->databaseConfig(),
+ $this->moduleCache(),
+ ];
+
+ $failed = array_values(array_filter($checks, static fn (array $check): bool => $check[1] === 'fail'));
+ $cli->table(['Check', 'Status', 'Details'], $checks);
+
+ if ($failed === []) {
+ $cli->success('Doctor checks passed.');
+ return;
+ }
+
+ $cli->error('Doctor found ' . count($failed) . ' failing check(s).');
+ exit(1);
+ }
+
+ public function getHelp(): string
+ {
+ return 'Usage: ./shift doctor';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Run project diagnostics.';
+ }
+
+ private function phpVersion(): array
+ {
+ return [
+ 'PHP version',
+ version_compare(PHP_VERSION, '8.3.0', '>=') ? 'ok' : 'fail',
+ PHP_VERSION,
+ ];
+ }
+
+ private function extensions(): array
+ {
+ $missing = array_values(array_filter(
+ ['json', 'pdo'],
+ static fn (string $extension): bool => !extension_loaded($extension)
+ ));
+
+ return [
+ 'PHP extensions',
+ $missing === [] ? 'ok' : 'fail',
+ $missing === [] ? 'json, pdo' : 'Missing: ' . implode(', ', $missing),
+ ];
+ }
+
+ private function composerConfig(): array
+ {
+ $path = APP_ROOT . '/composer.json';
+
+ if (!is_file($path)) {
+ return ['Composer config', 'fail', 'composer.json not found'];
+ }
+
+ json_decode((string) file_get_contents($path), true);
+
+ return [
+ 'Composer config',
+ json_last_error() === JSON_ERROR_NONE ? 'ok' : 'fail',
+ json_last_error() === JSON_ERROR_NONE ? 'composer.json valid JSON' : json_last_error_msg(),
+ ];
+ }
+
+ private function phpLint(): array
+ {
+ $files = array_merge(
+ $this->phpFiles(APP_ROOT . '/src'),
+ $this->phpFiles(APP_ROOT . '/application'),
+ $this->phpFiles(APP_ROOT . '/tests'),
+ [APP_ROOT . '/shift']
+ );
+
+ foreach ($files as $file) {
+ if (!is_file($file)) {
+ continue;
+ }
+
+ exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($file) . ' 2>&1', $output, $exitCode);
+
+ if ($exitCode !== 0) {
+ return ['PHP lint', 'fail', basename($file) . ': ' . trim(implode(' ', $output))];
+ }
+ }
+
+ return ['PHP lint', 'ok', count($files) . ' file(s) checked'];
+ }
+
+ private function testSuite(): array
+ {
+ exec('composer test 2>&1', $output, $exitCode);
+
+ return [
+ 'Test suite',
+ $exitCode === 0 ? 'ok' : 'fail',
+ $exitCode === 0 ? 'composer test passed' : trim(implode(' ', array_slice($output, -3))),
+ ];
+ }
+
+ private function environment(): array
+ {
+ return [
+ 'Environment',
+ is_file(APP_ROOT . '/.env') ? 'ok' : 'warn',
+ is_file(APP_ROOT . '/.env') ? '.env present' : '.env not found',
+ ];
+ }
+
+ private function databaseConfig(): array
+ {
+ try {
+ $config = DatabaseConfig::fromEnv();
+ } catch (Throwable $exception) {
+ return ['Database config', 'fail', $exception->getMessage()];
+ }
+
+ return [
+ 'Database config',
+ $config->driver !== '' && $config->database !== '' ? 'ok' : 'warn',
+ $config->driver . ':' . ($config->database !== '' ? $config->database : '(empty)'),
+ ];
+ }
+
+ private function moduleCache(): array
+ {
+ $loader = new ModuleLoader();
+
+ return [
+ 'Module cache',
+ 'ok',
+ $loader->isCached() ? 'cached' : 'empty',
+ ];
+ }
+
+ private function phpFiles(string $path): array
+ {
+ if (!is_dir($path)) {
+ return [];
+ }
+
+ $files = [];
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
+ );
+
+ foreach ($iterator as $file) {
+ if ($file->isFile() && $file->getExtension() === 'php') {
+ $files[] = $file->getPathname();
+ }
+ }
+
+ sort($files);
+
+ return $files;
+ }
+}
diff --git a/src/Console/Commands/EnvCheck.php b/src/Console/Commands/EnvCheck.php
index 2cf7f65..8ce4545 100644
--- a/src/Console/Commands/EnvCheck.php
+++ b/src/Console/Commands/EnvCheck.php
@@ -6,6 +6,7 @@
use Shift\Console\Cli;
use Shift\Console\CommandInterface;
+#[\Shift\Console\Attributes\Command('env:check', group: 'diagnostics')]
class EnvCheck implements CommandInterface
{
/** @var list */
diff --git a/src/Console/Commands/Help.php b/src/Console/Commands/Help.php
index 809e492..5028e48 100644
--- a/src/Console/Commands/Help.php
+++ b/src/Console/Commands/Help.php
@@ -12,6 +12,7 @@
use Shift\Console\CommandInterface;
use Shift\Console\CommandRegistry;
+#[\Shift\Console\Attributes\Command('help', aliases: ['h'], group: 'core')]
class Help implements CommandInterface
{
public function __construct(private readonly CommandRegistry $registry = new CommandRegistry())
@@ -33,15 +34,21 @@ public function execute(mixed ...$args): void
private function displayHelpForCommand(string $command): void
{
$cli = new Cli();
- $class = $this->registry->find($command);
+ $definition = $this->registry->findDefinition($command);
- if ($class === null) {
+ if ($definition === null) {
$cli->error('Command not found: ' . $command);
return;
}
- $instance = new $class();
- $cli->info(CommandRegistry::nameFromClass($class));
+ $instance = $definition->instantiate();
+ $cli->info($definition->name);
+ $cli->debug('Group: ' . $definition->group);
+
+ if ($definition->aliases !== []) {
+ $cli->debug('Aliases: ' . implode(', ', $definition->aliases));
+ }
+
$cli->debug($instance->getDescription());
$cli->debug($instance->getHelp());
}
@@ -49,19 +56,25 @@ private function displayHelpForCommand(string $command): void
private function displayFullHelp(): void
{
$cli = new Cli();
- $rows = [];
+ $groups = [];
- foreach ($this->registry->all() as $command => $class) {
- $instance = new $class();
- $rows[] = [
- $command,
+ foreach ($this->registry->definitions() as $definition) {
+ $instance = $definition->instantiate();
+ $groups[$definition->group][] = [
+ $definition->name,
+ $definition->aliases === [] ? '' : implode(', ', $definition->aliases),
$instance->getDescription(),
];
}
- usort($rows, static fn (array $left, array $right): int => strcmp($left[0], $right[0]));
+ ksort($groups);
- $cli->table(['Command', 'Description'], $rows);
+ foreach ($groups as $group => $rows) {
+ usort($rows, static fn (array $left, array $right): int => strcmp($left[0], $right[0]));
+
+ $cli->info(ucfirst($group));
+ $cli->table(['Command', 'Aliases', 'Description'], $rows);
+ }
}
public function getHelp(): string
diff --git a/src/Console/Commands/Migrate.php b/src/Console/Commands/Migrate.php
index 25e2c59..d8e39bd 100644
--- a/src/Console/Commands/Migrate.php
+++ b/src/Console/Commands/Migrate.php
@@ -8,6 +8,7 @@
use Shift\Database\DatabaseConfig;
use Shift\Database\Migrations\MigrationRunner;
+#[\Shift\Console\Attributes\Command('migrate', aliases: ['m'], group: 'database')]
class Migrate implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/MigrateRollback.php b/src/Console/Commands/MigrateRollback.php
index 7f9461a..b5adb23 100644
--- a/src/Console/Commands/MigrateRollback.php
+++ b/src/Console/Commands/MigrateRollback.php
@@ -8,6 +8,7 @@
use Shift\Database\DatabaseConfig;
use Shift\Database\Migrations\MigrationRunner;
+#[\Shift\Console\Attributes\Command('migrate:rollback', aliases: ['rollback'], group: 'database')]
class MigrateRollback implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/MigrateStatus.php b/src/Console/Commands/MigrateStatus.php
index 0d36070..511481e 100644
--- a/src/Console/Commands/MigrateStatus.php
+++ b/src/Console/Commands/MigrateStatus.php
@@ -8,6 +8,7 @@
use Shift\Database\DatabaseConfig;
use Shift\Database\Migrations\MigrationRunner;
+#[\Shift\Console\Attributes\Command('migrate:status', aliases: ['ms'], group: 'database')]
class MigrateStatus implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/ModuleList.php b/src/Console/Commands/ModuleList.php
index 9642760..45f1e66 100644
--- a/src/Console/Commands/ModuleList.php
+++ b/src/Console/Commands/ModuleList.php
@@ -6,6 +6,7 @@
use Shift\Console\CommandInterface;
use Shift\Modules\ModuleLoader;
+#[\Shift\Console\Attributes\Command('module:list', aliases: ['modules'], group: 'modules')]
class ModuleList implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/RouteList.php b/src/Console/Commands/RouteList.php
index d083a00..c587f73 100644
--- a/src/Console/Commands/RouteList.php
+++ b/src/Console/Commands/RouteList.php
@@ -7,6 +7,7 @@
use Shift\Modules\ModuleLoader;
use Shift\Routing\Router\Router;
+#[\Shift\Console\Attributes\Command('route:list', aliases: ['routes', 'rl'], group: 'routing')]
class RouteList implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/Serve.php b/src/Console/Commands/Serve.php
index 190dfec..726395d 100644
--- a/src/Console/Commands/Serve.php
+++ b/src/Console/Commands/Serve.php
@@ -11,6 +11,7 @@
use Shift\Console\CommandInterface;
+#[\Shift\Console\Attributes\Command('serve', aliases: ['server'], group: 'server')]
class Serve implements CommandInterface
{
diff --git a/src/Console/Commands/Test.php b/src/Console/Commands/Test.php
index c836539..10f452e 100644
--- a/src/Console/Commands/Test.php
+++ b/src/Console/Commands/Test.php
@@ -5,6 +5,7 @@
use Shift\Console\Cli;
use Shift\Console\CommandInterface;
+#[\Shift\Console\Attributes\Command('test', aliases: ['t'], group: 'diagnostics')]
class Test implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Console/Generator/stubs/command.stub b/src/Console/Generator/stubs/command.stub
index 739e4ed..b48e5ab 100644
--- a/src/Console/Generator/stubs/command.stub
+++ b/src/Console/Generator/stubs/command.stub
@@ -2,9 +2,11 @@
namespace Modules\{{ module }}\Commands;
+use Shift\Console\Attributes\Command;
use Shift\Console\Cli;
use Shift\Console\CommandInterface;
+#[Command('{{ command }}', group: 'modules')]
class {{ class }} implements CommandInterface
{
public function execute(mixed ...$args): void
diff --git a/src/Logging/ExceptionLogger.php b/src/Logging/ExceptionLogger.php
index eeaf683..b606c5e 100644
--- a/src/Logging/ExceptionLogger.php
+++ b/src/Logging/ExceptionLogger.php
@@ -36,7 +36,7 @@ public function log(Throwable $exception, ?Request $request = null, ?int $status
'path' => $request->getPath(),
'ip' => $request->getIpAddress(),
'user_agent' => $request->getUserAgent(),
- 'request_id' => $request->getHeader('X-Request-Id'),
+ 'request_id' => $request->getRequestId(),
];
}
diff --git a/src/Request.php b/src/Request.php
index 7d4fbb0..aae6dbc 100755
--- a/src/Request.php
+++ b/src/Request.php
@@ -18,6 +18,7 @@ class Request
private array $attributes = [];
private ?array $jsonData = null;
private string $rawBody;
+ private string $requestId;
public function __construct(?array $serverData = null, ?array $queryParams = null, ?array $postData = null, ?string $rawBody = null)
{
@@ -26,6 +27,7 @@ public function __construct(?array $serverData = null, ?array $queryParams = nul
$this->postData = $postData ?? $_POST;
$this->rawBody = $rawBody ?? (string) file_get_contents('php://input');
$this->parseRequest();
+ $this->requestId = $this->getHeader('X-Request-Id') ?? bin2hex(random_bytes(16));
}
private function parseRequest(): void
@@ -140,6 +142,11 @@ public function getIpAddress(): ?string
return $this->serverData['REMOTE_ADDR'] ?? null;
}
+ public function getRequestId(): string
+ {
+ return $this->requestId;
+ }
+
public function setRouteParams(array $routeParams): void
{
$this->routeParams = $routeParams;
diff --git a/tests/Feature/AppDispatchTest.php b/tests/Feature/AppDispatchTest.php
index 93f4209..8fc3416 100644
--- a/tests/Feature/AppDispatchTest.php
+++ b/tests/Feature/AppDispatchTest.php
@@ -18,6 +18,7 @@
$payload = json_decode($emitter->content, true);
assertSameValue(200, $emitter->statusCode, 'App should emit successful status.');
+ assertArrayHasKeyValue('X-Request-Id', $app->getRequest()->getRequestId(), $emitter->headers, 'App should emit request id header.');
assertSameValue('demo', $payload['data']['routeParams']['argument'] ?? null, 'App should pass route params to controller.');
},
@@ -87,7 +88,11 @@ public function log(string $level, string $message, array $context = []): void
}
};
- $app = new App(makeRequest('GET', '/errors/boom'), $router, $emitter);
+ $app = new App(new Shift\Request([
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => '/errors/boom',
+ 'HTTP_X_REQUEST_ID' => 'request-500',
+ ]), $router, $emitter);
$app->getContainer()->singleton(LoggerInterface::class, $logger);
$app->start();
@@ -100,5 +105,7 @@ public function log(string $level, string $message, array $context = []): void
assertSameValue('Controller exploded', $record['message'] ?? null, 'Log message should contain the exception message.');
assertSameValue(RuntimeException::class, $record['context']['exception'] ?? null, 'Log context should include the exception class.');
assertSameValue('/errors/boom', $record['context']['request']['path'] ?? null, 'Log context should include request path.');
+ assertSameValue('request-500', $record['context']['request']['request_id'] ?? null, 'Log context should include request id.');
+ assertArrayHasKeyValue('X-Request-Id', 'request-500', $emitter->headers, 'Error responses should include request id.');
},
];
diff --git a/tests/Feature/CliHelpTest.php b/tests/Feature/CliHelpTest.php
index b68a103..8dec35d 100644
--- a/tests/Feature/CliHelpTest.php
+++ b/tests/Feature/CliHelpTest.php
@@ -9,6 +9,8 @@
$output = ob_get_clean();
assertStringContains('help', $output, 'Help list should include itself.');
+ assertStringContains('Database', $output, 'Help list should group commands.');
+ assertStringContains('Aliases', $output, 'Help list should include aliases column.');
assertStringContains('migrate', $output, 'Help list should include migration commands.');
assertStringContains('create:migration', $output, 'Help list should include migration generator.');
},
@@ -18,6 +20,8 @@
$output = ob_get_clean();
assertStringContains('migrate:status', $output, 'Command help should include normalized command name.');
+ assertStringContains('Aliases: ms', $output, 'Command help should include aliases.');
+ assertStringContains('Group: database', $output, 'Command help should include group.');
assertStringContains('Usage: ./shift migrate:status', $output, 'Command help should include command usage.');
},
];
diff --git a/tests/Feature/CliRegistryTest.php b/tests/Feature/CliRegistryTest.php
index b7325ac..627cd56 100644
--- a/tests/Feature/CliRegistryTest.php
+++ b/tests/Feature/CliRegistryTest.php
@@ -8,9 +8,12 @@
'command registry discovers built-in and module commands' => function (): void {
$registry = CommandRegistry::default();
$commands = $registry->all();
+ $definitions = $registry->definitions();
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.');
+ assertSameValue('database', $definitions['migrate:status']->group ?? null, 'Registry should expose command groups.');
+ assertSameValue(['ms'], $definitions['migrate:status']->aliases ?? null, 'Registry should expose command aliases.');
},
'command registry normalizes command names' => function (): void {
$registry = CommandRegistry::default();
@@ -18,6 +21,7 @@
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.');
+ assertSameValue(MigrateStatus::class, $registry->find('ms'), 'Aliases should resolve.');
},
'cli dispatcher runs commands through the registry' => function (): void {
ob_start();
diff --git a/tests/Feature/RequestResponseTest.php b/tests/Feature/RequestResponseTest.php
index 9a7b16d..66d2539 100644
--- a/tests/Feature/RequestResponseTest.php
+++ b/tests/Feature/RequestResponseTest.php
@@ -10,6 +10,17 @@
assertSameValue(['name' => 'Shift'], $request->getJson(), 'JSON body should parse.');
assertSameValue('Shift', $request->input('name'), 'Input should read JSON body.');
assertSameValue('Bearer token', $request->getHeader('Authorization'), 'Header should be available.');
+ assertSameValue(32, strlen($request->getRequestId()), 'Request should generate a request id.');
+ },
+
+ 'request keeps incoming request id header' => function (): void {
+ $request = new Shift\Request([
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => '/health',
+ 'HTTP_X_REQUEST_ID' => 'request-123',
+ ]);
+
+ assertSameValue('request-123', $request->getRequestId(), 'Incoming request id should be preserved.');
},
'request rejects malformed json' => function (): void {