[ 'mode' => 'argument', 'required' => true, 'description' => 'Module name (e.g., Blog)', ], 'prefix' => [ 'mode' => 'argument', 'required' => false, 'description' => 'Optional URL prefix (e.g., /blog). If omitted, you will be prompted or default is /', ], '--update-composer' => [ 'mode' => 'flag', 'description' => 'Automatically add PSR-4 mapping to composer.json and dump autoload.', ], '--no-dump' => [ 'mode' => 'flag', 'description' => 'Skip composer dump-autoload when using --update-composer.', ], ]; public function handle(Input $input, Output $output): int { $name = $this->readArgWithFallback($input, 'name', 2); $name = trim($name); if ($name === '') { $output->writeln('Module name is required.'); return 1; } $prefixArg = $this->readArgWithFallback($input, 'prefix', 3); [$prefix, $updateComposer, $noDump] = $this->parseArgsForPrefixAndFlags($prefixArg, $name, $input); $root = dirname(__DIR__, 2); $moduleRoot = $root . '/modules/' . $name; if (!$this->createScaffold($moduleRoot, $output)) { return 1; } $this->createPersistenceDir($moduleRoot, $output); $this->writeProviderStub($moduleRoot, $name); $this->writeRoutesStubs($moduleRoot, $name); $this->writeViewControllerTemplateStubs($moduleRoot, $name); $this->writeControllersIndexIfMissing($moduleRoot); // Ensure Controllers directory exists specifically for the test assertion if (!is_dir($moduleRoot . '/Controllers')) { @mkdir($moduleRoot . '/Controllers', 0777, true); } $this->registerProviderInConfig($root, $name); $this->appendRouteInclude($root, $name, $prefix); $this->printPsr4Hint($output, $name, $prefix); if ($updateComposer) { $this->updateComposerPsr4($output, $root, $name, !$noDump); } $output->writeln("\nFull documentation available at: https://getphred.com"); return 0; } private function readArgWithFallback(Input $input, string $key, int $argvIndex): string { try { $val = $input->getArgument($key); if (is_string($val)) { return $val; } } catch (\Symfony\Component\Console\Exception\InvalidArgumentException) { // Try to read from ArrayInput-style arguments map if (method_exists($input, 'getArguments')) { $args = $input->getArguments(); if (is_array($args) && isset($args[$key]) && is_string($args[$key])) { return $args[$key]; } } // Reflection fallback for ArrayInput to read raw parameters if ($input instanceof \Symfony\Component\Console\Input\ArrayInput) { try { $ref = new \ReflectionObject($input); if ($ref->hasProperty('parameters')) { $prop = $ref->getProperty('parameters'); $prop->setAccessible(true); $params = $prop->getValue($input); if (is_array($params) && isset($params[$key]) && is_string($params[$key])) { return $params[$key]; } } } catch (\Throwable) { // ignore } } // Fall back to argv position if command definition didn't register arguments in this context (e.g., direct handle() calls in tests) $argv = $_SERVER['argv'] ?? []; $fallback = isset($argv[$argvIndex]) ? (string) $argv[$argvIndex] : ''; // Sanitize: ignore flags (starting with '-') and unexpected tokens if ($fallback !== '' && $fallback[0] === '-') { return ''; } return $fallback; } return ''; } private function parseArgsForPrefixAndFlags(string $prefixArg, string $name, Input $input): array { $defaultPrefix = '/' . strtolower($name); // Detect flags robustly $updateComposer = false; $noDump = false; // Attempt to read flags from options map if available if (method_exists($input, 'getOptions')) { $opts = $input->getOptions(); if (is_array($opts)) { $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 if ($input instanceof \Symfony\Component\Console\Input\ArrayInput) { try { $ref = new \ReflectionObject($input); if ($ref->hasProperty('parameters')) { $prop = $ref->getProperty('parameters'); $prop->setAccessible(true); $params = $prop->getValue($input); if (is_array($params)) { if (array_key_exists('--update-composer', $params)) { $updateComposer = (bool) $params['--update-composer']; } if (array_key_exists('--no-dump', $params)) { $noDump = (bool) $params['--no-dump']; } } } } catch (\Throwable) { // ignore } } if (method_exists($input, 'hasParameterOption')) { /** @var \Symfony\Component\Console\Input\InputInterface $input */ if ($input->hasParameterOption('--update-composer')) { $updateComposer = true; } if ($input->hasParameterOption('--no-dump')) { $noDump = true; } } try { $updateComposer = $updateComposer || (bool) $input->getOption('update-composer'); } catch (\Symfony\Component\Console\Exception\InvalidArgumentException) {} try { $noDump = $noDump || (bool) $input->getOption('no-dump'); } catch (\Symfony\Component\Console\Exception\InvalidArgumentException) {} if ($prefixArg !== '') { $prefix = $prefixArg; } else { $prefix = $this->readPrefixInteractive($name, $defaultPrefix); } $prefix = '/' . trim((string) $prefix, '/'); return [$prefix, $updateComposer, $noDump]; } private function readPrefixInteractive(string $name, string $defaultPrefix): string { $isInteractive = function (): bool { if (function_exists('stream_isatty')) { return @stream_isatty(STDIN); } if (function_exists('posix_isatty')) { return @posix_isatty(STDIN); } return false; }; if ($isInteractive()) { fwrite(STDOUT, "Enter URL prefix for module '$name' [{$defaultPrefix}]: "); $input = fgets(STDIN); $input = $input === false ? '' : trim((string) $input); return $input === '' ? $defaultPrefix : $input; } return $defaultPrefix; } private function createScaffold(string $moduleRoot, Output $output): bool { $dirs = [ 'Controllers', 'Views', 'Templates', 'Services', 'Models', 'Repositories', 'Database/Migrations', 'Routes', 'Providers', 'Tests', ]; // Ensure module root exists first if (!is_dir($moduleRoot) && !mkdir($moduleRoot, 0777, true) && !is_dir($moduleRoot)) { $output->writeln('Failed to create directory: ' . $moduleRoot); return false; } foreach ($dirs as $dir) { $path = $moduleRoot . '/' . $dir; if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) { $output->writeln('Failed to create directory: ' . $path); return false; } } return true; } private function createPersistenceDir(string $moduleRoot, Output $output): void { $driver = getenv('ORM_DRIVER') ?: null; if (!$driver) { return; } $driverName = ucfirst(strtolower($driver)); $path = $moduleRoot . '/Persistence/' . $driverName; if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) { $output->writeln('Failed to create directory: ' . $path); } } private function writeProviderStub(string $moduleRoot, string $name): void { $providerClass = $name . 'ServiceProvider'; $providerNs = "Project\\Modules\\$name\\Providers"; $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); } private function writeRoutesStubs(string $moduleRoot, string $name): void { file_put_contents($moduleRoot . '/Routes/web.php', " $viewNs, ]); file_put_contents($moduleRoot . '/Views/HomeView.php', $viewCode); $ctrlNs = "Project\\Modules\\$name\\Controllers"; $ctrlUsesViewNs = "Project\\Modules\\$name\\Views\\HomeView"; $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); file_put_contents($moduleRoot . '/Templates/home.eyrie.php', "

\n"); } private function writeControllersIndexIfMissing(string $moduleRoot): void { $controllersDir = $moduleRoot . '/Controllers'; if (!is_dir($controllersDir)) { @mkdir($controllersDir, 0777, true); } $index = $controllersDir . '/.gitkeep'; if (!is_file($index)) { @file_put_contents($index, ""); } } private function registerProviderInConfig(string $root, string $name): void { $providersFile = $root . '/config/providers.php'; if (!is_file($providersFile)) { return; } $contents = file_get_contents($providersFile) ?: ''; $providerClass = $name . 'ServiceProvider'; // Validate module name to avoid accidental injection from CLI flags if (!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $name)) { return; } // Only register if module path exists if (!is_dir($root . '/modules/' . $name)) { return; } $fqcn = "Project\\Modules\\$name\\Providers\\$providerClass::class"; if (strpos($contents, $fqcn) !== false) { return; } $updated = preg_replace( '/(\'modules\'\s*=>\s*\[)([\s\S]*?)(\])/', "$1$2\n Project\\\\Modules\\\\$name\\\\Providers\\\\$providerClass::class,\n $3", $contents, 1 ); if ($updated) { file_put_contents($providersFile, $updated); } } private function appendRouteInclude(string $root, string $name, string $prefix): void { $routesRoot = $root . '/routes'; $webRootFile = $routesRoot . '/web.php'; if (!is_dir($routesRoot)) { mkdir($routesRoot, 0777, true); } if (!is_file($webRootFile)) { file_put_contents($webRootFile, "writeln("\nModule '$name' created in modules/$name."); $output->writeln('Remember to add PSR-4 autoload mapping in composer.json:'); $output->writeln(' "Project\\\\Modules\\\\' . $name . '\\\\": "modules/' . $name . '/"'); $output->writeln("\nModule routes are mounted at prefix '$prefix' in routes/web.php."); } private function updateComposerPsr4(Output $output, string $root, string $name, bool $dumpAutoload): void { $composer = $root . '/composer.json'; if (!is_file($composer)) { $output->writeln('composer.json not found; cannot update PSR-4 mapping.'); return; } $json = file_get_contents($composer); $data = $json ? json_decode($json, true) : null; if (!is_array($data)) { $output->writeln('composer.json parse error; aborting PSR-4 update.'); return; } $bak = $composer . '.bak'; @copy($composer, $bak); $psr4 = $data['autoload']['psr-4'] ?? []; $ns = 'Project\\Modules\\' . $name . '\\'; $path = 'modules/' . $name . '/'; $changed = false; if (!isset($psr4[$ns])) { $psr4[$ns] = $path; $data['autoload']['psr-4'] = $psr4; $changed = true; } if ($changed) { $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; if (file_put_contents($composer, $encoded) === false) { $output->writeln('Failed to write composer.json; original saved to composer.json.bak'); } else { $output->writeln('Updated composer.json with PSR-4 mapping for Project\\Modules\\' . $name . '\\.'); } } else { $output->writeln('PSR-4 mapping already exists in composer.json.'); } if ($dumpAutoload) { $out = shell_exec('composer dump-autoload 2>&1') ?: ''; $output->writeln(trim($out)); } else { $output->writeln('Skipped composer dump-autoload (use --no-dump).'); } } };