diff --git a/system/Commands/Generators/CommandGenerator.php b/system/Commands/Generators/CommandGenerator.php index 93e1836b47a0..15073453e163 100644 --- a/system/Commands/Generators/CommandGenerator.php +++ b/system/Commands/Generators/CommandGenerator.php @@ -13,111 +13,86 @@ namespace CodeIgniter\Commands\Generators; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractGeneratorCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; use CodeIgniter\CLI\CLI; -use CodeIgniter\CLI\GeneratorTrait; - -/** - * Generates a skeleton command file. - */ -class CommandGenerator extends BaseCommand +use CodeIgniter\CLI\Input\Option; + +#[Command(name: 'make:command', description: 'Generates a new spark command.', group: 'Generators')] +#[GeneratorCommand( + component: 'Command', + template: 'command.tpl.php', + directory: 'Commands', + classNameLang: 'CLI.generator.className.command', +)] +class CommandGenerator extends AbstractGeneratorCommand { - use GeneratorTrait; - - /** - * The Command's Group - * - * @var string - */ - protected $group = 'Generators'; - - /** - * The Command's Name - * - * @var string - */ - protected $name = 'make:command'; - - /** - * The Command's Description - * - * @var string - */ - protected $description = 'Generates a new spark command.'; - - /** - * The Command's Usage - * - * @var string - */ - protected $usage = 'make:command [options]'; - - /** - * The Command's Arguments - * - * @var array - */ - protected $arguments = [ - 'name' => 'The command class name.', - ]; - - /** - * The Command's Options - * - * @var array - */ - protected $options = [ - '--command' => 'The command name. Default: "command:name"', - '--type' => 'The command type. Options [basic, generator]. Default: "basic".', - '--group' => 'The command group. Default: [basic -> "App", generator -> "Generators"].', - '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', - '--suffix' => 'Append the component title to the class name (e.g. User => UserCommand).', - '--force' => 'Force overwrite existing file.', - ]; - - /** - * Actually execute a command. - */ - public function run(array $params) + protected function configure(): void + { + parent::configure(); + + $this + ->addOption(new Option( + name: 'command', + shortcut: 'c', + description: 'The command name.', + requiresValue: true, + valueLabel: 'name', + default: 'command:name', + )) + ->addOption(new Option( + name: 'type', + shortcut: 't', + description: 'The command type: "basic" or "generator".', + requiresValue: true, + default: 'basic', + )) + ->addOption(new Option( + name: 'group', + shortcut: 'g', + description: 'The command group. Defaults to "App" for basic and "Generators" for generator commands.', + acceptsValue: true, + )); + } + + protected function interact(array &$arguments, array &$options): void { - $this->component = 'Command'; - $this->directory = 'Commands'; - $this->template = 'command.tpl.php'; + $type = $this->getUnboundOption('type', $options); - $this->classNameLang = 'CLI.generator.className.command'; - $this->generateClass($params); + if (! is_string($type) || $type === 'basic' || $type === 'generator') { + return; + } - return EXIT_SUCCESS; + $options['type'] = CLI::prompt(lang('CLI.generator.commandType'), ['basic', 'generator'], 'required'); } - /** - * Prepare options and do the necessary replacements. - */ - protected function prepare(string $class): string + protected function execute(array $arguments, array $options): int { - $command = $this->getOption('command'); - $group = $this->getOption('group'); - $type = $this->getOption('type'); - - $command = is_string($command) ? $command : 'command:name'; - $type = is_string($type) ? $type : 'basic'; - - if (! in_array($type, ['basic', 'generator'], true)) { - // @codeCoverageIgnoreStart - $type = CLI::prompt(lang('CLI.generator.commandType'), ['basic', 'generator'], 'required'); - CLI::newLine(); - // @codeCoverageIgnoreEnd + $type = $this->getValidatedOption('type'); + + if ($type !== 'basic' && $type !== 'generator') { + CLI::error(lang('CLI.generator.invalidCommandType', [$type])); + + return EXIT_ERROR; } + return $this->generateClass(); + } + + protected function getReplacements(string $class): array + { + $group = $this->getValidatedOption('group'); + if (! is_string($group)) { - $group = $type === 'generator' ? 'Generators' : 'App'; + $group = $this->getValidatedOption('type') === 'generator' ? 'Generators' : 'App'; } - return $this->parseTemplate( - $class, - ['{group}', '{command}'], - [$group, $command], - ['type' => $type], - ); + return ['{command}' => $this->getValidatedOption('command'), '{group}' => $group]; + } + + protected function getTemplateData(string $class): array + { + return ['type' => $this->getValidatedOption('type')]; } } diff --git a/system/Commands/Generators/Views/command.tpl.php b/system/Commands/Generators/Views/command.tpl.php index 840b645719d1..536084dc81b8 100644 --- a/system/Commands/Generators/Views/command.tpl.php +++ b/system/Commands/Generators/Views/command.tpl.php @@ -2,77 +2,28 @@ namespace {namespace}; -use CodeIgniter\CLI\BaseCommand; -use CodeIgniter\CLI\CLI; -use CodeIgniter\CLI\GeneratorTrait; - +use CodeIgniter\CLI\AbstractGeneratorCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; -class {class} extends BaseCommand +#[Command(name: '{command}', description: '', group: '{group}')] +#[GeneratorCommand(component: 'Command', template: 'command.tpl.php', directory: 'Commands')] +class {class} extends AbstractGeneratorCommand { - - use GeneratorTrait; - - - /** - * The Command's Group - * - * @var string - */ - protected $group = '{group}'; - - /** - * The Command's Name - * - * @var string - */ - protected $name = '{command}'; - - /** - * The Command's Description - * - * @var string - */ - protected $description = ''; - - /** - * The Command's Usage - * - * @var string - */ - protected $usage = '{command} [arguments] [options]'; - - /** - * The Command's Arguments - * - * @var array - */ - protected $arguments = []; - - /** - * The Command's Options - * - * @var array - */ - protected $options = []; +} + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; - /** - * Actually execute a command. - * - * @param array $params - */ - public function run(array $params) +#[Command(name: '{command}', description: '', group: '{group}')] +class {class} extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int { - - $this->component = 'Command'; - $this->directory = 'Commands'; - $this->template = 'command.tpl.php'; - - $this->generateClass($params); - - // your command logic here - + // return EXIT_SUCCESS; } } + diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 9a9753fd08d9..506d62e29fb0 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -44,6 +44,7 @@ 'fileExist' => 'File exists: "{0}"', 'fileOverwrite' => 'File overwritten: "{0}"', 'invalidClassName' => 'Class name "{0}" is not valid.', + 'invalidCommandType' => 'Command type "{0}" is not valid.', 'invalidParentClass' => 'Parent class "{0}" is not valid.', 'parentClass' => 'Parent class', 'returnType' => 'Return type', diff --git a/tests/system/CLI/AbstractGeneratorCommandTest.php b/tests/system/CLI/AbstractGeneratorCommandTest.php index 3830b765e993..f50e5ac5f619 100644 --- a/tests/system/CLI/AbstractGeneratorCommandTest.php +++ b/tests/system/CLI/AbstractGeneratorCommandTest.php @@ -163,6 +163,40 @@ public function testInteriorRootNamespaceSegmentIsPreserved(): void $this->assertStringContainsString('namespace App\Widgets\Sub\App;', $content); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4495 + */ + public function testInteriorCaseIsPreserved(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['TestModule'], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'TestModule.php'); + } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4857 + */ + public function testNamespaceLikeNameIsNotTreatedAsNamespace(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['App_Lesson'], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + + $target = APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'AppLesson.php'; + $this->assertFileExists($target); + $content = file_get_contents($target); + $this->assertIsString($content); + $this->assertStringContainsString('namespace App\Widgets;', $content); + $this->assertStringContainsString('class AppLesson extends BaseConfig', $content); + } + public function testTrailingSeparatorInNameIsIgnored(): void { $command = new GeneratorFixtureCommand(new Commands()); diff --git a/tests/system/Commands/Generators/CommandGeneratorTest.php b/tests/system/Commands/Generators/CommandGeneratorTest.php index 83b5cf5407c1..88c959bd9d9a 100644 --- a/tests/system/Commands/Generators/CommandGeneratorTest.php +++ b/tests/system/Commands/Generators/CommandGeneratorTest.php @@ -13,7 +13,10 @@ namespace CodeIgniter\Commands\Generators; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Commands; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; @@ -25,131 +28,120 @@ final class CommandGeneratorTest extends CIUnitTestCase { use StreamFilterTrait; - protected function tearDown(): void + protected function setUp(): void { - preg_match_all('/File (?:created|overwritten): (APPPATH[^\r\n\x1b]+)/', $this->getStreamFilterBuffer(), $matches); + parent::setUp(); - foreach ($matches[1] as $file) { - $path = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, $file); + CLI::reset(); + } - if (is_file($path)) { - unlink($path); - } + protected function tearDown(): void + { + parent::tearDown(); - $dir = dirname($path); - $dirFiles = is_dir($dir) ? scandir($dir) : false; + CLI::reset(); - if (str_starts_with($dir, APPPATH . 'Commands') && $dirFiles !== false && count($dirFiles) === 2) { - rmdir($dir); + foreach (['Deliver.php', 'Bogus.php', 'PublishCommand.php'] as $file) { + if (is_file(APPPATH . 'Commands/' . $file)) { + unlink(APPPATH . 'Commands/' . $file); } } } - protected function getFileContents(string $filepath): string + private function getUndecoratedBuffer(): string { - if (! is_file($filepath)) { - return ''; - } + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + private function getContents(string $file): string + { + $contents = file_get_contents(APPPATH . 'Commands/' . $file); + $this->assertIsString($contents); - return (string) file_get_contents($filepath); + return $contents; } public function testGenerateCommand(): void { command('make:command deliver'); - $file = APPPATH . 'Commands/Deliver.php'; - $this->assertFileExists($file); - $contents = $this->getFileContents($file); - $this->assertStringContainsString('protected $group = \'App\';', $contents); - $this->assertStringContainsString('protected $name = \'command:name\';', $contents); + + $this->assertSame("\nFile created: APPPATH/Commands/Deliver.php\n", $this->getUndecoratedBuffer()); + + $contents = $this->getContents('Deliver.php'); + $this->assertStringContainsString("#[Command(name: 'command:name', description: '', group: 'App')]", $contents); + $this->assertStringContainsString('class Deliver extends AbstractCommand', $contents); + $this->assertStringContainsString('protected function execute(array $arguments, array $options): int', $contents); } public function testGenerateCommandWithOptionCommand(): void { - command('make:command deliver -command clear:sessions'); - $file = APPPATH . 'Commands/Deliver.php'; - $this->assertFileExists($file); - $contents = $this->getFileContents($file); - $this->assertStringContainsString('protected $name = \'clear:sessions\';', $contents); - $this->assertStringContainsString('protected $usage = \'clear:sessions [arguments] [options]\';', $contents); - } + command('make:command deliver --command clear:sessions'); - public function testGenerateCommandWithOptionTypeBasic(): void - { - command('make:command deliver -type basic'); - $file = APPPATH . 'Commands/Deliver.php'; - $this->assertFileExists($file); - $contents = $this->getFileContents($file); - $this->assertStringContainsString('protected $group = \'App\';', $contents); - $this->assertStringContainsString('protected $name = \'command:name\';', $contents); + $this->assertStringContainsString( + "#[Command(name: 'clear:sessions', description: '', group: 'App')]", + $this->getContents('Deliver.php'), + ); } public function testGenerateCommandWithOptionTypeGenerator(): void { - command('make:command deliver -type generator'); - $file = APPPATH . 'Commands/Deliver.php'; - $this->assertFileExists($file); - $contents = $this->getFileContents($file); - $this->assertStringContainsString('protected $group = \'Generators\';', $contents); - $this->assertStringContainsString('protected $name = \'command:name\';', $contents); + command('make:command deliver --type generator'); + + $contents = $this->getContents('Deliver.php'); + $this->assertStringContainsString("#[Command(name: 'command:name', description: '', group: 'Generators')]", $contents); + $this->assertStringContainsString( + "#[GeneratorCommand(component: 'Command', template: 'command.tpl.php', directory: 'Commands')]", + $contents, + ); + $this->assertStringContainsString('class Deliver extends AbstractGeneratorCommand', $contents); + $this->assertStringNotContainsString('protected function execute', $contents); } public function testGenerateCommandWithOptionGroup(): void { - command('make:command deliver -group Deliverables'); - $file = APPPATH . 'Commands/Deliver.php'; - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $this->assertFileExists($file); + command('make:command deliver --group Deliverables'); - $contents = $this->getFileContents($file); - $this->assertStringContainsString('protected $group = \'Deliverables\';', $contents); + $this->assertStringContainsString( + "#[Command(name: 'command:name', description: '', group: 'Deliverables')]", + $this->getContents('Deliver.php'), + ); } - public function testGenerateCommandWithOptionSuffix(): void + public function testGenerateCommandWithShortcuts(): void { - command('make:command publish -suffix'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Commands/PublishCommand.php'; - $this->assertFileExists($file); - } + command('make:command deliver -c make:deliverable -t generator -g Deliverables'); - /** - * @see https://github.com/codeigniter4/CodeIgniter4/issues/4495 - */ - public function testGeneratorPreservesCase(): void - { - command('make:model TestModule'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Models/TestModule.php'); + $contents = $this->getContents('Deliver.php'); + $this->assertStringContainsString("#[Command(name: 'make:deliverable', description: '', group: 'Deliverables')]", $contents); + $this->assertStringContainsString('class Deliver extends AbstractGeneratorCommand', $contents); } - /** - * @see https://github.com/codeigniter4/CodeIgniter4/issues/4495 - */ - public function testGeneratorPreservesCaseButChangesComponentName(): void + public function testInvalidTypeIsRejectedWhenNotInteractive(): void { - command('make:controller TestModulecontroller'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Controllers/TestModuleController.php'); + command('make:command bogus --type advanced --no-interaction'); + + $this->assertSame("\nCommand type \"advanced\" is not valid.\n", $this->getUndecoratedBuffer()); + $this->assertFileDoesNotExist(APPPATH . 'Commands/Bogus.php'); } - /** - * @see https://github.com/codeigniter4/CodeIgniter4/issues/4857 - */ - public function testGeneratorIsNotConfusedWithNamespaceLikeClassNames(): void + public function testInvalidTypePromptsWhenInteractive(): void { - $time = time(); - $notExists = true; - command('make:migration App_Lesson'); + $io = new MockInputOutput(); + $io->setInputs(['generator']); + CLI::setInputOutput($io); - // we got 5 chances to prove that the file created went to app/Database/Migrations - foreach (range(0, 4) as $increment) { - $expectedFile = sprintf('%sDatabase/Migrations/%s_AppLesson.php', APPPATH, gmdate('Y-m-d-His', $time + $increment)); - clearstatcache(true, $expectedFile); + $command = new CommandGenerator(new Commands()); + $command->setInteractive(true); - $notExists = $notExists && ! is_file($expectedFile); - } + $this->assertSame(EXIT_SUCCESS, $command->run(['deliver'], ['type' => 'advanced'])); + $this->assertStringContainsString('Command type', $io->getOutput()); + $this->assertStringContainsString('class Deliver extends AbstractGeneratorCommand', $this->getContents('Deliver.php')); + } + + public function testGenerateCommandWithOptionSuffix(): void + { + command('make:command publish --suffix'); - $this->assertFalse($notExists, 'Creating migration file for class "AppLesson" did not go to "app/Database/Migrations"'); + $this->assertFileExists(APPPATH . 'Commands/PublishCommand.php'); } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index bb597e684bc4..8c73bea07ef4 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -33,6 +33,9 @@ Behavior Changes - **Commands:** Generator commands migrated to ``AbstractGeneratorCommand`` now return ``EXIT_ERROR`` (previously ``EXIT_SUCCESS``) when the target file exists without ``--force``, when the namespace is not defined, or when the name does not form a valid class name. The ``CodeIgniter`` namespace confirmation is no longer prompted on non-interactive runs. +- **Commands:** The ``make:command`` command now generates modern commands: a ``basic`` command extends ``AbstractCommand`` with the ``#[Command]`` attribute, + and a ``generator`` command extends ``AbstractGeneratorCommand`` with the ``#[GeneratorCommand]`` attribute. To keep generating ``BaseCommand`` classes, + copy the previous template into your project and point ``Config\Generators::$views['make:command']`` at it. - **Commands:** The success and error messages from ``debugbar:clear``, ``cache:clear``, and ``cache:info`` now include the affected path or cache driver/handler so the user can see which resource was acted on (or rejected). Scripts asserting on the prior literal text will need to be updated. - **Commands:** Declining the ``key:generate`` overwrite prompt interactively now returns ``EXIT_SUCCESS`` instead of ``EXIT_ERROR``. Output messages were also reworded; CI/automation that branches on the exit code or greps the previous wording will need updating. - **Commands:** The ``migrate:rollback`` command no longer accepts the undocumented ``-g`` (database group) option. It never had any effect, since ``MigrationRunner::regress()`` ignores the group, and the modern command pipeline now rejects unknown options. Remove ``-g`` from any ``migrate:rollback`` invocation. @@ -238,6 +241,8 @@ Commands options, which the target command validates like any other input. - The ``make:controller`` command now accepts ``-b`` and ``-r`` as shortcuts for ``--bare`` and ``--restful``, and rejects an invalid ``--restful`` value on non-interactive runs instead of prompting. +- The ``make:command`` command now accepts ``-c``, ``-t``, and ``-g`` as shortcuts for ``--command``, ``--type``, and ``--group``, and rejects an + invalid ``--type`` value on non-interactive runs instead of prompting. - Modern commands can now opt in to prompting for missing required arguments on interactive runs by implementing the new ``PromptsForMissingInputInterface`` marker interface. Prompt labels can be customized via ``getArgumentPromptLabels()``, and an ``afterPrompting()`` hook runs when prompting occurred. Non-interactive runs keep failing fast with the missing-arguments error. @@ -387,6 +392,7 @@ Message Changes - Added new language keys: - ``Cache.unsupportedLockStore`` (``CacheException::forUnsupportedLockStore()``) - ``CLI.commandAlias`` and ``CLI.helpAliases`` (command alias rendering in ``list`` and ``help``) + - ``CLI.generator.invalidCommandType`` (``make:command`` rejecting an invalid ``--type``) - ``Commands.invalidCommandAlias``, ``Commands.commandAliasSameAsName``, ``Commands.duplicateCommandAlias``, ``Commands.aliasClashesWithCommandName``, and ``Commands.aliasClashesWithAlias`` (command alias validation) - Removed deprecated language keys tied to removed exception constructors: diff --git a/user_guide_src/source/cli/cli_generators.rst b/user_guide_src/source/cli/cli_generators.rst index a5b1283fa02f..0dd3334fb378 100644 --- a/user_guide_src/source/cli/cli_generators.rst +++ b/user_guide_src/source/cli/cli_generators.rst @@ -84,12 +84,15 @@ Argument: Options: ======== -* ``--command``: The command name to run in spark. Defaults to ``command:name``. -* ``--group``: The group/namespace of the command. Defaults to ``App`` for basic commands, and ``Generators`` for generator commands. -* ``--type``: The type of command, whether a ``basic`` command or a ``generator`` command. Defaults to ``basic``. -* ``--namespace``: Set the root namespace. Defaults to value of ``APP_NAMESPACE``. -* ``--suffix``: Append the component suffix to the generated class name. -* ``--force``: Set this flag to overwrite existing files on destination. +* ``--command`` (``-c``): The command name to run in spark. Defaults to ``command:name``. +* ``--type`` (``-t``): The type of command, whether a ``basic`` command or a ``generator`` command. Defaults to ``basic``. +* ``--group`` (``-g``): The group of the command. Defaults to ``App`` for basic commands, and ``Generators`` for generator commands. +* ``--namespace`` (``-n``): Set the root namespace. Defaults to value of ``APP_NAMESPACE``. +* ``--suffix`` (``-s``): Append the component suffix to the generated class name. +* ``--force`` (``-f``): Set this flag to overwrite existing files on destination. + +.. note:: Since v4.8.0, the generated class is a :doc:`modern command `: a ``basic`` command extends + ``AbstractCommand``, and a ``generator`` command extends :doc:`AbstractGeneratorCommand `. make:config -----------