diff --git a/system/Commands/Worker/WorkerInstall.php b/system/Commands/Worker/WorkerInstall.php index 99afdc9f69eb..f64fe2d8ab50 100644 --- a/system/Commands/Worker/WorkerInstall.php +++ b/system/Commands/Worker/WorkerInstall.php @@ -13,27 +13,23 @@ namespace CodeIgniter\Commands\Worker; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; /** - * Install Worker Mode for FrankenPHP. - * - * This command sets up the necessary files to run CodeIgniter 4 - * in FrankenPHP worker mode for improved performance. + * Installs the files needed to run CodeIgniter 4 in FrankenPHP worker mode. */ -class WorkerInstall extends BaseCommand +#[Command( + name: 'worker:install', + description: 'Install FrankenPHP worker mode by creating necessary configuration files', + group: 'Worker Mode', +)] +class WorkerInstall extends AbstractCommand { - protected $group = 'Worker Mode'; - protected $name = 'worker:install'; - protected $description = 'Install FrankenPHP worker mode by creating necessary configuration files'; - protected $usage = 'worker:install [options]'; - protected $options = [ - '--force' => 'Overwrite existing files', - ]; - /** - * Template file mappings (template => destination path) + * Template file mappings (template => destination path). * * @var array */ @@ -42,9 +38,18 @@ class WorkerInstall extends BaseCommand 'Caddyfile.tpl' => 'Caddyfile', ]; - public function run(array $params) + protected function configure(): void + { + $this->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Overwrite existing files.', + )); + } + + protected function execute(array $arguments, array $options): int { - $force = array_key_exists('force', $params) || CLI::getOption('force'); + $force = $options['force'] === true; CLI::write('Setting up FrankenPHP Worker Mode', 'yellow'); CLI::newLine(); @@ -53,63 +58,46 @@ public function run(array $params) $created = []; - // Process each template foreach ($this->templates as $template => $destination) { $source = SYSTEMPATH . 'Commands/Worker/Views/' . $template; $target = ROOTPATH . $destination; $isFile = is_file($target); - // Skip if file exists and not forcing overwrite if (! $force && $isFile) { continue; } - // Read template content $content = file_get_contents($source); + if ($content === false) { - CLI::error( - "Failed to read template: {$template}", - 'light_gray', - 'red', - ); - CLI::newLine(); + CLI::error(sprintf('Failed to read template: %s', $template), 'light_gray', 'red'); return EXIT_ERROR; } - // Write file to destination if (! write_file($target, $content)) { - CLI::error( - 'Failed to create file: ' . clean_path($target), - 'light_gray', - 'red', - ); - CLI::newLine(); + CLI::error(sprintf('Failed to create file: %s', clean_path($target)), 'light_gray', 'red'); return EXIT_ERROR; } if ($force && $isFile) { - CLI::write(' File overwritten: ' . clean_path($target), 'yellow'); + CLI::write(sprintf(' File overwritten: %s', clean_path($target)), 'yellow'); } else { - CLI::write(' File created: ' . clean_path($target), 'green'); + CLI::write(sprintf(' File created: %s', clean_path($target)), 'green'); } $created[] = $destination; } - // No files were created if ($created === []) { - CLI::newLine(); CLI::write('Worker mode files already exist.', 'yellow'); CLI::write('Use --force to overwrite existing files.', 'yellow'); - CLI::newLine(); return EXIT_ERROR; } - // Success message CLI::newLine(); CLI::write('Worker mode files created successfully!', 'green'); CLI::newLine(); @@ -119,10 +107,7 @@ public function run(array $params) return EXIT_SUCCESS; } - /** - * Display next steps to the user - */ - protected function showNextSteps(): void + private function showNextSteps(): void { CLI::write('Next Steps:', 'yellow'); CLI::newLine(); @@ -133,6 +118,5 @@ protected function showNextSteps(): void CLI::write('2. Test your application:', 'white'); CLI::write(' curl http://localhost:8080/', 'green'); - CLI::newLine(); } } diff --git a/system/Commands/Worker/WorkerUninstall.php b/system/Commands/Worker/WorkerUninstall.php index 0d083f2e5b49..3e7d8c1111c2 100644 --- a/system/Commands/Worker/WorkerUninstall.php +++ b/system/Commands/Worker/WorkerUninstall.php @@ -13,26 +13,23 @@ namespace CodeIgniter\Commands\Worker; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; /** - * Uninstall Worker Mode for FrankenPHP. - * - * This command removes the files created by the worker:install command. + * Removes the files created by the worker:install command. */ -class WorkerUninstall extends BaseCommand +#[Command( + name: 'worker:uninstall', + description: 'Remove FrankenPHP worker mode configuration files', + group: 'Worker Mode', +)] +class WorkerUninstall extends AbstractCommand { - protected $group = 'Worker Mode'; - protected $name = 'worker:uninstall'; - protected $description = 'Remove FrankenPHP worker mode configuration files'; - protected $usage = 'worker:uninstall [options]'; - protected $options = [ - '--force' => 'Skip confirmation prompt', - ]; - /** - * Files to remove (must match Install command) + * Files to remove (must match the worker:install command). * * @var list */ @@ -41,80 +38,103 @@ class WorkerUninstall extends BaseCommand 'Caddyfile', ]; - public function run(array $params) + protected function configure(): void { - $force = array_key_exists('force', $params) || CLI::getOption('force'); + $this->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Skip the confirmation prompt.', + )); + } - CLI::write('Uninstalling FrankenPHP Worker Mode', 'yellow'); - CLI::newLine(); + protected function interact(array &$arguments, array &$options): void + { + if ($this->hasUnboundOption('force', $options)) { + return; + } - // Find existing files - $existing = []; + if ($this->existingFiles() === []) { + return; + } - foreach ($this->files as $file) { - $path = ROOTPATH . $file; - if (is_file($path)) { - $existing[] = $file; - } + if (CLI::prompt('Remove the FrankenPHP worker mode files?', ['y', 'n']) === 'y') { + $options['force'] = null; // simulate the presence of the --force option } + } + + protected function execute(array $arguments, array $options): int + { + $existing = $this->existingFiles(); - // No files to remove if ($existing === []) { CLI::write('No worker mode files found to remove.', 'yellow'); - CLI::newLine(); return EXIT_SUCCESS; } - // Show files that will be removed - CLI::write('The following files will be removed:', 'yellow'); + if ($options['force'] === false) { + if ($this->isInteractive()) { + CLI::write('Uninstall cancelled.', 'yellow'); - foreach ($existing as $file) { - CLI::write(' - ' . $file, 'white'); - } - CLI::newLine(); + return EXIT_SUCCESS; + } - // Confirm deletion unless --force is used - if (! $force) { - $confirm = CLI::prompt('Are you sure you want to remove these files?', ['y', 'n']); - CLI::newLine(); + CLI::error('Uninstall aborted: pass --force to remove worker mode files in non-interactive mode.', 'light_gray', 'red'); - if ($confirm !== 'y') { - CLI::write('Uninstall cancelled.', 'yellow'); - CLI::newLine(); + return EXIT_ERROR; + } - return EXIT_ERROR; - } + CLI::newLine(); + CLI::write('The following files will be removed:', 'yellow'); + + foreach ($existing as $file) { + CLI::write(sprintf(' - %s', $file), 'white'); } + CLI::newLine(); + $removed = []; - // Remove each file foreach ($existing as $file) { $path = ROOTPATH . $file; if (! @unlink($path)) { - CLI::error('Failed to remove file: ' . clean_path($path), 'light_gray', 'red'); + CLI::error(sprintf('Failed to remove file: %s', clean_path($path)), 'light_gray', 'red'); continue; } - CLI::write(' File removed: ' . clean_path($path), 'green'); + CLI::write(sprintf(' File removed: %s', clean_path($path)), 'green'); + $removed[] = $file; } - // Summary CLI::newLine(); + if ($removed === []) { - CLI::error('No files were removed.'); - CLI::newLine(); + CLI::error('No files were removed.', 'light_gray', 'red'); return EXIT_ERROR; } CLI::write('Worker mode files removed successfully!', 'green'); - CLI::newLine(); return EXIT_SUCCESS; } + + /** + * @return list + */ + private function existingFiles(): array + { + $existing = []; + + foreach ($this->files as $file) { + if (is_file(ROOTPATH . $file)) { + $existing[] = $file; + } + } + + return $existing; + } } diff --git a/tests/system/Commands/Worker/WorkerCommandsTest.php b/tests/system/Commands/Worker/WorkerCommandsTest.php index ff570356fdcf..7e5a494039b4 100644 --- a/tests/system/Commands/Worker/WorkerCommandsTest.php +++ b/tests/system/Commands/Worker/WorkerCommandsTest.php @@ -13,8 +13,11 @@ namespace CodeIgniter\Commands\Worker; +use CodeIgniter\CLI\CLI; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -38,6 +41,13 @@ protected function tearDown(): void parent::tearDown(); $this->cleanupFiles(); + + CLI::reset(); + } + + private function getUndecoratedOutput(string $output): string + { + return preg_replace('/\e\[[^m]+m/', '', $output) ?? ''; } private function cleanupFiles(): void @@ -141,6 +151,68 @@ public function testWorkerUninstallListsFilesToRemove(): void $this->assertStringContainsString('Caddyfile', $output); } + public function testWorkerUninstallCancelsWithoutForce(): void + { + command('worker:install'); + + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + command('worker:uninstall'); + + $this->assertFileExists(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileExists(ROOTPATH . 'Caddyfile'); + + $output = $this->getUndecoratedOutput($io->getOutput()); + $this->assertStringContainsString('Remove the FrankenPHP worker mode files? [y, n]: n', $output); + $this->assertStringContainsString('Uninstall cancelled.', $output); + } + + public function testWorkerUninstallWithoutForceButConfirmed(): void + { + command('worker:install'); + + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + command('worker:uninstall'); + + $this->assertFileDoesNotExist(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileDoesNotExist(ROOTPATH . 'Caddyfile'); + + $output = $this->getUndecoratedOutput($io->getOutput()); + $this->assertStringContainsString('Remove the FrankenPHP worker mode files? [y, n]: y', $output); + $this->assertStringContainsString('Worker mode files removed successfully!', $output); + } + + #[DataProvider('provideWorkerUninstallAbortsNonInteractively')] + public function testWorkerUninstallAbortsNonInteractively(string $flag): void + { + command('worker:install'); + $this->resetStreamFilterBuffer(); + + command("worker:uninstall {$flag}"); + + $this->assertFileExists(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileExists(ROOTPATH . 'Caddyfile'); + $this->assertStringContainsString( + 'Uninstall aborted: pass --force to remove worker mode files in non-interactive mode.', + $this->getStreamFilterBuffer(), + ); + } + + /** + * @return iterable + */ + public static function provideWorkerUninstallAbortsNonInteractively(): iterable + { + yield 'long form' => ['--no-interaction']; + + yield 'short form' => ['-N']; + } + public function testWorkerInstallAndUninstallCycle(): void { command('worker:install'); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index f1c369184f3b..c04db4e88287 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -34,6 +34,7 @@ Behavior Changes - **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. - **Commands:** The ``logs:clear`` command now returns ``EXIT_SUCCESS`` (previously ``EXIT_ERROR``) when the user declines the interactive confirmation prompt, since user-initiated cancellation is not a failure. Output messages have also been reworded to distinguish cancellation (interactive ``n``) from abort (non-interactive without ``--force``), and the resolved log directory path is now included in the prompt, success, and failure messages. +- **Commands:** The ``worker:uninstall`` command now returns ``EXIT_SUCCESS`` (previously ``EXIT_ERROR``) when the user declines the interactive confirmation prompt, since user-initiated cancellation is not a failure. In non-interactive mode without ``--force`` it now aborts with ``EXIT_ERROR`` instead of prompting, and the redundant ``Uninstalling FrankenPHP Worker Mode`` header was dropped. Scripts that branch on the previous exit code or wording will need updating. - **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating. - **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method (e.g., ``GET``, ``POST``). Lowercase method names (e.g., ``post``) will no longer match. @@ -202,6 +203,7 @@ Commands - Added ``key:rotate`` command to demote the current ``encryption.key`` to ``encryption.previousKeys`` in **.env** and generate a new key. See :ref:`spark-key-rotate`. - Added ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior IO afterwards. See :ref:`modern-commands-call-silently`. - The ``migrate``, ``migrate:rollback``, ``migrate:refresh``, and ``migrate:status`` commands now accept long option names (``--namespace``, ``--group``, ``--batch``, ``--force``) alongside their existing short forms (``-n``, ``-g``, ``-b``, ``-f``). +- The ``worker:install`` and ``worker:uninstall`` commands now accept the ``-f`` short option alongside ``--force``. - Added :php:class:`NullInputOutput `, an :php:class:`InputOutput ` sink that discards all writes and returns an empty string from ``input()``. - The ``spark routes`` command now resolves the Before/After Filters columns for routes that use custom placeholders registered via ``$routes->addPlaceholder()``. Sample URIs for custom placeholders are derived from the placeholder regex when possible, and can be overridden through the new ``$placeholderSamples`` property in ``Config\Routing``. See :ref:`placeholder-samples-for-spark-routes`.