Phred/src/commands/create_module.php

383 lines
16 KiB
PHP
Raw Normal View History

Refactor M7 module scaffolding, route inclusion, and tests; implement providers discovery; fix URL extension negotiation; clean docs • Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes • Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes • Implement create:module as a console Command (extends Phred\Console\Command): ◦ Args: name, prefix; Flags: --update-composer, --no-dump ◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput ◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep ◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders) ◦ Appends guarded include snippet to routes/web.php ◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump ◦ Prevents providers.php corruption via name validation and existence checks • Add URL extension negotiation middleware tweaks: ◦ Only set Accept for .json (and future .xml), never for none/php ◦ Never override existing Accept header • Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract • Add tests: ◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping ◦ ProviderRouteTest for provider-contributed route ◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior ◦ MvcViewTest validates transformData+render • Fix config/providers.php syntax and add comment placeholder for modules • Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow • Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
2025-12-16 22:14:22 +00:00
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:module';
protected string $description = 'Scaffold a new Module with routes, provider, and optional PSR-4 setup.';
protected array $options = [
// Match existing command style: arguments are declared as simple keys without leading dashes
'name' => [
'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 /<name-lower>',
],
'--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('<error>Module name is required.</error>');
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);
}
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']);
Refactor M7 module scaffolding, route inclusion, and tests; implement providers discovery; fix URL extension negotiation; clean docs • Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes • Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes • Implement create:module as a console Command (extends Phred\Console\Command): ◦ Args: name, prefix; Flags: --update-composer, --no-dump ◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput ◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep ◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders) ◦ Appends guarded include snippet to routes/web.php ◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump ◦ Prevents providers.php corruption via name validation and existence checks • Add URL extension negotiation middleware tweaks: ◦ Only set Accept for .json (and future .xml), never for none/php ◦ Never override existing Accept header • Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract • Add tests: ◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping ◦ ProviderRouteTest for provider-contributed route ◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior ◦ MvcViewTest validates transformData+render • Fix config/providers.php syntax and add comment placeholder for modules • Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow • Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
2025-12-16 22:14:22 +00:00
}
}
// 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('<error>Failed to create directory:</error> ' . $moduleRoot);
return false;
}
foreach ($dirs as $dir) {
$path = $moduleRoot . '/' . $dir;
if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) {
$output->writeln('<error>Failed to create directory:</error> ' . $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('<error>Failed to create directory:</error> ' . $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,
]);
Refactor M7 module scaffolding, route inclusion, and tests; implement providers discovery; fix URL extension negotiation; clean docs • Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes • Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes • Implement create:module as a console Command (extends Phred\Console\Command): ◦ Args: name, prefix; Flags: --update-composer, --no-dump ◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput ◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep ◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders) ◦ Appends guarded include snippet to routes/web.php ◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump ◦ Prevents providers.php corruption via name validation and existence checks • Add URL extension negotiation middleware tweaks: ◦ Only set Accept for .json (and future .xml), never for none/php ◦ Never override existing Accept header • Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract • Add tests: ◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping ◦ ProviderRouteTest for provider-contributed route ◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior ◦ MvcViewTest validates transformData+render • Fix config/providers.php syntax and add comment placeholder for modules • Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow • Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
2025-12-16 22:14:22 +00:00
file_put_contents($moduleRoot . '/Providers/' . $providerClass . '.php', $providerCode);
}
private function writeRoutesStubs(string $moduleRoot, string $name): void
{
file_put_contents($moduleRoot . '/Routes/web.php', "<?php\n// Module web routes for $name\n");
file_put_contents($moduleRoot . '/Routes/api.php', "<?php\n// Module API routes for $name\n");
}
private function writeViewControllerTemplateStubs(string $moduleRoot, string $name): void
{
$viewNs = "Project\\Modules\\$name\\Views";
$viewStub = file_get_contents(dirname(__DIR__) . '/stubs/module/view.stub');
$viewCode = strtr($viewStub, [
'{{namespace}}' => $viewNs,
]);
Refactor M7 module scaffolding, route inclusion, and tests; implement providers discovery; fix URL extension negotiation; clean docs • Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes • Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes • Implement create:module as a console Command (extends Phred\Console\Command): ◦ Args: name, prefix; Flags: --update-composer, --no-dump ◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput ◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep ◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders) ◦ Appends guarded include snippet to routes/web.php ◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump ◦ Prevents providers.php corruption via name validation and existence checks • Add URL extension negotiation middleware tweaks: ◦ Only set Accept for .json (and future .xml), never for none/php ◦ Never override existing Accept header • Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract • Add tests: ◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping ◦ ProviderRouteTest for provider-contributed route ◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior ◦ MvcViewTest validates transformData+render • Fix config/providers.php syntax and add comment placeholder for modules • Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow • Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
2025-12-16 22:14:22 +00:00
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,
Refactor M7 module scaffolding, route inclusion, and tests; implement providers discovery; fix URL extension negotiation; clean docs • Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes • Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes • Implement create:module as a console Command (extends Phred\Console\Command): ◦ Args: name, prefix; Flags: --update-composer, --no-dump ◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput ◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep ◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders) ◦ Appends guarded include snippet to routes/web.php ◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump ◦ Prevents providers.php corruption via name validation and existence checks • Add URL extension negotiation middleware tweaks: ◦ Only set Accept for .json (and future .xml), never for none/php ◦ Never override existing Accept header • Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract • Add tests: ◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping ◦ ProviderRouteTest for provider-contributed route ◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior ◦ MvcViewTest validates transformData+render • Fix config/providers.php syntax and add comment placeholder for modules • Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow • Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
2025-12-16 22:14:22 +00:00
]);
file_put_contents($moduleRoot . '/Controllers/HomeController.php', $ctrlCode);
file_put_contents($moduleRoot . '/Templates/home.eyrie.php', "<h1><?= htmlspecialchars(\$title) ?></h1>\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, "<?php\n");
}
$dollar = '$';
$includeSnippet = "\n" .
"// Module '$name' mounted at '$prefix' (only if module routes file exists)\n" .
"if (is_file(__DIR__ . '/../modules/$name/Routes/web.php')) {\n" .
" \\Phred\\Http\\Routing\\RouteGroups::include(" . $dollar . "router, '$prefix', function (\\Phred\\Http\\Router " . $dollar . "router) {\n" .
" /** @noinspection PhpIncludeInspection */\n" .
" (static function (" . $dollar . "router) { require __DIR__ . '/../modules/$name/Routes/web.php'; })(" . $dollar . "router);\n" .
" });\n" .
"}\n";
$currentWeb = file_get_contents($webRootFile) ?: '';
if (strpos($currentWeb, "/modules/$name/Routes/web.php") === false) {
file_put_contents($webRootFile, $currentWeb . $includeSnippet);
}
}
private function printPsr4Hint(Output $output, string $name, string $prefix): void
{
$output->writeln("\n<info>Module '$name' created</info> 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('<error>composer.json not found; cannot update PSR-4 mapping.</error>');
return;
}
$json = file_get_contents($composer);
$data = $json ? json_decode($json, true) : null;
if (!is_array($data)) {
$output->writeln('<error>composer.json parse error; aborting PSR-4 update.</error>');
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('<error>Failed to write composer.json; original saved to composer.json.bak</error>');
} else {
$output->writeln('<info>Updated composer.json</info> 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).');
}
}
};