From eb74cb97c08dc906fc6b58b18ccdf38727dc37ca Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 5 Jun 2026 16:12:39 +0200 Subject: [PATCH 1/3] CLI: exempt commands with positional arguments from the sibling-subcommand check The sibling-subcommand guard added for 5.4 rejects an unknown token after a command that has registered subcommands, treating it as a mistyped subcommand. That assumption is wrong for commands which declare their own positional arguments: there the trailing token is a legitimate argument, not a subcommand. This broke every Bake command of that shape, e.g. `bake template Articles` is rejected because `bake template all` is also registered, even though `TemplateCommand` accepts a `name` argument. Only the command's own option parser can tell a positional argument apart from a subcommand name. Resolve the command first, then skip the guard when its parser declares positional arguments. This restores the behavior the original change already promised ("commands with declared positional arguments are unaffected") while keeping the typo protection for parent commands that take no arguments. --- src/Console/CommandRunner.php | 53 +++++++++++++++----- tests/TestCase/Console/CommandRunnerTest.php | 46 +++++++++++++++++ 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/src/Console/CommandRunner.php b/src/Console/CommandRunner.php index d3639e966f5..2f8dc8e2796 100644 --- a/src/Console/CommandRunner.php +++ b/src/Console/CommandRunner.php @@ -31,6 +31,7 @@ use Cake\Routing\Router; use Cake\Routing\RoutingApplicationInterface; use Cake\Utility\Inflector; +use Throwable; /** * Run CLI commands for the provided application. @@ -169,15 +170,28 @@ public function run(array $argv, ?ConsoleIo $io = null): int $name = 'help'; } + try { + $name = $this->resolveName($commands, $io, $name); + } catch (MissingOptionException $e) { + $io->error($e->getFullMessage()); + + return CommandInterface::CODE_ERROR; + } + + $command = $this->getCommand($io, $commands, $name); + // If the matched command also has sibling subcommands (e.g. `i18n` exists alongside // `i18n init` / `i18n extract`), an unknown next token is almost always a typo for a // subcommand. Reject it instead of letting it fall through as a positional argument. + // + // Commands that declare their own positional arguments are exempt: there the next token + // is a legitimate argument (e.g. `bake template Articles` alongside `bake template all`), + // not a mistyped subcommand, and only the command's own parser can tell the two apart. if ( - $name !== null - && $commands->has($name) - && isset($argv[0]) + isset($argv[0]) && !str_starts_with($argv[0], '-') && $this->hasCommandsWithPrefix($commands, $name) + && !$this->commandHasArguments($command) ) { $candidate = $name . ' ' . $argv[0]; if (!$commands->has($candidate)) { @@ -187,15 +201,6 @@ public function run(array $argv, ?ConsoleIo $io = null): int } } - try { - $name = $this->resolveName($commands, $io, $name); - } catch (MissingOptionException $e) { - $io->error($e->getFullMessage()); - - return CommandInterface::CODE_ERROR; - } - - $command = $this->getCommand($io, $commands, $name); $result = $this->runCommand($command, $argv, $io); if ($result === null) { @@ -366,6 +371,30 @@ protected function hasCommandsWithPrefix(CommandCollection $commands, string $pr return false; } + /** + * Check whether a command declares its own positional arguments. + * + * A command that accepts positional arguments treats a trailing token as a real argument, + * so it must not be rejected as a mistyped subcommand by the sibling-subcommand check. + * + * @param \Cake\Console\CommandInterface $command The resolved command instance to inspect. + * @return bool + */ + protected function commandHasArguments(CommandInterface $command): bool + { + if (!$command instanceof BaseCommand) { + return false; + } + + try { + $parser = $command->getOptionParser(); + } catch (Throwable) { + return false; + } + + return $parser->arguments() !== []; + } + /** * Build the error message shown when a token following a command name doesn't * match any known subcommand of that command. diff --git a/tests/TestCase/Console/CommandRunnerTest.php b/tests/TestCase/Console/CommandRunnerTest.php index d06e837083f..d904471ef5d 100644 --- a/tests/TestCase/Console/CommandRunnerTest.php +++ b/tests/TestCase/Console/CommandRunnerTest.php @@ -27,6 +27,7 @@ use Cake\Console\CommandInterface; use Cake\Console\CommandRunner; use Cake\Console\ConsoleIo; +use Cake\Console\ConsoleOptionParser; use Cake\Console\TestSuite\StubConsoleOutput; use Cake\Core\BasePlugin; use Cake\Core\Configure; @@ -210,6 +211,51 @@ public function testRunOptionAfterParentCommandIsNotASubcommand(): void $this->assertStringNotContainsString('Unknown command', $messages); } + /** + * Test that a command declaring its own positional arguments is not subject to the + * unknown-subcommand check, even when a sibling subcommand sharing its prefix exists. + * + * For example, given `widget` (which takes a `name` argument), `widget all` is also + * registered: `bin/cake widget Articles` must run the `widget` command with `Articles` + * as its argument, not be rejected as a mistyped `widget` subcommand. + */ + public function testRunPositionalArgumentNotTreatedAsUnknownSubcommand(): void + { + $parent = new class extends Command { + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + return $parser->addArgument('name', ['help' => 'A name.', 'required' => false]); + } + + public function execute(Arguments $args, ConsoleIo $io): int + { + $io->out('ran widget with ' . (string)$args->getArgument('name')); + + return static::CODE_SUCCESS; + } + }; + $sibling = new class extends Command { + public function execute(Arguments $args, ConsoleIo $io): int + { + return static::CODE_SUCCESS; + } + }; + + $output = new StubConsoleOutput(); + $app = $this->makeAppWithCommands([ + 'help' => HelpCommand::class, + 'widget' => $parent, + 'widget all' => $sibling, + ]); + $runner = new CommandRunner($app); + $result = $runner->run(['cake', 'widget', 'Articles'], $this->getMockIo($output)); + + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + $messages = implode("\n", $output->messages()); + $this->assertStringNotContainsString('Unknown command', $messages); + $this->assertStringContainsString('ran widget with Articles', $messages); + } + /** * Test using `cake --help` invokes the help command */ From 2117ce7d4f2a2dda086d37e1e2c75907ab761571 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 5 Jun 2026 16:26:04 +0200 Subject: [PATCH 2/3] Drop redundant string cast in test (rector RemoveConcatAutocastRector) --- tests/TestCase/Console/CommandRunnerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TestCase/Console/CommandRunnerTest.php b/tests/TestCase/Console/CommandRunnerTest.php index d904471ef5d..700c3d16d8e 100644 --- a/tests/TestCase/Console/CommandRunnerTest.php +++ b/tests/TestCase/Console/CommandRunnerTest.php @@ -229,7 +229,7 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar public function execute(Arguments $args, ConsoleIo $io): int { - $io->out('ran widget with ' . (string)$args->getArgument('name')); + $io->out('ran widget with ' . $args->getArgument('name')); return static::CODE_SUCCESS; } From bf2baa8adbc1c3cdcd1a461972145a0ec5ebe69a Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 5 Jun 2026 16:50:24 +0200 Subject: [PATCH 3/3] Err on the side of running the command when arguments can't be determined When the option parser cannot be inspected, assume the command may take positional arguments and skip the sibling-subcommand check, so a valid command runs instead of being rejected with an unknown-subcommand error. --- src/Console/CommandRunner.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Console/CommandRunner.php b/src/Console/CommandRunner.php index 2f8dc8e2796..e7e4884224f 100644 --- a/src/Console/CommandRunner.php +++ b/src/Console/CommandRunner.php @@ -191,7 +191,7 @@ public function run(array $argv, ?ConsoleIo $io = null): int isset($argv[0]) && !str_starts_with($argv[0], '-') && $this->hasCommandsWithPrefix($commands, $name) - && !$this->commandHasArguments($command) + && $this->isArgumentlessCommand($command) ) { $candidate = $name . ' ' . $argv[0]; if (!$commands->has($candidate)) { @@ -372,15 +372,17 @@ protected function hasCommandsWithPrefix(CommandCollection $commands, string $pr } /** - * Check whether a command declares its own positional arguments. + * Check whether a command is known to accept no positional arguments. * - * A command that accepts positional arguments treats a trailing token as a real argument, - * so it must not be rejected as a mistyped subcommand by the sibling-subcommand check. + * Only returns true when the command's option parser can be inspected and declares zero + * arguments. When the parser cannot be determined, it errs on the side of caution and + * returns false, so a potentially valid command is run rather than rejected by the + * sibling-subcommand check as a mistyped subcommand. * * @param \Cake\Console\CommandInterface $command The resolved command instance to inspect. * @return bool */ - protected function commandHasArguments(CommandInterface $command): bool + protected function isArgumentlessCommand(CommandInterface $command): bool { if (!$command instanceof BaseCommand) { return false; @@ -392,7 +394,7 @@ protected function commandHasArguments(CommandInterface $command): bool return false; } - return $parser->arguments() !== []; + return $parser->arguments() === []; } /**