From 2b11961bfcd90ba0528af1570ef3ce4f35c1f147 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 1 Apr 2026 02:11:39 +0800 Subject: [PATCH 1/7] refactor `Console::run()` --- system/CLI/Console.php | 78 +++++++++++++++++---------- tests/system/CLI/ConsoleTest.php | 92 ++++++++++++-------------------- 2 files changed, 84 insertions(+), 86 deletions(-) diff --git a/system/CLI/Console.php b/system/CLI/Console.php index 89415e265134..bd2950b7ae53 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -16,35 +16,61 @@ use CodeIgniter\CodeIgniter; use Config\App; use Config\Services; -use Exception; /** - * Console - * * @see \CodeIgniter\CLI\ConsoleTest */ class Console { + private const DEFAULT_COMMAND = 'list'; + + /** + * @var array + */ + private array $options = []; + /** * Runs the current command discovered on the CLI. * - * @return int|void Exit code + * @param list $tokens * - * @throws Exception + * @return int|null Exit code or null for legacy commands that don't return an exit code. */ - public function run() + public function run(array $tokens = []) { - // Create CLIRequest - $appConfig = config(App::class); - Services::createRequest($appConfig, true); - // Load Routes - service('routes')->loadRoutes(); + if ($tokens === []) { + $tokens = service('superglobals')->server('argv', []); + } + + $parser = new CommandLineParser($tokens); + + $arguments = $parser->getArguments(); + $this->options = $parser->getOptions(); + + $this->showHeader($this->hasParameterOption(['no-header'])); + unset($this->options['no-header']); - $params = array_merge(CLI::getSegments(), CLI::getOptions()); - $params = $this->parseParamsForHelpOption($params); - $command = array_shift($params) ?? 'list'; + if ($this->hasParameterOption(['help'])) { + unset($this->options['help']); - return service('commands')->run($command, $params); + if ($arguments === []) { + $arguments = ['help', self::DEFAULT_COMMAND]; + } elseif ($arguments[0] !== 'help') { + array_unshift($arguments, 'help'); + } + } + + $command = array_shift($arguments) ?? self::DEFAULT_COMMAND; + + return service('commands')->run($command, array_merge($arguments, $this->options)); + } + + public function initialize(): static + { + Services::createRequest(config(App::class), true); + service('routes')->loadRoutes(); + + return $this; } /** @@ -67,24 +93,18 @@ public function showHeader(bool $suppress = false) } /** - * Introspects the `$params` passed for presence of the - * `--help` option. + * Checks whether any of the options are present in the command line. * - * If present, it will be found as `['help' => null]`. - * We'll remove that as an option from `$params` and - * unshift it as argument instead. - * - * @param array $params + * @param list $options */ - private function parseParamsForHelpOption(array $params): array + private function hasParameterOption(array $options): bool { - if (array_key_exists('help', $params)) { - unset($params['help']); - - $params = $params === [] ? ['list'] : $params; - array_unshift($params, 'help'); + foreach ($options as $option) { + if (array_key_exists($option, $this->options)) { + return true; + } } - return $params; + return false; } } diff --git a/tests/system/CLI/ConsoleTest.php b/tests/system/CLI/ConsoleTest.php index ff4ac02ab29b..d4dc6247a0b7 100644 --- a/tests/system/CLI/ConsoleTest.php +++ b/tests/system/CLI/ConsoleTest.php @@ -39,13 +39,7 @@ protected function setUp(): void Services::injectMock('superglobals', new Superglobals()); CLI::init(); - $env = new DotEnv(ROOTPATH); - $env->load(); - - // Set environment values that would otherwise stop the framework from functioning during tests. - if (service('superglobals')->server('app.baseURL') === null) { - service('superglobals')->setServer('app.baseURL', 'http://example.com/'); - } + (new DotEnv(ROOTPATH))->load(); $this->app = new MockCodeIgniter(new MockCLIConfig()); $this->app->initialize(); @@ -53,39 +47,38 @@ protected function setUp(): void protected function tearDown(): void { - CLI::reset(); - parent::tearDown(); + + CLI::reset(); } - public function testHeader(): void + public function testHeaderShowsNormally(): void { - $console = new Console(); - $console->showHeader(); - $this->assertGreaterThan( - 0, - strpos( - $this->getStreamFilterBuffer(), - sprintf('CodeIgniter v%s Command Line Tool', CodeIgniter::CI_VERSION), - ), + $this->initializeConsole(); + (new Console())->run(); + + $this->assertStringContainsString( + sprintf('CodeIgniter v%s Command Line Tool', CodeIgniter::CI_VERSION), + $this->getStreamFilterBuffer(), ); } - public function testNoHeader(): void + public function testHeaderDoesNotShowOnNoHeader(): void { - $console = new Console(); - $console->showHeader(true); - $this->assertSame('', $this->getStreamFilterBuffer()); + $this->initializeConsole('--no-header'); + (new Console())->run(); + + $this->assertStringNotContainsString( + sprintf('CodeIgniter v%s Command Line Tool', CodeIgniter::CI_VERSION), + $this->getStreamFilterBuffer(), + ); } public function testRun(): void { - $this->initCLI(); - - $console = new Console(); - $console->run(); + $this->initializeConsole(); + (new Console())->run(); - // make sure the result looks like a command list $this->assertStringContainsString('Lists the available commands.', $this->getStreamFilterBuffer()); $this->assertStringContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); } @@ -97,10 +90,8 @@ public function testRunEventsPreCommand(): void $result = 'fired'; }); - $this->initCLI(); - - $console = new Console(); - $console->run(); + $this->initializeConsole(); + (new Console())->run(); $this->assertEventTriggered('pre_command'); $this->assertSame('fired', $result); @@ -113,10 +104,8 @@ public function testRunEventsPostCommand(): void $result = 'fired'; }); - $this->initCLI(); - - $console = new Console(); - $console->run(); + $this->initializeConsole(); + (new Console())->run(); $this->assertEventTriggered('post_command'); $this->assertSame('fired', $result); @@ -124,23 +113,17 @@ public function testRunEventsPostCommand(): void public function testBadCommand(): void { - $this->initCLI('bogus'); - - $console = new Console(); - $console->run(); + $this->initializeConsole('bogus'); + (new Console())->run(); - // make sure the result looks like a command list $this->assertStringContainsString('Command "bogus" not found', $this->getStreamFilterBuffer()); } public function testHelpCommandDetails(): void { - $this->initCLI('help', 'make:migration'); - - $console = new Console(); - $console->run(); + $this->initializeConsole('help', 'make:migration'); + (new Console())->run(); - // make sure the result looks like more detailed help $this->assertStringContainsString('Description:', $this->getStreamFilterBuffer()); $this->assertStringContainsString('Usage:', $this->getStreamFilterBuffer()); $this->assertStringContainsString('Options:', $this->getStreamFilterBuffer()); @@ -148,8 +131,7 @@ public function testHelpCommandDetails(): void public function testHelpCommandUsingHelpOption(): void { - $this->initCLI('env', '--help'); - + $this->initializeConsole('env', '--help'); (new Console())->run(); $this->assertStringContainsString('env []', $this->getStreamFilterBuffer()); @@ -161,8 +143,7 @@ public function testHelpCommandUsingHelpOption(): void public function testHelpOptionIsOnlyPassed(): void { - $this->initCLI('--help'); - + $this->initializeConsole('--help'); (new Console())->run(); // Since calling `php spark` is the same as calling `php spark list`, @@ -172,21 +153,18 @@ public function testHelpOptionIsOnlyPassed(): void public function testHelpArgumentAndHelpOptionCombined(): void { - $this->initCLI('help', '--help'); - + $this->initializeConsole('help', '--help'); (new Console())->run(); // Same as calling `php spark help` only $this->assertStringContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); } - /** - * @param string ...$command - */ - protected function initCLI(...$command): void + private function initializeConsole(string ...$tokens): void { - service('superglobals')->setServer('argv', ['spark', ...$command]); - service('superglobals')->setServer('argc', count(service('superglobals')->server('argv'))); + service('superglobals') + ->setServer('argv', ['spark', ...$tokens]) + ->setServer('argc', count($tokens) + 1); CLI::init(); } From 0dc02028aa4a51798cb315e60a6a2534eb4032df Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 1 Apr 2026 02:12:07 +0800 Subject: [PATCH 2/7] deprecate `Boot::initializeConsole()` --- system/Boot.php | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/system/Boot.php b/system/Boot.php index d3b25895093b..d865eff315b1 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -161,9 +161,8 @@ public static function bootSpark(Paths $paths): int static::autoloadHelpers(); static::initializeCodeIgniter(); - $console = static::initializeConsole(); - return static::runCommand($console); + return static::runCommand(new Console()); } /** @@ -422,8 +421,13 @@ protected static function saveConfigCache(FactoriesCache $factoriesCache): void $factoriesCache->save('config'); } + /** + * @deprecated 4.8.0 No longer used. + */ protected static function initializeConsole(): Console { + @trigger_error(sprintf('The static %s() method is deprecated and no longer used.', __METHOD__), E_USER_DEPRECATED); + $console = new Console(); // Show basic information before we do anything else. @@ -439,8 +443,13 @@ protected static function initializeConsole(): Console protected static function runCommand(Console $console): int { - $exit = $console->run(); + $exitCode = $console->initialize()->run(); + + if (! is_int($exitCode)) { + @trigger_error(sprintf('Starting with CodeIgniter v4.8.0, commands must return an integer exit code. Last command exited with %s. Defaulting to EXIT_SUCCESS.', get_debug_type($exitCode)), E_USER_DEPRECATED); + $exitCode = EXIT_SUCCESS; + } - return is_int($exit) ? $exit : EXIT_SUCCESS; + return $exitCode; } } From 983cf3ae0b9b9667b3fb5d9179d1431bb48d0267 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 1 Apr 2026 02:12:43 +0800 Subject: [PATCH 3/7] call `Console::run()` within `command()` --- system/Common.php | 47 +++++++++------------------ tests/system/Commands/CommandTest.php | 4 +-- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/system/Common.php b/system/Common.php index 9da60952934b..2c34148646a9 100644 --- a/system/Common.php +++ b/system/Common.php @@ -12,6 +12,7 @@ */ use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\CLI\Console; use CodeIgniter\Config\BaseConfig; use CodeIgniter\Config\Factories; use CodeIgniter\Context\Context; @@ -127,7 +128,7 @@ function command(string $command) $regexString = '([^\s]+?)(?:\s|(? */ - $params = []; - $command = array_shift($args); - $optionValue = false; - - foreach ($args as $i => $arg) { - if (mb_strpos($arg, '-') !== 0) { - if ($optionValue) { - // if this was an option value, it was already - // included in the previous iteration - $optionValue = false; - } else { - // add to segments if not starting with '-' - // and not an option value - $params[] = $arg; - } - - continue; - } - - $arg = ltrim($arg, '-'); - $value = null; - - if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) { - $value = $args[$i + 1]; - $optionValue = true; + // Don't show the header as it is not needed when running commands from code. + if (! in_array('--no-header', $tokens, true)) { + if (! in_array('--', $tokens, true)) { + $tokens[] = '--no-header'; + } else { + $index = (int) array_search('--', $tokens, true); + array_splice($tokens, $index, 0, '--no-header'); } - - $params[$arg] = $value; } + // Prepend an application name, as Console expects one. + array_unshift($tokens, 'spark'); + ob_start(); - service('commands')->run($command, $params); + (new Console())->run($tokens); return ob_get_clean(); } diff --git a/tests/system/Commands/CommandTest.php b/tests/system/Commands/CommandTest.php index 1c6e81033e63..27a2ed40aa2a 100644 --- a/tests/system/Commands/CommandTest.php +++ b/tests/system/Commands/CommandTest.php @@ -165,11 +165,11 @@ public static function provideCommandParsesArgsCorrectly(): iterable ], [ 'reveal seg1 seg2 -opt1 val1 seg3', - ['seg1', 'seg2', 'opt1' => 'val1', 'seg3'], + ['seg1', 'seg2', 'seg3', 'opt1' => 'val1'], ], [ 'reveal as df -gh -jk -qw 12 zx cv', - ['as', 'df', 'gh' => null, 'jk' => null, 'qw' => '12', 'zx', 'cv'], + ['as', 'df', 'zx', 'cv', 'gh' => null, 'jk' => null, 'qw' => '12'], ], [ 'reveal as -df "some stuff" -jk 12 -sd "Some longer stuff" -fg \'using single quotes\'', From 1421c19eafbd8ac3c7c91891b3f9a3530f6a5f2e Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 1 Apr 2026 02:13:07 +0800 Subject: [PATCH 4/7] add changelog --- user_guide_src/source/changelogs/v4.8.0.rst | 4 ++++ user_guide_src/source/installation/upgrade_480.rst | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index a95ac7f89f30..f13ca838c182 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -43,6 +43,7 @@ Interface Changes Method Signature Changes ======================== +- **CLI:** The ``Console::run()`` method now accepts an optional ``array $tokens`` parameter. This allows you to pass an array of command tokens directly to the console runner, which is useful for testing or programmatically running commands. If not provided, it will default to using the global ``$argv``. - **CodeIgniter:** The deprecated parameters in methods have been removed: - ``CodeIgniter\CodeIgniter::handleRequest()`` no longer accepts the deprecated ``$cacheConfig`` and ``$returnResponse`` parameters. - ``$cacheConfig`` is no longer used and is now hard deprecated. A deprecation notice will be triggered if this is passed to the method. @@ -175,6 +176,7 @@ Commands This provides more flexibility in how you can pass options to commands. - ``CLI`` now supports parsing array options written multiple times (e.g., ``--option=value1 --option=value2``) into an array of values. This allows you to easily pass multiple values for the same option without needing to use a comma-separated string. When used with ``CLI::getOption()``, an array option will return its last value (for example, in this case, ``value2``). To retrieve all values for an array option, use ``CLI::getRawOption()``. +- Likewise, the ``command()`` function now also supports the above enhancements for command-line option parsing when using the function to run commands from code. Testing ======= @@ -266,7 +268,9 @@ Changes Deprecations ************ +- The ``Boot::initializeConsole()`` method is now deprecated and will be removed in a future release. - **CLI:** The ``CLI::parseCommandLine()`` method is now deprecated and will be removed in a future release. The ``CLI`` class now uses the new ``CommandLineParser`` class to handle command-line argument parsing. +- **CLI:** Returning a non-integer exit code from a command is now deprecated and will trigger a deprecation notice. Command methods should return an integer exit code (e.g., ``0`` for success, non-zero for errors) to ensure proper behavior across all platforms. - **HTTP:** The ``CLIRequest::parseCommand()`` method is now deprecated and will be removed in a future release. The ``CLIRequest`` class now uses the new ``CommandLineParser`` class to handle command-line argument parsing. - **HTTP:** ``URI::setSilent()`` is now hard deprecated. This method was only previously marked as deprecated. It will now trigger a deprecation notice when used. diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst index 6d16bffcd4c3..d2738db27158 100644 --- a/user_guide_src/source/installation/upgrade_480.rst +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -20,6 +20,13 @@ Mandatory File Changes Breaking Changes **************** +Console Exit Codes +================== + +Previously, returning a non-integer value from a command run through ``spark`` would be treated as a successful execution (exit code ``0``). +Starting with v4.8.0, this behavior is still supported but will trigger a deprecation notice. Commands should now return an integer exit code +to ensure proper behavior across all platforms. + ********************* Breaking Enhancements ********************* From 7084d55169c5af2b859fd4bea21f1bfe5bde9147 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 4 Apr 2026 19:21:13 +0800 Subject: [PATCH 5/7] fix phpstan --- utils/phpstan-baseline/loader.neon | 2 +- utils/phpstan-baseline/missingType.iterableValue.neon | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 633e4837d6cd..222aa6ade6ec 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2028 errors +# total 2027 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 2576e7366393..a6f30d2a39b9 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1231 errors +# total 1230 errors parameters: ignoreErrors: @@ -57,11 +57,6 @@ parameters: count: 1 path: ../../system/CLI/CLI.php - - - message: '#^Method CodeIgniter\\CLI\\Console\:\:parseParamsForHelpOption\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/Console.php - - message: '#^Method CodeIgniter\\CodeIgniter\:\:getPerformanceStats\(\) return type has no value type specified in iterable type array\.$#' count: 1 From 012d8efca508f58d8ec93d098e95ed759b23f7b0 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 4 Apr 2026 20:25:59 +0800 Subject: [PATCH 6/7] add more tests for `command()` --- tests/system/Commands/HelpCommandTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index 519f5ab33f65..8f22d645b53a 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Commands; +use CodeIgniter\CodeIgniter; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; @@ -63,4 +64,26 @@ public function testHelpCommandOnInexistentCommandButWithAlternatives(): void $this->assertStringContainsString('Command "clear" not found.', $this->getBuffer()); $this->assertStringContainsString('Did you mean one of these?', $this->getBuffer()); } + + public function testNormalHelpCommandHasNoBanner(): void + { + command('help'); + + $this->assertStringNotContainsString( + sprintf('CodeIgniter %s Command Line Tool', CodeIgniter::CI_VERSION), + $this->getBuffer(), + ); + $this->assertStringContainsString('Displays basic usage information.', $this->getBuffer()); + } + + public function testHelpCommandWithDoubleHyphenStillRemovesBanner(): void + { + command('help -- list'); + + $this->assertStringNotContainsString( + sprintf('CodeIgniter %s Command Line Tool', CodeIgniter::CI_VERSION), + $this->getBuffer(), + ); + $this->assertStringContainsString('Lists the available commands.', $this->getBuffer()); + } } From 4bb97c0db9aa746084cdef13fe02d7fe75ce43ae Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 5 Apr 2026 20:33:30 +0800 Subject: [PATCH 7/7] revert deprecation on `Boot::initializeConsole()` --- system/Boot.php | 19 ++----------------- user_guide_src/source/changelogs/v4.8.0.rst | 3 ++- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/system/Boot.php b/system/Boot.php index d865eff315b1..adff06c46194 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -162,7 +162,7 @@ public static function bootSpark(Paths $paths): int static::initializeCodeIgniter(); - return static::runCommand(new Console()); + return static::runCommand(static::initializeConsole()); } /** @@ -421,24 +421,9 @@ protected static function saveConfigCache(FactoriesCache $factoriesCache): void $factoriesCache->save('config'); } - /** - * @deprecated 4.8.0 No longer used. - */ protected static function initializeConsole(): Console { - @trigger_error(sprintf('The static %s() method is deprecated and no longer used.', __METHOD__), E_USER_DEPRECATED); - - $console = new Console(); - - // Show basic information before we do anything else. - if (is_int($suppress = array_search('--no-header', $_SERVER['argv'], true))) { - unset($_SERVER['argv'][$suppress]); - $suppress = true; - } - - $console->showHeader($suppress); - - return $console; + return new Console(); } protected static function runCommand(Console $console): int diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index f13ca838c182..045c38f11ce5 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -23,6 +23,8 @@ BREAKING Behavior Changes ================ +- The static ``Boot::initializeConsole()`` method no longer handles the display of the console header. This is now handled within ``Console::run()``. + If you have overridden ``Boot::initializeConsole()``, you should remove any code related to displaying the console header, as this is now the responsibility of the ``Console`` class. - **Commands:** The ``filter:check`` command now requires the HTTP method argument to be uppercase (e.g., ``spark filter:check GET /`` instead of ``spark filter:check get /``). - **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 @@ -268,7 +270,6 @@ Changes Deprecations ************ -- The ``Boot::initializeConsole()`` method is now deprecated and will be removed in a future release. - **CLI:** The ``CLI::parseCommandLine()`` method is now deprecated and will be removed in a future release. The ``CLI`` class now uses the new ``CommandLineParser`` class to handle command-line argument parsing. - **CLI:** Returning a non-integer exit code from a command is now deprecated and will trigger a deprecation notice. Command methods should return an integer exit code (e.g., ``0`` for success, non-zero for errors) to ensure proper behavior across all platforms. - **HTTP:** The ``CLIRequest::parseCommand()`` method is now deprecated and will be removed in a future release. The ``CLIRequest`` class now uses the new ``CommandLineParser`` class to handle command-line argument parsing.