diff --git a/src/Console/CommandRunner.php b/src/Console/CommandRunner.php index d3639e966f5..e7e4884224f 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->isArgumentlessCommand($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,32 @@ protected function hasCommandsWithPrefix(CommandCollection $commands, string $pr return false; } + /** + * Check whether a command is known to accept no positional arguments. + * + * 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 isArgumentlessCommand(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..700c3d16d8e 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 ' . $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 */