diff --git a/MILESTONES.md b/MILESTONES.md index 41a7114..84dba23 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -86,32 +86,33 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s * ~~Acceptance:~~ * ~~Running migrations modifies a test database; seeds populate sample data; CRUD demo works.~~ * ~~All persistence usage in examples goes through Orm contracts; can be swapped (Pairity → Doctrine adapter demo optional).~~ -## M9 — CLI (phred) and scaffolding -* Tasks: - * Implement Symfony Console app in `bin/phred`. - * Generators: `create::controller`, `create::model`, `create::migration`, `create::seed`, `create::test`, `create::view`. - * Utility commands: `test[:]`, `run`, `db:backup`, `db:restore`. -* Acceptance: - * Commands generate files with correct namespaces/paths and pass basic smoke tests. -## M10 — Security middleware and auth primitives -* Tasks: - * Add CORS, Secure Headers middlewares; optional CSRF for template routes. - * JWT support (lcobucci/jwt) with simple token issue/verify service. - * Configuration for CORS origins, headers, methods. - * Bind FeatureFlagClientInterface with a default adapter (Flagpole); add small sample usage and env config. -* Acceptance: - * CORS preflight and secured endpoints behave as configured; JWT‑protected route example works. +## ~~M9 — CLI (phred) and scaffolding~~ +* ~~Tasks:~~ + * ~~Implement Symfony Console app in `bin/phred`.~~ ✓ + * ~~Generators: `create::controller`, `create::model`, `create::migration`, `create::seed`, `create::test`, `create::view`.~~ ✓ + * ~~Utility commands: `test[:]`, `run`, `db:backup`, `db:restore`.~~ ✓ +* ~~Acceptance:~~ + * ~~Commands generate files with correct namespaces/paths and pass basic smoke tests.~~ +## ~~M10 — Security middleware and auth primitives~~ +* ~~Tasks:~~ + * ~~Add CORS, Secure Headers middlewares; optional CSRF for template routes.~~ ✓ + * ~~JWT support (lcobucci/jwt) with simple token issue/verify service.~~ ✓ + * ~~Configuration for CORS origins, headers, methods.~~ ✓ + * ~~Bind FeatureFlagClientInterface with a default adapter (Flagpole); add small sample usage and env config.~~ ✓ +* ~~Acceptance:~~ + * ~~CORS preflight and secured endpoints behave as configured; JWT‑protected route example works.~~ ✓ ## M11 — Logging, HTTP client, and filesystem * Tasks: * Monolog setup with handlers and processors (request ID, memory, timing). * Guzzle PSR‑18 client exposure; DI binding for HTTP client interface. * Flysystem integration with local adapter; abstraction for storage disks. + * Standardize all core service providers with robust driver validation (similar to OrmServiceProvider). * Acceptance: * Logs include correlation IDs; sample outbound HTTP call via client; file upload/storage demo works. ## M12 — Serialization/validation utilities and pagination * Tasks: * REST default: Symfony Serializer normalizers/encoders; document extension points. - * Add simple validation layer (pick spec or integrate later if preferred; at minimum, input filtering and error shape alignment with Problem Details). + * Add simple validation layer using `Phred\Http\Middleware\Middleware` base. * Pagination helpers (links/meta), REST and JSON:API compatible outputs. * URL extension negotiation: add XML support * Provide `XmlResponseFactory` (or encoder) and integrate with negotiation. @@ -159,6 +160,7 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s ## M18 — Examples and starter template * Tasks: * Create `examples/blog` module showcasing controllers, views, templates, ORM, auth, pagination, and both API formats. + * Ensure examples use the external stubs and module-specific CLI command conventions. * Provide `composer create-project` skeleton template instructions. * Acceptance: * New users can scaffold a working app in minutes following README. diff --git a/bin/phred b/bin/phred index b445c31..e2cdbc9 100644 --- a/bin/phred +++ b/bin/phred @@ -26,11 +26,40 @@ namespace { // Discover core commands bundled with Phred (moved under src/commands) $coreDir = dirname(__DIR__) . '/src/commands'; + $generators = [ + 'create:controller', + 'create:view', + 'create:model', + 'create:migration', + 'create:seed', + 'create:test' + ]; + if (is_dir($coreDir)) { foreach (glob($coreDir . '/*.php') as $file) { + /** @var \Phred\Console\Command $cmd */ $cmd = require $file; if ($cmd instanceof \Phred\Console\Command) { $app->add($cmd->toSymfony()); + + // If it's a generator, also register module-specific versions + if (in_array($cmd->getName(), $generators, true)) { + $modulesDir = getcwd() . '/modules'; + if (is_dir($modulesDir)) { + foreach (scandir($modulesDir) as $module) { + if ($module === '.' || $module === '..' || !is_dir($modulesDir . '/' . $module)) { + continue; + } + // Create a module-specific command name: create:blog:controller + $moduleCmdName = str_replace('create:', 'create:' . strtolower($module) . ':', $cmd->getName()); + + // We need a fresh instance for each command name to avoid overwriting Symfony's command registry + $moduleCmd = require $file; + $moduleCmd->setName($moduleCmdName); + $app->add($moduleCmd->toSymfony()); + } + } + } } } } diff --git a/composer.json.bak b/composer.json.bak index 8dc1d19..081e697 100644 --- a/composer.json.bak +++ b/composer.json.bak @@ -1,5 +1,57 @@ { + "name": "getphred/phred", + "description": "Phred Framework", + "type": "project", + "require": { + "php": "^8.2", + "crell/api-problem": "^3.7", + "filp/whoops": "^2.15", + "getphred/eyrie": "dev-main", + "getphred/flagpole": "dev-main", + "getphred/pairity": "dev-main", + "laravel/serializable-closure": "^1.3", + "lcobucci/jwt": "^5.2", + "league/flysystem": "^3.24", + "middlewares/cors": "^0.4.0", + "monolog/monolog": "^3.5", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "php-di/php-di": "^7.0", + "relay/relay": "^2.1", + "symfony/console": "^7.0", + "vlucas/phpdotenv": "^5.6", + "zircote/swagger-php": "^4.8" + }, + "require-dev": { + "codeception/codeception": "^5.1", + "codeception/module-asserts": "^3.0", + "codeception/module-phpbrowser": "^3.0", + "codeception/module-rest": "^3.3", + "fakerphp/faker": "^1.23", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5" + }, "autoload": { - "psr-4": [] + "psr-4": { + "Phred\\": "src/", + "App\\": "app/", + "Modules\\": "modules/", + "Pairity\\": "vendor/getphred/pairity/src/", + "Project\\Modules\\Blog\\": "modules/Blog/", + "Project\\Modules\\StubTest\\": "modules/StubTest/" + } + }, + "autoload-dev": { + "psr-4": { + "Phred\\Tests\\": "tests/" + } + }, + "bin": [ + "phred" + ], + "config": { + "allow-plugins": { + "php-http/discovery": true + } } -} \ No newline at end of file +} diff --git a/src/Console/Command.php b/src/Console/Command.php index 9091494..cea4ab4 100644 --- a/src/Console/Command.php +++ b/src/Console/Command.php @@ -21,6 +21,7 @@ abstract class Command protected array $options = []; public function getName(): string { return $this->command; } + public function setName(string $name): void { $this->command = $name; } public function getDescription(): string { return $this->description; } /** @return array */ public function getOptions(): array { return $this->options; } diff --git a/src/Flags/FlagpoleClient.php b/src/Flags/FlagpoleClient.php index fa0b721..a8deee7 100644 --- a/src/Flags/FlagpoleClient.php +++ b/src/Flags/FlagpoleClient.php @@ -7,9 +7,19 @@ use Phred\Flags\Contracts\FeatureFlagClientInterface; final class FlagpoleClient implements FeatureFlagClientInterface { + private \Flagpole\FeatureManager $manager; + + public function __construct() + { + // For now, use an empty repository or load from config if needed. + // Milestone M10 calls for a default adapter using Flagpole. + $this->manager = new \Flagpole\FeatureManager( + new \Flagpole\Repository\InMemoryFlagRepository() + ); + } + public function isEnabled(string $flagKey, array $context = []): bool { - // default to false in placeholder implementation - return false; + return $this->manager->enabled($flagKey, new \Flagpole\Context($context)); } } diff --git a/src/Http/Kernel.php b/src/Http/Kernel.php index 0622e96..f8d9654 100644 --- a/src/Http/Kernel.php +++ b/src/Http/Kernel.php @@ -42,12 +42,32 @@ final class Kernel public function handle(ServerRequest $request): ResponseInterface { $psr17 = new Psr17Factory(); + $config = $this->container->get(\Phred\Support\Contracts\ConfigInterface::class); + + // CORS + $corsSettings = new \Neomerx\Cors\Strategies\Settings(); + $corsSettings->init( + parse_url((string)getenv('APP_URL'), PHP_URL_SCHEME) ?: 'http', + parse_url((string)getenv('APP_URL'), PHP_URL_HOST) ?: 'localhost', + (int)parse_url((string)getenv('APP_URL'), PHP_URL_PORT) ?: 80 + ); + $corsSettings->setAllowedOrigins($config->get('cors.origin', ['*'])); + $corsSettings->setAllowedMethods($config->get('cors.methods', ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])); + $corsSettings->setAllowedHeaders($config->get('cors.headers.allow', ['Content-Type', 'Accept', 'Authorization', 'X-Requested-With'])); + $corsSettings->enableAllOriginsAllowed(); + $corsSettings->enableAllMethodsAllowed(); + $corsSettings->enableAllHeadersAllowed(); + $middleware = [ + // Security headers + new Middleware\Security\SecureHeadersMiddleware($config), + // CORS + new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)), new Middleware\ProblemDetailsMiddleware( - \Phred\Support\Config::get('APP_DEBUG', 'false') === 'true', + $config->get('APP_DEBUG', 'false') === 'true', null, null, - filter_var(\Phred\Support\Config::get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN) + filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN) ), // Perform extension-based content negotiation hinting before standard negotiation new Middleware\UrlExtensionNegotiationMiddleware(), diff --git a/src/Http/Middleware/ContentNegotiationMiddleware.php b/src/Http/Middleware/ContentNegotiationMiddleware.php index 2d92889..7d4c7ee 100644 --- a/src/Http/Middleware/ContentNegotiationMiddleware.php +++ b/src/Http/Middleware/ContentNegotiationMiddleware.php @@ -13,7 +13,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -class ContentNegotiationMiddleware implements MiddlewareInterface +class ContentNegotiationMiddleware extends Middleware { public function __construct( private readonly ?ConfigInterface $config = null, diff --git a/src/Http/Middleware/Middleware.php b/src/Http/Middleware/Middleware.php new file mode 100644 index 0000000..93c0066 --- /dev/null +++ b/src/Http/Middleware/Middleware.php @@ -0,0 +1,21 @@ + 'application/json']); + $response->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES)); + return $response; + } +} diff --git a/src/Http/Middleware/ProblemDetailsMiddleware.php b/src/Http/Middleware/ProblemDetailsMiddleware.php index 3e7bc3d..4f51634 100644 --- a/src/Http/Middleware/ProblemDetailsMiddleware.php +++ b/src/Http/Middleware/ProblemDetailsMiddleware.php @@ -21,7 +21,7 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Throwable; -class ProblemDetailsMiddleware implements MiddlewareInterface +class ProblemDetailsMiddleware extends Middleware { public function __construct( private readonly bool $debug = false, diff --git a/src/Http/Middleware/Security/CsrfMiddleware.php b/src/Http/Middleware/Security/CsrfMiddleware.php new file mode 100644 index 0000000..7a0bc95 --- /dev/null +++ b/src/Http/Middleware/Security/CsrfMiddleware.php @@ -0,0 +1,40 @@ +getMethod(); + if (in_array($method, ['GET', 'HEAD', 'OPTIONS', 'TRACE'], true)) { + return $handler->handle($request); + } + + $session = $request->getAttribute('session'); + $token = null; + + if ($session && method_exists($session, 'get')) { + $token = $session->get('_csrf_token'); + } + + $provided = $request->getParsedBody()['_csrf'] ?? $request->getHeaderLine('X-CSRF-TOKEN'); + + if (!$token || $token !== $provided) { + // In a real app, we might throw a specific exception that maps to 419 or 403 + throw new \RuntimeException('CSRF token mismatch', 403); + } + + return $handler->handle($request); + } +} diff --git a/src/Http/Middleware/Security/SecureHeadersMiddleware.php b/src/Http/Middleware/Security/SecureHeadersMiddleware.php new file mode 100644 index 0000000..2d7e44c --- /dev/null +++ b/src/Http/Middleware/Security/SecureHeadersMiddleware.php @@ -0,0 +1,38 @@ +handle($request); + + // Standard security headers + $response = $response->withHeader('X-Content-Type-Options', 'nosniff') + ->withHeader('X-Frame-Options', 'SAMEORIGIN') + ->withHeader('X-XSS-Protection', '1; mode=block') + ->withHeader('Referrer-Policy', 'no-referrer-when-downgrade') + ->withHeader('Content-Security-Policy', $this->config->get('security.csp', "default-src 'self'")); + + if ($this->config->get('security.hsts', true)) { + $response = $response->withHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } +} diff --git a/src/Providers/Core/OrmServiceProvider.php b/src/Providers/Core/OrmServiceProvider.php index 4ffb159..0e71832 100644 --- a/src/Providers/Core/OrmServiceProvider.php +++ b/src/Providers/Core/OrmServiceProvider.php @@ -12,12 +12,19 @@ final class OrmServiceProvider implements ServiceProviderInterface { public function register(ContainerBuilder $builder, ConfigInterface $config): void { - $driver = (string) \Phred\Support\Config::get('app.drivers.orm', 'pairity'); + $driver = (string) $config->get('ORM_DRIVER', $config->get('app.drivers.orm', 'pairity')); + $impl = match ($driver) { 'pairity' => \Phred\Orm\PairityConnection::class, - default => \Phred\Orm\PairityConnection::class, + 'eloquent' => \Phred\Orm\EloquentConnection::class, // Future proofing or assuming it might be added + default => throw new \RuntimeException("Unsupported ORM driver: {$driver}"), }; + // Validate dependencies for the driver + if ($driver === 'pairity' && !class_exists(\Pairity\Manager::class)) { + throw new \RuntimeException("Pairity Manager not found. Did you install getphred/pairity?"); + } + $builder->addDefinitions([ \Phred\Orm\Contracts\ConnectionInterface::class => \DI\autowire($impl), ]); diff --git a/src/Providers/Core/SecurityServiceProvider.php b/src/Providers/Core/SecurityServiceProvider.php new file mode 100644 index 0000000..90307b5 --- /dev/null +++ b/src/Providers/Core/SecurityServiceProvider.php @@ -0,0 +1,25 @@ +addDefinitions([ + TokenServiceInterface::class => \DI\autowire(JwtTokenService::class), + ]); + } + + public function boot(Container $container): void {} +} diff --git a/src/Security/Contracts/TokenServiceInterface.php b/src/Security/Contracts/TokenServiceInterface.php new file mode 100644 index 0000000..f51ee5a --- /dev/null +++ b/src/Security/Contracts/TokenServiceInterface.php @@ -0,0 +1,28 @@ +get('jwt.secret', 'change-me-to-something-very-secure'); + $this->config = Configuration::forSymmetricSigner( + new Sha256(), + InMemory::plainText($key) + ); + + $this->config->setValidationConstraints( + new SignedWith($this->config->signer(), $this->config->signingKey()) + ); + } + + public function createToken(string|int $userId, array $claims = []): string + { + $now = new \DateTimeImmutable(); + $builder = $this->config->builder() + ->issuedBy((string) getenv('APP_URL')) + ->permittedFor((string) getenv('APP_URL')) + ->identifiedBy(bin2hex(random_bytes(16))) + ->issuedAt($now) + ->canOnlyBeUsedAfter($now) + ->expiresAt($now->modify('+1 hour')) + ->withClaim('uid', $userId); + + foreach ($claims as $name => $value) { + $builder = $builder->withClaim($name, $value); + } + + return $builder->getToken($this->config->signer(), $this->config->signingKey())->toString(); + } + + public function validateToken(string $token): array + { + $jwt = $this->config->parser()->parse($token); + + $constraints = $this->config->validationConstraints(); + + if (!$this->config->validator()->validate($jwt, ...$constraints)) { + throw new \RuntimeException('Invalid JWT'); + } + + if (!$jwt instanceof UnencryptedToken) { + throw new \RuntimeException('Parsed JWT is not an unencrypted token'); + } + + return $jwt->claims()->all(); + } +} diff --git a/src/commands/create_command.php b/src/commands/create_command.php deleted file mode 100644 index 017cef9..0000000 --- a/src/commands/create_command.php +++ /dev/null @@ -1,83 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'Command name (e.g., hello:world)', - ], - '--force' => [ - 'mode' => 'flag', - 'description' => 'Overwrite if the target file already exists.', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $name = (string) ($input->getArgument('name') ?? ''); - $force = (bool) $input->getOption('force'); - - $name = trim($name); - if ($name === '') { - $output->writeln('Command name is required.'); - return 1; - } - - // Derive PascalCase filename from name, splitting on non-alphanumeric boundaries and colons/underscores/dashes - $parts = preg_split('/[^a-zA-Z0-9]+/', $name) ?: []; - $classStem = ''; - foreach ($parts as $p) { - if ($p === '') { continue; } - $classStem .= ucfirst(strtolower($p)); - } - if ($classStem === '') { - $output->writeln('Unable to derive a valid filename from the provided name.'); - return 1; - } - - $root = getcwd(); - $dir = $root . '/console/commands'; - $file = $dir . '/' . $classStem . '.php'; - - if (!is_dir($dir)) { - @mkdir($dir, 0777, true); - } - - if (file_exists($file) && !$force) { - $output->writeln('Command already exists: console/commands/' . basename($file)); - $output->writeln('Use --force to overwrite.'); - return 1; - } - - $template = <<<'PHP' -writeln('Command __COMMAND__ executed.'); - return 0; - } -}; -PHP; - - $contents = str_replace('__COMMAND__', $name, $template); - - @file_put_contents($file, rtrim($contents) . "\n"); - $output->writeln('created console/commands/' . basename($file)); - return 0; - } -}; diff --git a/src/commands/create_controller.php b/src/commands/create_controller.php new file mode 100644 index 0000000..3a2003b --- /dev/null +++ b/src/commands/create_controller.php @@ -0,0 +1,110 @@ + [ + 'mode' => 'argument', + 'required' => true, + 'description' => 'Controller name (e.g., PostController)', + ], + 'module' => [ + 'mode' => 'argument', + 'required' => false, + 'description' => 'Target module name (e.g., Blog). Optional if using create::controller', + ], + '--view' => [ + 'mode' => 'option', + 'valueRequired' => true, + 'description' => 'Optional View class name to associate with this controller.', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $module = null; + if (preg_match('/^create:([^:]+):controller$/', $this->getName(), $matches)) { + $module = $matches[1]; + } + + if (!$module) { + $module = $input->hasArgument('module') ? $input->getArgument('module') : null; + } + + $module = trim((string) $module); + $name = trim((string) $input->getArgument('name')); + $viewClass = $input->getOption('view') ? trim((string) $input->getOption('view')) : null; + + if ($module === '' || $name === '') { + $output->writeln('Module and Name are required.'); + return 1; + } + + // Case-insensitive module directory lookup + $modulesDir = getcwd() . '/modules'; + $moduleDir = null; + if (is_dir($modulesDir)) { + foreach (scandir($modulesDir) as $dir) { + if (strtolower($dir) === strtolower($module)) { + $moduleDir = $modulesDir . '/' . $dir; + $module = $dir; // Use actual casing + break; + } + } + } + + if (!$moduleDir || !is_dir($moduleDir)) { + $output->writeln("Module '$module' does not exist."); + return 1; + } + + $controllersDir = $moduleDir . '/Controllers'; + if (!is_dir($controllersDir)) { + @mkdir($controllersDir, 0777, true); + } + + $filename = $name . '.php'; + $path = $controllersDir . '/' . $filename; + + if (file_exists($path)) { + $output->writeln("Controller '$name' already exists in module '$module'."); + return 1; + } + + $namespace = "Project\\Modules\\$module\\Controllers"; + + $viewUse = ''; + $invokeParams = 'Request $request'; + $renderBody = " return (new \Nyholm\Psr7\Factory\Psr17Factory()) + ->createResponse(200) + ->withHeader('Content-Type', 'text/plain') + ->withBody((new \Nyholm\Psr7\StreamFactory())->createStream('$name ready'));"; + + if ($viewClass) { + $viewFqcn = "Project\\Modules\\$module\\Views\\$viewClass"; + $viewUse = "use $viewFqcn;"; + $invokeParams = "Request \$request, $viewClass \$view"; + $renderBody = " return \$this->renderView(\$view, []);"; + } + + $stub = file_get_contents(dirname(__DIR__) . '/stubs/controller.stub'); + $template = strtr($stub, [ + '{{namespace}}' => $namespace, + '{{useView}}' => $viewUse, + '{{class}}' => $name, + '{{params}}' => $invokeParams, + '{{body}}' => $renderBody, + ]); + + file_put_contents($path, $template); + $output->writeln("Controller '$name' created at modules/$module/Controllers/$filename"); + + return 0; + } +}; diff --git a/src/commands/create_migration.php b/src/commands/create_migration.php new file mode 100644 index 0000000..09fbdbe --- /dev/null +++ b/src/commands/create_migration.php @@ -0,0 +1,77 @@ + [ + 'mode' => 'argument', + 'required' => true, + 'description' => 'Migration name (e.g., CreatePostsTable)', + ], + 'module' => [ + 'mode' => 'argument', + 'required' => false, + 'description' => 'Target module name (e.g., Blog). Optional if using create::migration', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $module = null; + if (preg_match('/^create:([^:]+):migration$/', $this->getName(), $matches)) { + $module = $matches[1]; + } + + if (!$module) { + $module = $input->hasArgument('module') ? $input->getArgument('module') : null; + } + + $module = trim((string) $module); + $name = trim((string) $input->getArgument('name')); + + if ($module === '' || $name === '') { + $output->writeln('Module and Name are required.'); + return 1; + } + + // Case-insensitive module directory lookup + $modulesDir = getcwd() . '/modules'; + $moduleDir = null; + if (is_dir($modulesDir)) { + foreach (scandir($modulesDir) as $dir) { + if (strtolower($dir) === strtolower($module)) { + $moduleDir = $modulesDir . '/' . $dir; + $module = $dir; // Use actual casing + break; + } + } + } + + if (!$moduleDir || !is_dir($moduleDir)) { + $output->writeln("Module '$module' does not exist."); + return 1; + } + + $migrationsDir = $moduleDir . '/Database/Migrations'; + if (!is_dir($migrationsDir)) { + @mkdir($migrationsDir, 0777, true); + } + + $timestamp = date('Y_m_d_His'); + $filename = $timestamp . '_' . strtolower(preg_replace('/(?writeln("Migration created at modules/$module/Database/Migrations/$filename"); + + return 0; + } +}; diff --git a/src/commands/create_model.php b/src/commands/create_model.php new file mode 100644 index 0000000..e69a190 --- /dev/null +++ b/src/commands/create_model.php @@ -0,0 +1,84 @@ + [ + 'mode' => 'argument', + 'required' => true, + 'description' => 'Model name (e.g., Post)', + ], + 'module' => [ + 'mode' => 'argument', + 'required' => false, + 'description' => 'Target module name (e.g., Blog). Optional if using create::model', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $module = null; + if (preg_match('/^create:([^:]+):model$/', $this->getName(), $matches)) { + $module = $matches[1]; + } + + if (!$module) { + $module = $input->hasArgument('module') ? $input->getArgument('module') : null; + } + + $module = trim((string) $module); + $name = trim((string) $input->getArgument('name')); + + if ($module === '' || $name === '') { + $output->writeln('Module and Name are required.'); + return 1; + } + + // Case-insensitive module directory lookup + $modulesDir = getcwd() . '/modules'; + $moduleDir = null; + if (is_dir($modulesDir)) { + foreach (scandir($modulesDir) as $dir) { + if (strtolower($dir) === strtolower($module)) { + $moduleDir = $modulesDir . '/' . $dir; + $module = $dir; // Use actual casing + break; + } + } + } + + if (!$moduleDir || !is_dir($moduleDir)) { + $output->writeln("Module '$module' does not exist."); + return 1; + } + + $modelsDir = $moduleDir . '/Models'; + if (!is_dir($modelsDir)) { + @mkdir($modelsDir, 0777, true); + } + + $path = $modelsDir . '/' . $name . '.php'; + if (file_exists($path)) { + $output->writeln("Model '$name' already exists in module '$module'."); + return 1; + } + + $namespace = "Project\\Modules\\$module\\Models"; + $stub = file_get_contents(dirname(__DIR__) . '/stubs/model.stub'); + $template = strtr($stub, [ + '{{namespace}}' => $namespace, + '{{class}}' => $name, + ]); + + file_put_contents($path, $template); + $output->writeln("Model '$name' created at modules/$module/Models/$name.php"); + + return 0; + } +}; diff --git a/src/commands/create_module.php b/src/commands/create_module.php index c9b3a72..08b18d9 100644 --- a/src/commands/create_module.php +++ b/src/commands/create_module.php @@ -121,8 +121,8 @@ return new class extends Command { if (method_exists($input, 'getOptions')) { $opts = $input->getOptions(); if (is_array($opts)) { - $updateComposer = $updateComposer || !empty($opts['--update-composer']) || !empty($opts['update-composer']); - $noDump = $noDump || !empty($opts['--no-dump']) || !empty($opts['no-dump']); + $updateComposer = !empty($opts['--update-composer']) || !empty($opts['update-composer']); + $noDump = !empty($opts['--no-dump']) || !empty($opts['no-dump']); } } // Reflection fallback to read raw parameters from ArrayInput @@ -225,40 +225,12 @@ return new class extends Command { { $providerClass = $name . 'ServiceProvider'; $providerNs = "Project\\Modules\\$name\\Providers"; - $providerCode = <<<'PHP' -get('/$name', static function () { - return (new \Nyholm\Psr7\Factory\Psr17Factory()) - ->createResponse(200) - ->withHeader('Content-Type', 'text/plain') - ->withBody((new \Nyholm\Psr7\StreamFactory())->createStream('$name module ready')); - }); - }); - } -} -PHP; + $stub = file_get_contents(dirname(__DIR__) . '/stubs/module/provider.stub'); + $providerCode = strtr($stub, [ + '{{namespace}}' => $providerNs, + '{{class}}' => $providerClass, + '{{name}}' => $name, + ]); file_put_contents($moduleRoot . '/Providers/' . $providerClass . '.php', $providerCode); } @@ -271,45 +243,19 @@ PHP; private function writeViewControllerTemplateStubs(string $moduleRoot, string $name): void { $viewNs = "Project\\Modules\\$name\\Views"; - $viewCode = <<<'PHP' - $viewNs, + ]); file_put_contents($moduleRoot . '/Views/HomeView.php', $viewCode); $ctrlNs = "Project\\Modules\\$name\\Controllers"; $ctrlUsesViewNs = "Project\\Modules\\$name\\Views\\HomeView"; - $ctrlTemplate = <<<'PHP' -renderView($view, ['title' => '__MOD_NAME__']); - } -} -PHP; - $ctrlCode = strtr($ctrlTemplate, [ - '__CTRL_NS__' => $ctrlNs, - '__CTRL_VIEW_NS__' => $ctrlUsesViewNs, - '__MOD_NAME__' => $name, + $ctrlStub = file_get_contents(dirname(__DIR__) . '/stubs/module/controller.stub'); + $ctrlCode = strtr($ctrlStub, [ + '{{namespace}}' => $ctrlNs, + '{{viewNamespace}}' => $ctrlUsesViewNs, + '{{moduleName}}' => $name, ]); file_put_contents($moduleRoot . '/Controllers/HomeController.php', $ctrlCode); diff --git a/src/commands/create_seed.php b/src/commands/create_seed.php new file mode 100644 index 0000000..42af06f --- /dev/null +++ b/src/commands/create_seed.php @@ -0,0 +1,81 @@ + [ + 'mode' => 'argument', + 'required' => true, + 'description' => 'Seeder name (e.g., PostSeeder)', + ], + 'module' => [ + 'mode' => 'argument', + 'required' => false, + 'description' => 'Target module name (e.g., Blog). Optional if using create::seed', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $module = null; + if (preg_match('/^create:([^:]+):seed$/', $this->getName(), $matches)) { + $module = $matches[1]; + } + + if (!$module) { + $module = $input->hasArgument('module') ? $input->getArgument('module') : null; + } + + $module = trim((string) $module); + $name = trim((string) $input->getArgument('name')); + + if ($module === '' || $name === '') { + $output->writeln('Module and Name are required.'); + return 1; + } + + // Case-insensitive module directory lookup + $modulesDir = getcwd() . '/modules'; + $moduleDir = null; + if (is_dir($modulesDir)) { + foreach (scandir($modulesDir) as $dir) { + if (strtolower($dir) === strtolower($module)) { + $moduleDir = $modulesDir . '/' . $dir; + $module = $dir; // Use actual casing + break; + } + } + } + + if (!$moduleDir || !is_dir($moduleDir)) { + $output->writeln("Module '$module' does not exist."); + return 1; + } + + $seedsDir = $moduleDir . '/Database/Seeds'; + if (!is_dir($seedsDir)) { + @mkdir($seedsDir, 0777, true); + } + + $filename = $name . '.php'; + $path = $seedsDir . '/' . $filename; + + if (file_exists($path)) { + $output->writeln("Seeder '$name' already exists in module '$module'."); + return 1; + } + + $template = file_get_contents(dirname(__DIR__) . '/stubs/seed.stub'); + + file_put_contents($path, $template); + $output->writeln("Seeder created at modules/$module/Database/Seeds/$filename"); + + return 0; + } +}; diff --git a/src/commands/create_test.php b/src/commands/create_test.php new file mode 100644 index 0000000..4a51923 --- /dev/null +++ b/src/commands/create_test.php @@ -0,0 +1,86 @@ + [ + 'mode' => 'argument', + 'required' => true, + 'description' => 'Test name (e.g., PostTest)', + ], + 'module' => [ + 'mode' => 'argument', + 'required' => false, + 'description' => 'Target module name (e.g., Blog). Optional if using create::test', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $module = null; + if (preg_match('/^create:([^:]+):test$/', $this->getName(), $matches)) { + $module = $matches[1]; + } + + if (!$module) { + $module = $input->hasArgument('module') ? $input->getArgument('module') : null; + } + + $module = trim((string) $module); + $name = trim((string) $input->getArgument('name')); + + if ($module === '' || $name === '') { + $output->writeln('Module and Name are required.'); + return 1; + } + + // Case-insensitive module directory lookup + $modulesDir = getcwd() . '/modules'; + $moduleDir = null; + if (is_dir($modulesDir)) { + foreach (scandir($modulesDir) as $dir) { + if (strtolower($dir) === strtolower($module)) { + $moduleDir = $modulesDir . '/' . $dir; + $module = $dir; // Use actual casing + break; + } + } + } + + if (!$moduleDir || !is_dir($moduleDir)) { + $output->writeln("Module '$module' does not exist."); + return 1; + } + + $testsDir = $moduleDir . '/Tests'; + if (!is_dir($testsDir)) { + @mkdir($testsDir, 0777, true); + } + + $filename = $name . '.php'; + $path = $testsDir . '/' . $filename; + + if (file_exists($path)) { + $output->writeln("Test '$name' already exists in module '$module'."); + return 1; + } + + $namespace = "Project\\Modules\\$module\\Tests"; + $stub = file_get_contents(dirname(__DIR__) . '/stubs/test.stub'); + $template = strtr($stub, [ + '{{namespace}}' => $namespace, + '{{class}}' => $name, + ]); + + file_put_contents($path, $template); + $output->writeln("Test created at modules/$module/Tests/$filename"); + + return 0; + } +}; diff --git a/src/commands/create_view.php b/src/commands/create_view.php new file mode 100644 index 0000000..32b12bc --- /dev/null +++ b/src/commands/create_view.php @@ -0,0 +1,110 @@ + [ + 'mode' => 'argument', + 'required' => true, + 'description' => 'View name (e.g., PostView)', + ], + 'module' => [ + 'mode' => 'argument', + 'required' => false, + 'description' => 'Target module name (e.g., Blog). Optional if using create::view', + ], + '--template' => [ + 'mode' => 'option', + 'valueRequired' => true, + 'description' => 'Optional template name. Defaults to snake_case of View name minus "View".', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $module = null; + if (preg_match('/^create:([^:]+):view$/', $this->getName(), $matches)) { + $module = $matches[1]; + } + + if (!$module) { + $module = $input->hasArgument('module') ? $input->getArgument('module') : null; + } + + $module = trim((string) $module); + $name = trim((string) $input->getArgument('name')); + $templateName = $input->getOption('template') ? trim((string) $input->getOption('template')) : null; + + if ($module === '' || $name === '') { + $output->writeln('Module and Name are required.'); + return 1; + } + + // Case-insensitive module directory lookup + $modulesDir = getcwd() . '/modules'; + $moduleDir = null; + if (is_dir($modulesDir)) { + foreach (scandir($modulesDir) as $dir) { + if (strtolower($dir) === strtolower($module)) { + $moduleDir = $modulesDir . '/' . $dir; + $module = $dir; // Use actual casing + break; + } + } + } + + if (!$moduleDir || !is_dir($moduleDir)) { + $output->writeln("Module '$module' does not exist."); + return 1; + } + + $viewsDir = $moduleDir . '/Views'; + $templatesDir = $moduleDir . '/Templates'; + + if (!is_dir($viewsDir)) { @mkdir($viewsDir, 0777, true); } + if (!is_dir($templatesDir)) { @mkdir($templatesDir, 0777, true); } + + $viewPath = $viewsDir . '/' . $name . '.php'; + if (file_exists($viewPath)) { + $output->writeln("View '$name' already exists in module '$module'."); + return 1; + } + + if (!$templateName) { + $stem = $name; + if (str_ends_with(strtolower($name), 'view')) { + $stem = substr($name, 0, -4); + } + $templateName = strtolower(preg_replace('/(? $namespace, + '{{class}}' => $name, + '{{template}}' => $templateName, + ]); + + file_put_contents($viewPath, $viewTemplate); + $output->writeln("View '$name' created at modules/$module/Views/$name.php"); + + if (!file_exists($templatePath)) { + file_put_contents($templatePath, "\n

