diff --git a/system/CLI/AbstractGeneratorCommand.php b/system/CLI/AbstractGeneratorCommand.php index f6fb4e0dd3de..a5ff8e081a8d 100644 --- a/system/CLI/AbstractGeneratorCommand.php +++ b/system/CLI/AbstractGeneratorCommand.php @@ -52,6 +52,11 @@ abstract class AbstractGeneratorCommand extends AbstractCommand implements Promp protected ?string $templatePath = null; + /** + * The answer to the `CodeIgniter` namespace confirmation for the current `generateClass()`, or `null` if not yet asked. + */ + private ?bool $confirmedCodeIgniterNamespace = null; + /** * @throws LogicException */ @@ -137,6 +142,8 @@ protected function execute(array $arguments, array $options): int */ protected function generateClass(): int { + $this->confirmedCodeIgniterNamespace = null; + return $this->generate($this->qualifyClassName()); } @@ -150,6 +157,14 @@ protected function generateView(string $view): int return $this->generate($view); } + /** + * Whether the `CodeIgniter` namespace confirmation was declined since the last `generateClass()`. + */ + protected function isCodeIgniterNamespaceDeclined(): bool + { + return $this->confirmedCodeIgniterNamespace === false; + } + /** * Additional template placeholder replacements, which take precedence over the core `{namespace}` / `{class}` pairs. * @@ -327,17 +342,8 @@ private function generate(string $class): int */ private function generateFile(string $target, string $content): int { - if (str_starts_with($target, SYSTEMPATH)) { - CLI::write(lang('CLI.generator.usingCINamespace'), 'yellow'); - - if ( - $this->isInteractive() - && CLI::prompt(lang('CLI.generator.confirmContinue'), ['y', 'n'], 'required') === 'n' - ) { - CLI::write(lang('CLI.generator.cancelOperation'), 'yellow'); - - return EXIT_SUCCESS; - } + if (str_starts_with($target, SYSTEMPATH) && ! $this->confirmCodeIgniterNamespaceOnce()) { + return EXIT_SUCCESS; } $isFile = is_file($target); @@ -374,6 +380,23 @@ private function generateFile(string $target, string $content): int return EXIT_SUCCESS; } + private function confirmCodeIgniterNamespaceOnce(): bool + { + if ($this->confirmedCodeIgniterNamespace !== null) { + return $this->confirmedCodeIgniterNamespace; + } + + CLI::write(lang('CLI.generator.usingCINamespace'), 'yellow'); + + if ($this->isInteractive() && CLI::prompt(lang('CLI.generator.confirmContinue'), ['y', 'n'], 'required') === 'n') { + CLI::write(lang('CLI.generator.cancelOperation'), 'yellow'); + + return $this->confirmedCodeIgniterNamespace = false; + } + + return $this->confirmedCodeIgniterNamespace = true; + } + /** * Performs the placeholder replacements on the rendered template. * diff --git a/system/Commands/Generators/ModelGenerator.php b/system/Commands/Generators/ModelGenerator.php index b3a0f7c056ac..3803bf407f90 100644 --- a/system/Commands/Generators/ModelGenerator.php +++ b/system/Commands/Generators/ModelGenerator.php @@ -82,7 +82,7 @@ protected function execute(array $arguments, array $options): int $exitCode = $this->generateClass(); - if ($exitCode !== EXIT_SUCCESS || $return !== 'entity') { + if ($exitCode !== EXIT_SUCCESS || $return !== 'entity' || $this->isCodeIgniterNamespaceDeclined()) { return $exitCode; } @@ -92,7 +92,12 @@ protected function execute(array $arguments, array $options): int $entityOptions['force'] = null; } - return $this->call('make:entity', [$this->getEntityClass($this->qualifyClassName())], $entityOptions); + return $this->call( + 'make:entity', + [$this->getEntityClass($this->qualifyClassName())], + $entityOptions, + noInteractionOverride: true, + ); } protected function getReplacements(string $class): array diff --git a/tests/system/CLI/AbstractGeneratorCommandTest.php b/tests/system/CLI/AbstractGeneratorCommandTest.php index f50e5ac5f619..25577ef5ded7 100644 --- a/tests/system/CLI/AbstractGeneratorCommandTest.php +++ b/tests/system/CLI/AbstractGeneratorCommandTest.php @@ -13,6 +13,8 @@ namespace CodeIgniter\CLI; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; use CodeIgniter\Exceptions\LogicException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockInputOutput; @@ -413,6 +415,89 @@ public function testAcceptingCodeIgniterNamespacePromptProceeds(): void $this->assertStringContainsString('File exists: ', $io->getOutput()); } + public function testNonInteractiveCodeIgniterNamespaceWarnsOnceAndProceeds(): void + { + $io = new MockInputOutput(); + CLI::setInputOutput($io); + + $command = $this->createClassAndViewGenerator(); + $command->setInteractive(false); + + $this->assertSame(EXIT_ERROR, $command->run([CLI::class], ['namespace' => 'CodeIgniter'])); + $this->assertSame( + <<<'EOT' + + Warning: Using the "CodeIgniter" namespace will generate the file in the system directory. + File exists: "SYSTEMPATH/CLI/CLI.php" + File exists: "SYSTEMPATH/CLI/Commands.php" + + EOT, + preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), + ); + } + + public function testAcceptedCodeIgniterNamespaceIsReusedForTheView(): void + { + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + $command = $this->createClassAndViewGenerator(); + $command->setInteractive(true); + + $this->assertSame(EXIT_ERROR, $command->run([CLI::class], ['namespace' => 'CodeIgniter'])); + $this->assertSame( + <<<'EOT' + + Warning: Using the "CodeIgniter" namespace will generate the file in the system directory. + Are you sure you want to continue? [y, n]: y + File exists: "SYSTEMPATH/CLI/CLI.php" + File exists: "SYSTEMPATH/CLI/Commands.php" + + EOT, + preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), + ); + } + + public function testCodeIgniterNamespaceIsConfirmedAgainOnTheNextRun(): void + { + $io = new MockInputOutput(); + $io->setInputs(['y', 'n']); + CLI::setInputOutput($io); + + $command = $this->createClassAndViewGenerator(); + $command->setInteractive(true); + + $this->assertSame(EXIT_ERROR, $command->run([CLI::class], ['namespace' => 'CodeIgniter'])); + $this->assertSame(EXIT_SUCCESS, $command->run([CLI::class], ['namespace' => 'CodeIgniter'])); + $this->assertSame( + <<<'EOT' + + Warning: Using the "CodeIgniter" namespace will generate the file in the system directory. + Are you sure you want to continue? [y, n]: y + File exists: "SYSTEMPATH/CLI/CLI.php" + File exists: "SYSTEMPATH/CLI/Commands.php" + Warning: Using the "CodeIgniter" namespace will generate the file in the system directory. + Are you sure you want to continue? [y, n]: n + Operation has been cancelled. + + EOT, + preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), + ); + } + + private function createClassAndViewGenerator(): AbstractGeneratorCommand + { + return new #[Command(name: 'make:classandview', description: 'Generates a class and a view.', group: 'Fixtures')] + #[GeneratorCommand(component: 'Widget', template: 'config.tpl.php')] + class (new Commands()) extends AbstractGeneratorCommand { + protected function execute(array $arguments, array $options): int + { + return $this->generateClass() | $this->generateView(Commands::class); + } + }; + } + public function testImportsAreSortedInGeneratedContent(): void { $command = new ImportSortingGeneratorCommand(new Commands()); diff --git a/tests/system/Commands/Generators/CellGeneratorTest.php b/tests/system/Commands/Generators/CellGeneratorTest.php index 7731835d0ccb..821a9cb29864 100644 --- a/tests/system/Commands/Generators/CellGeneratorTest.php +++ b/tests/system/Commands/Generators/CellGeneratorTest.php @@ -14,7 +14,9 @@ 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; @@ -39,12 +41,12 @@ protected function tearDown(): void CLI::reset(); - $dir = APPPATH . 'Cells'; - - if (is_dir($dir)) { - helper('filesystem'); - delete_files($dir, true, false, true); - rmdir($dir); + foreach ([APPPATH . 'Cells', SYSTEMPATH . 'Cells'] as $dir) { + if (is_dir($dir)) { + helper('filesystem'); + delete_files($dir, true, false, true); + rmdir($dir); + } } } @@ -134,4 +136,28 @@ public function testForceOverwritesBothFiles(): void $this->getUndecoratedBuffer(), ); } + + public function testDecliningCodeIgniterNamespaceAsksOnceForBothFiles(): void + { + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + $command = new CellGenerator(new Commands()); + $command->setInteractive(true); + + $this->assertSame(EXIT_SUCCESS, $command->run(['foo'], ['namespace' => 'CodeIgniter'])); + $this->assertSame( + <<<'EOT' + + Warning: Using the "CodeIgniter" namespace will generate the file in the system directory. + Are you sure you want to continue? [y, n]: n + Operation has been cancelled. + + EOT, + preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), + ); + $this->assertFileDoesNotExist(SYSTEMPATH . 'Cells' . DIRECTORY_SEPARATOR . 'FooCell.php'); + $this->assertFileDoesNotExist(SYSTEMPATH . 'Cells' . DIRECTORY_SEPARATOR . 'foo.php'); + } } diff --git a/tests/system/Commands/Generators/ModelGeneratorTest.php b/tests/system/Commands/Generators/ModelGeneratorTest.php index 3d7a18224e1d..ef75d9b223b4 100644 --- a/tests/system/Commands/Generators/ModelGeneratorTest.php +++ b/tests/system/Commands/Generators/ModelGeneratorTest.php @@ -49,7 +49,7 @@ protected function tearDown(): void helper('filesystem'); - foreach ([APPPATH . 'Models/Admin', APPPATH . 'Entities'] as $dir) { + foreach ([APPPATH . 'Models/Admin', APPPATH . 'Entities', SYSTEMPATH . 'Models', SYSTEMPATH . 'Entities'] as $dir) { if (is_dir($dir)) { delete_files($dir, true, false, true); rmdir($dir); @@ -216,4 +216,52 @@ public function testInvalidReturnTypePromptsWhenInteractive(): void $this->assertStringContainsString('Return type', $io->getOutput()); $this->assertStringContainsString("protected \$returnType = 'object';", $this->getContents('Models/User.php')); } + + public function testDecliningCodeIgniterNamespaceSkipsTheEntity(): void + { + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + $command = new ModelGenerator(new Commands()); + $command->setInteractive(true); + + $this->assertSame(EXIT_SUCCESS, $command->run(['user'], ['namespace' => 'CodeIgniter', 'return' => 'entity'])); + $this->assertSame( + <<<'EOT' + + Warning: Using the "CodeIgniter" namespace will generate the file in the system directory. + Are you sure you want to continue? [y, n]: n + Operation has been cancelled. + + EOT, + preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), + ); + $this->assertFileDoesNotExist(SYSTEMPATH . 'Models' . DIRECTORY_SEPARATOR . 'User.php'); + $this->assertFileDoesNotExist(SYSTEMPATH . 'Entities' . DIRECTORY_SEPARATOR . 'User.php'); + } + + public function testAcceptingCodeIgniterNamespaceDoesNotAskAgainForTheEntity(): void + { + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + $command = new ModelGenerator(new Commands()); + $command->setInteractive(true); + + $this->assertSame(EXIT_SUCCESS, $command->run(['user'], ['namespace' => 'CodeIgniter', 'return' => 'entity'])); + $this->assertSame( + <<<'EOT' + + Warning: Using the "CodeIgniter" namespace will generate the file in the system directory. + Are you sure you want to continue? [y, n]: y + File created: SYSTEMPATH/Models/User.php + Warning: Using the "CodeIgniter" namespace will generate the file in the system directory. + File created: SYSTEMPATH/Entities/User.php + + EOT, + preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), + ); + } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index b925dbbf119a..f9254883659c 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -35,7 +35,7 @@ Behavior Changes behaviour may need to re-implement against the modern API (``configure()`` + ``execute()`` and the ``#[Command]`` attribute) once the class it extends is migrated, or, preferably, compose instead of extending. Invocations on the command line are unaffected. - **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. + longer prompted on non-interactive runs, and ``make:cell`` and ``make:model --return entity`` now ask it only once. - **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. - **Commands:** The ``make:migration`` command now rejects ``--force`` as an unknown option. It was previously accepted but ignored, since the diff --git a/user_guide_src/source/cli/cli_modern_generators.rst b/user_guide_src/source/cli/cli_modern_generators.rst index bcdf29f557d0..10c5472a2566 100644 --- a/user_guide_src/source/cli/cli_modern_generators.rst +++ b/user_guide_src/source/cli/cli_modern_generators.rst @@ -262,8 +262,10 @@ Behavioural changes we need to be aware of when migrating: generator reported ``EXIT_SUCCESS`` even when the target file already existed. The base class returns ``EXIT_ERROR`` for an existing file without ``--force``, a write failure, and an undefined namespace. Declining the ``CodeIgniter`` namespace confirmation still exits with ``EXIT_SUCCESS``. -- **The** ``CodeIgniter`` **namespace confirmation only prompts on interactive runs.** Non-interactive - runs print the warning and proceed instead of blocking on input that will never arrive. +- **The** ``CodeIgniter`` **namespace confirmation is asked once, and only on interactive runs.** The + trait asked for every file. The base class asks once per ``generateClass()`` and reuses the answer for + the views generated after it. Non-interactive runs print the warning and proceed instead of blocking + on input that will never arrive. - **The** ``CodeIgniter`` **namespace confirmation keys off the target path.** The trait compared the raw ``--namespace`` option, so an attribute-pinned ``CodeIgniter`` namespace or a spelling like ``--namespace CodeIgniter/`` did not warn. The base class warns exactly when the file would be written @@ -294,6 +296,13 @@ AbstractGeneratorCommand Like :php:meth:`generateClass`, but for non-class artifacts. The name is used as-is, without qualification or suffix handling. + .. php:method:: isCodeIgniterNamespaceDeclined(): bool + + Whether the ``CodeIgniter`` namespace confirmation was declined since the + last :php:meth:`generateClass` call. A declined run still returns + ``EXIT_SUCCESS``, so check this before generating anything that depends + on the declined file. + .. php:method:: getReplacements(string $class): array :param string $class: The namespaced class (or view) being generated.