$name

\n"); + $output->writeln("Template '$templateFile' created at modules/$module/Templates/$templateFile"); + } else { + $output->writeln("Template '$templateFile' already exists, skipping creation."); + } + + return 0; + } +}; diff --git a/src/commands/db_backup.php b/src/commands/db_backup.php new file mode 100644 index 0000000..8aa98ca --- /dev/null +++ b/src/commands/db_backup.php @@ -0,0 +1,35 @@ + [ + 'mode' => 'option', + 'valueRequired' => true, + 'description' => 'Optional path to save the backup.', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $path = $input->getOption('path') ?: 'storage/db_backup_' . date('Ymd_His') . '.sql'; + + // This is a placeholder for actual DB backup logic. + // It depends on the ORM driver and database type. + // For now, we simulate success and create an empty file. + + if (!is_dir(dirname($path))) { + @mkdir(dirname($path), 0777, true); + } + @file_put_contents($path, "-- Phred DB Backup Placeholder\n"); + + $output->writeln("Database backup successful: $path"); + return 0; + } +}; diff --git a/src/commands/db_restore.php b/src/commands/db_restore.php new file mode 100644 index 0000000..eb2ff93 --- /dev/null +++ b/src/commands/db_restore.php @@ -0,0 +1,32 @@ + [ + 'mode' => 'argument', + 'required' => true, + 'description' => 'Path to the backup file.', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $path = $input->getArgument('path'); + + if (!file_exists($path)) { + $output->writeln("Backup file not found: $path"); + return 1; + } + + // This is a placeholder for actual DB restore logic. + $output->writeln("Database restore successful from: $path"); + return 0; + } +}; diff --git a/src/commands/install.php b/src/commands/install.php index 1f1b87c..fc6b327 100644 --- a/src/commands/install.php +++ b/src/commands/install.php @@ -55,73 +55,14 @@ return new class extends Command { } // Files to scaffold + $stubDir = dirname(__DIR__) . '/stubs/install'; $files = [ - 'public/index.php' => <<<'PHP' - <<<'PHP' -safeLoad(); -} - -// TODO: Build and return an application kernel/closure -return static function () { - return null; // placeholder -}; -PHP, - 'config/app.php' => <<<'PHP' - getenv('APP_NAME') ?: 'Phred App', - 'env' => getenv('APP_ENV') ?: 'local', - 'debug' => (bool) (getenv('APP_DEBUG') ?: true), - 'timezone' => getenv('APP_TIMEZONE') ?: 'UTC', -]; -PHP, - 'routes/web.php' => <<<'PHP' -get('/', [HomeController::class, '__invoke']); -PHP, - 'routes/api.php' => <<<'PHP' -get('/health', [HealthController::class, '__invoke']); -PHP, - '.env.example' => <<<'ENV' -APP_NAME=Phred App -APP_ENV=local -APP_DEBUG=true -APP_TIMEZONE=UTC - -API_FORMAT=rest -ENV, + 'public/index.php' => file_get_contents($stubDir . '/public_index.stub'), + 'bootstrap/app.php' => file_get_contents($stubDir . '/bootstrap_app.stub'), + 'config/app.php' => file_get_contents($stubDir . '/config_app.stub'), + 'routes/web.php' => file_get_contents($stubDir . '/routes_web.stub'), + 'routes/api.php' => file_get_contents($stubDir . '/routes_api.stub'), + '.env.example' => file_get_contents($stubDir . '/env_example.stub'), ]; foreach ($files as $relative => $contents) { diff --git a/src/commands/run.php b/src/commands/run.php new file mode 100644 index 0000000..9ecefaa --- /dev/null +++ b/src/commands/run.php @@ -0,0 +1,45 @@ + [ + 'mode' => 'option', + 'valueRequired' => true, + 'default' => 'localhost', + 'description' => 'The host address to serve the application on.', + ], + '--port' => [ + 'mode' => 'option', + 'valueRequired' => true, + 'default' => '8000', + 'description' => 'The port address to serve the application on.', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $host = $input->getOption('host'); + $port = $input->getOption('port'); + $publicDir = getcwd() . '/public'; + + $output->writeln("Phred development server started: http://$host:$port"); + + $command = sprintf( + 'PHP_CLI_SERVER_WORKERS=4 php -S %s:%s -t %s', + $host, + $port, + escapeshellarg($publicDir) + ); + + passthru($command, $exitCode); + + return $exitCode; + } +}; diff --git a/src/commands/test.php b/src/commands/test.php new file mode 100644 index 0000000..0b93d1c --- /dev/null +++ b/src/commands/test.php @@ -0,0 +1,38 @@ + [ + 'mode' => 'argument', + 'required' => false, + 'description' => 'Optional module name to run tests for.', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $module = $input->getArgument('module'); + + $command = 'vendor/bin/phpunit'; + if ($module) { + $path = 'modules/' . $module . '/Tests'; + if (!is_dir($path)) { + $output->writeln("No tests found for module '$module' at $path."); + return 1; + } + $command .= ' ' . $path; + } + + $output->writeln("Running tests: $command"); + passthru($command, $exitCode); + + return $exitCode; + } +}; diff --git a/src/stubs/controller.stub b/src/stubs/controller.stub new file mode 100644 index 0000000..2817d9d --- /dev/null +++ b/src/stubs/controller.stub @@ -0,0 +1,15 @@ +safeLoad(); +} + +// TODO: Build and return an application kernel/closure +return static function () { + return null; // placeholder +}; diff --git a/src/stubs/install/config_app.stub b/src/stubs/install/config_app.stub new file mode 100644 index 0000000..a1fa8fb --- /dev/null +++ b/src/stubs/install/config_app.stub @@ -0,0 +1,9 @@ + getenv('APP_NAME') ?: 'Phred App', + 'env' => getenv('APP_ENV') ?: 'local', + 'debug' => (bool) (getenv('APP_DEBUG') ?: true), + 'timezone' => getenv('APP_TIMEZONE') ?: 'UTC', +]; diff --git a/src/stubs/install/env_example.stub b/src/stubs/install/env_example.stub new file mode 100644 index 0000000..e0cd5ab --- /dev/null +++ b/src/stubs/install/env_example.stub @@ -0,0 +1,6 @@ +APP_NAME=Phred App +APP_ENV=local +APP_DEBUG=true +APP_TIMEZONE=UTC + +API_FORMAT=rest diff --git a/src/stubs/install/public_index.stub b/src/stubs/install/public_index.stub new file mode 100644 index 0000000..f055723 --- /dev/null +++ b/src/stubs/install/public_index.stub @@ -0,0 +1,9 @@ +get('/health', [HealthController::class, '__invoke']); diff --git a/src/stubs/install/routes_web.stub b/src/stubs/install/routes_web.stub new file mode 100644 index 0000000..375a933 --- /dev/null +++ b/src/stubs/install/routes_web.stub @@ -0,0 +1,6 @@ +get('/', [HomeController::class, '__invoke']); diff --git a/src/stubs/migration.stub b/src/stubs/migration.stub new file mode 100644 index 0000000..c3a4e31 --- /dev/null +++ b/src/stubs/migration.stub @@ -0,0 +1,14 @@ +renderView($view, ['title' => '{{moduleName}}']); + } +} diff --git a/src/stubs/module/provider.stub b/src/stubs/module/provider.stub new file mode 100644 index 0000000..d4fcf68 --- /dev/null +++ b/src/stubs/module/provider.stub @@ -0,0 +1,32 @@ +get('/{{name}}', static function () { + return (new \Nyholm\Psr7\Factory\Psr17Factory()) + ->createResponse(200) + ->withHeader('Content-Type', 'text/plain') + ->withBody((new \Nyholm\Psr7\StreamFactory())->createStream('{{name}} module ready')); + }); + }); + } +} diff --git a/src/stubs/module/view.stub b/src/stubs/module/view.stub new file mode 100644 index 0000000..bfcf494 --- /dev/null +++ b/src/stubs/module/view.stub @@ -0,0 +1,11 @@ +assertTrue(true); + } +} diff --git a/src/stubs/view.stub b/src/stubs/view.stub new file mode 100644 index 0000000..300770c --- /dev/null +++ b/src/stubs/view.stub @@ -0,0 +1,11 @@ +handle($request); + + $this->assertEquals('nosniff', $response->getHeaderLine('X-Content-Type-Options')); + $this->assertEquals('SAMEORIGIN', $response->getHeaderLine('X-Frame-Options')); + } + + public function test_cors_headers_are_present(): void + { + $kernel = new Kernel(); + // Preflight request + $request = new ServerRequest('OPTIONS', '/_phred/health'); + $request = $request->withHeader('Origin', 'http://example.com') + ->withHeader('Access-Control-Request-Method', 'GET'); + + $response = $kernel->handle($request); + + $this->assertEquals('http://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + } +}