diff --git a/src/Command/Api/ApiCommandHelper.php b/src/Command/Api/ApiCommandHelper.php index 8c2b7fa64..c8573e51d 100644 --- a/src/Command/Api/ApiCommandHelper.php +++ b/src/Command/Api/ApiCommandHelper.php @@ -532,10 +532,11 @@ private function generateApiListCommands(array $apiCommands, string $commandPref continue; } $namespace = $commandNameParts[1]; - if (!array_key_exists($namespace, $apiListCommands)) { + $name = $commandPrefix . ':' . $namespace; + $hasVisibleCommand = $this->namespaceHasVisibleCommand($apiCommands, $namespace); + if (!array_key_exists($name, $apiListCommands) && $hasVisibleCommand) { /** @var \Acquia\Cli\Command\Acsf\AcsfListCommand|\Acquia\Cli\Command\Api\ApiListCommand $command */ $command = $commandFactory->createListCommand(); - $name = $commandPrefix . ':' . $namespace; $command->setName($name); $command->setNamespace($name); $command->setAliases([]); @@ -546,6 +547,42 @@ private function generateApiListCommands(array $apiCommands, string $commandPref return $apiListCommands; } + /** + * Whether any API command in the given namespace is visible (not hidden). + * + * List commands (api:{namespace}) are only registered when at least one sub-command + * under that namespace exists and is visible. If every sub-command is hidden + * (deprecated/pre-release), the namespace list is omitted. + * + * @param ApiBaseCommand[] $apiCommands + */ + private function namespaceHasVisibleCommand(array $apiCommands, string $namespace): bool + { + $commandsInNamespace = []; + foreach ($apiCommands as $apiCommand) { + $commandNameParts = explode(':', $apiCommand->getName()); + if (count($commandNameParts) < 3) { + continue; + } + if ($commandNameParts[1] !== $namespace) { + continue; + } + $commandsInNamespace[] = $apiCommand; + } + + if ($commandsInNamespace === []) { + return false; + } + + foreach ($commandsInNamespace as $command) { + if (!$command->isHidden()) { + return true; + } + } + + return false; + } + /** * @param array $requestBody * @return array diff --git a/src/Command/CommandBase.php b/src/Command/CommandBase.php index 7b0c1da9c..5f4ed423c 100644 --- a/src/Command/CommandBase.php +++ b/src/Command/CommandBase.php @@ -152,6 +152,14 @@ public function appendHelp(string $helpText): void $this->setHelp($helpText); } + /** + * Helper text for IDE-only commands. + */ + public static function getIdeHelperText(): string + { + return 'This command will only work in an IDE terminal.'; + } + protected static function getUuidRegexConstraint(): Regex { return new Regex([ diff --git a/src/Command/Ide/IdePhpVersionCommand.php b/src/Command/Ide/IdePhpVersionCommand.php index 5283b2291..285af2d08 100644 --- a/src/Command/Ide/IdePhpVersionCommand.php +++ b/src/Command/Ide/IdePhpVersionCommand.php @@ -4,6 +4,7 @@ namespace Acquia\Cli\Command\Ide; +use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\DrupalEnvironmentDetector\AcquiaDrupalEnvironmentDetector; use Symfony\Component\Console\Attribute\AsCommand; @@ -22,6 +23,7 @@ protected function configure(): void $this ->addArgument('version', InputArgument::REQUIRED, 'The PHP version') ->setHidden(!AcquiaDrupalEnvironmentDetector::isAhIdeEnv()); + $this->appendHelp(CommandBase::getIdeHelperText()); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Command/Ide/IdeServiceRestartCommand.php b/src/Command/Ide/IdeServiceRestartCommand.php index 211bd9d44..4c87dcb0c 100644 --- a/src/Command/Ide/IdeServiceRestartCommand.php +++ b/src/Command/Ide/IdeServiceRestartCommand.php @@ -4,6 +4,7 @@ namespace Acquia\Cli\Command\Ide; +use Acquia\Cli\Command\CommandBase; use Acquia\DrupalEnvironmentDetector\AcquiaDrupalEnvironmentDetector; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -25,6 +26,7 @@ protected function configure(): void ->addUsage('apache') ->addUsage('mysql') ->setHidden(!AcquiaDrupalEnvironmentDetector::isAhIdeEnv()); + $this->appendHelp(CommandBase::getIdeHelperText()); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Command/Ide/IdeServiceStartCommand.php b/src/Command/Ide/IdeServiceStartCommand.php index 1034d2c1f..909650234 100644 --- a/src/Command/Ide/IdeServiceStartCommand.php +++ b/src/Command/Ide/IdeServiceStartCommand.php @@ -4,6 +4,7 @@ namespace Acquia\Cli\Command\Ide; +use Acquia\Cli\Command\CommandBase; use Acquia\DrupalEnvironmentDetector\AcquiaDrupalEnvironmentDetector; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -25,6 +26,7 @@ protected function configure(): void ->addUsage('apache') ->addUsage('mysql') ->setHidden(!AcquiaDrupalEnvironmentDetector::isAhIdeEnv()); + $this->appendHelp(CommandBase::getIdeHelperText()); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Command/Ide/IdeServiceStopCommand.php b/src/Command/Ide/IdeServiceStopCommand.php index 954824a9e..479e0f3e3 100644 --- a/src/Command/Ide/IdeServiceStopCommand.php +++ b/src/Command/Ide/IdeServiceStopCommand.php @@ -4,6 +4,7 @@ namespace Acquia\Cli\Command\Ide; +use Acquia\Cli\Command\CommandBase; use Acquia\DrupalEnvironmentDetector\AcquiaDrupalEnvironmentDetector; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -25,6 +26,7 @@ protected function configure(): void ->addUsage('apache') ->addUsage('mysql') ->setHidden(!AcquiaDrupalEnvironmentDetector::isAhIdeEnv()); + $this->appendHelp(CommandBase::getIdeHelperText()); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Command/Ide/IdeShareCommand.php b/src/Command/Ide/IdeShareCommand.php index 6d1b4f246..0a52f4581 100644 --- a/src/Command/Ide/IdeShareCommand.php +++ b/src/Command/Ide/IdeShareCommand.php @@ -26,6 +26,7 @@ protected function configure(): void $this ->addOption('regenerate', '', InputOption::VALUE_NONE, 'regenerate the share code') ->setHidden(!AcquiaDrupalEnvironmentDetector::isAhIdeEnv()); + $this->appendHelp(CommandBase::getIdeHelperText()); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Command/Ide/IdeXdebugToggleCommand.php b/src/Command/Ide/IdeXdebugToggleCommand.php index c7787d247..b66d11f39 100644 --- a/src/Command/Ide/IdeXdebugToggleCommand.php +++ b/src/Command/Ide/IdeXdebugToggleCommand.php @@ -4,6 +4,7 @@ namespace Acquia\Cli\Command\Ide; +use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\DrupalEnvironmentDetector\AcquiaDrupalEnvironmentDetector; use Symfony\Component\Console\Attribute\AsCommand; @@ -20,6 +21,7 @@ protected function configure(): void { $this ->setHidden(!AcquiaDrupalEnvironmentDetector::isAhIdeEnv()); + $this->appendHelp(CommandBase::getIdeHelperText()); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Command/Ide/Wizard/IdeWizardCreateSshKeyCommand.php b/src/Command/Ide/Wizard/IdeWizardCreateSshKeyCommand.php index a8712d1b1..fcdf7c021 100644 --- a/src/Command/Ide/Wizard/IdeWizardCreateSshKeyCommand.php +++ b/src/Command/Ide/Wizard/IdeWizardCreateSshKeyCommand.php @@ -21,6 +21,7 @@ protected function configure(): void { $this ->setHidden(!CommandBase::isAcquiaCloudIde()); + $this->appendHelp(CommandBase::getIdeHelperText()); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Command/Ide/Wizard/IdeWizardDeleteSshKeyCommand.php b/src/Command/Ide/Wizard/IdeWizardDeleteSshKeyCommand.php index c6cdf6443..afc297758 100644 --- a/src/Command/Ide/Wizard/IdeWizardDeleteSshKeyCommand.php +++ b/src/Command/Ide/Wizard/IdeWizardDeleteSshKeyCommand.php @@ -23,6 +23,7 @@ protected function configure(): void { $this ->setHidden(!CommandBase::isAcquiaCloudIde()); + $this->appendHelp(CommandBase::getIdeHelperText()); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Command/Self/MakeDocsCommand.php b/src/Command/Self/MakeDocsCommand.php index ad777cc2e..84233db2f 100644 --- a/src/Command/Self/MakeDocsCommand.php +++ b/src/Command/Self/MakeDocsCommand.php @@ -17,6 +17,18 @@ #[AsCommand(name: 'self:make-docs', description: 'Generate documentation for all ACLI commands', hidden: true)] final class MakeDocsCommand extends CommandBase { + /** Commands excluded from public docs (IDE-only; hidden when not in AH IDE per AcquiaDrupalEnvironmentDetector::isAhIdeEnv()). */ + private const DOCS_INCLUDED_COMMANDS = [ + 'ide:php-version', + 'ide:service-restart', + 'ide:service-start', + 'ide:service-stop', + 'ide:share', + 'ide:wizard:ssh-key:create-upload', + 'ide:wizard:ssh-key:delete', + 'ide:xdebug-toggle', + ]; + protected function configure(): void { $this->addOption('format', 'f', InputOption::VALUE_OPTIONAL, 'The format to describe the docs in.', 'rst'); @@ -43,7 +55,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $commands = json_decode($buffer->fetch(), true); $index = []; foreach ($commands['commands'] as $command) { - if ($command['definition']['hidden'] ?? false) { + if (array_key_exists('hidden', $command) && $command['hidden'] && !in_array($command['name'], self::DOCS_INCLUDED_COMMANDS, true)) { continue; } $filename = $command['name'] . '.json'; diff --git a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php new file mode 100644 index 000000000..407827157 --- /dev/null +++ b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php @@ -0,0 +1,112 @@ +injectCommand(ApiListCommand::class); + } + + /** + * Create a mock command with the given name and hidden state. + */ + private function createMockApiCommand(string $name, bool $hidden): Command + { + $cmd = new Command($name); + $cmd->setName($name); + $cmd->setHidden($hidden); + return $cmd; + } + + /** + * Call private generateApiListCommands via reflection. + * + * @param Command[] $apiCommands + * @return \Acquia\Cli\Command\Api\ApiListCommandBase[] + */ + private function generateApiListCommands(array $apiCommands, string $commandPrefix = 'api'): array + { + $helper = new ApiCommandHelper($this->logger); + $ref = new ReflectionMethod(ApiCommandHelper::class, 'generateApiListCommands'); + return $ref->invoke($helper, $apiCommands, $commandPrefix, $this->getCommandFactory()); + } + + public function testNamespaceWithVisibleCommandGetsListCommand(): void + { + $apiCommands = [ + $this->createMockApiCommand('api:foo:list', true), + $this->createMockApiCommand('api:foo:create', false), + ]; + $listCommands = $this->generateApiListCommands($apiCommands); + $this->assertArrayHasKey('api:foo', $listCommands); + $this->assertSame('api:foo', $listCommands['api:foo']->getName()); + } + + /** + * Kill LogicalAndNegation: condition must be (in namespace AND not hidden), not its negation. + * Ensures we only set hasVisibleCommand when we find a command that matches both. + */ + public function testNamespaceWithOneVisibleAndOneHiddenGetsListCommand(): void + { + $apiCommands = [ + $this->createMockApiCommand('api:bar:list', false), + $this->createMockApiCommand('api:bar:create', true), + ]; + $listCommands = $this->generateApiListCommands($apiCommands); + $this->assertArrayHasKey('api:bar', $listCommands); + } + + public function testOnlyOneListCommandPerVisibleNamespace(): void + { + $apiCommands = [ + $this->createMockApiCommand('api:accounts:list', false), + $this->createMockApiCommand('api:accounts:create', false), + ]; + $listCommands = $this->generateApiListCommands($apiCommands); + $this->assertCount(1, $listCommands); + $this->assertArrayHasKey('api:accounts', $listCommands); + } + + /** + * namespaceHasVisibleCommand must scan the full command list (continue on mismatch). + * If continue were replaced with break, a visible command after another namespace would be missed. + */ + public function testNamespaceVisibleCommandAfterOtherNamespaceStillGetsListCommand(): void + { + $apiCommands = [ + $this->createMockApiCommand('api:mix:first', true), + $this->createMockApiCommand('api:other:list', false), + $this->createMockApiCommand('api:mix:second', false), + ]; + $listCommands = $this->generateApiListCommands($apiCommands); + $this->assertArrayHasKey('api:mix', $listCommands, 'mix has a visible command after other namespace; break would skip it.'); + } + + /** + * When every sub-command under a namespace is hidden, omit the namespace list command. + */ + public function testNamespaceWithAllHiddenCommandsDoesNotGetListCommand(): void + { + $apiCommands = [ + $this->createMockApiCommand('api:baz:list', true), + $this->createMockApiCommand('api:baz:create', true), + ]; + $listCommands = $this->generateApiListCommands($apiCommands); + $this->assertArrayNotHasKey('api:baz', $listCommands); + } +} diff --git a/tests/phpunit/src/Commands/CommandBaseTest.php b/tests/phpunit/src/Commands/CommandBaseTest.php index 9efd49405..1ba70b09c 100644 --- a/tests/phpunit/src/Commands/CommandBaseTest.php +++ b/tests/phpunit/src/Commands/CommandBaseTest.php @@ -7,7 +7,16 @@ use Acquia\Cli\Command\App\LinkCommand; use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeListCommand; +use Acquia\Cli\Command\Ide\IdePhpVersionCommand; +use Acquia\Cli\Command\Ide\IdeServiceRestartCommand; +use Acquia\Cli\Command\Ide\IdeServiceStartCommand; +use Acquia\Cli\Command\Ide\IdeServiceStopCommand; +use Acquia\Cli\Command\Ide\IdeShareCommand; +use Acquia\Cli\Command\Ide\IdeXdebugToggleCommand; +use Acquia\Cli\Command\Ide\Wizard\IdeWizardCreateSshKeyCommand; +use Acquia\Cli\Command\Ide\Wizard\IdeWizardDeleteSshKeyCommand; use Acquia\Cli\Exception\AcquiaCliException; +use Acquia\Cli\Tests\Commands\Ide\IdeHelper; use Acquia\Cli\Tests\CommandTestBase; use Symfony\Component\Validator\Exception\ValidatorException; @@ -48,6 +57,45 @@ public function testCloudAppFromLocalConfig(): void $this->executeCommand(); } + /** + * Test getIdeHelperText returns the expected IDE helper string. + * Calling from test (outside class) ensures the method remains public. + */ + public function testGetIdeHelperText(): void + { + $this->assertSame('This command will only work in an IDE terminal.', CommandBase::getIdeHelperText()); + } + + /** + * Test that all IDE-only commands append the IDE helper text to their help. + * Covers configure() appendHelp(CommandBase::getIdeHelperText()) in each command. + */ + public function testIdeCommandsHelpContainsIdeHelperText(): void + { + $ideHelperText = 'This command will only work in an IDE terminal.'; + $commandClasses = [ + IdePhpVersionCommand::class, + IdeServiceRestartCommand::class, + IdeServiceStartCommand::class, + IdeServiceStopCommand::class, + IdeShareCommand::class, + IdeXdebugToggleCommand::class, + IdeWizardCreateSshKeyCommand::class, + IdeWizardDeleteSshKeyCommand::class, + ]; + + IdeHelper::setCloudIdeEnvVars(); + try { + foreach ($commandClasses as $commandClass) { + $command = $this->injectCommand($commandClass); + $help = $command->getHelp(); + $this->assertStringContainsString($ideHelperText, $help, $commandClass . ' help should contain IDE helper text.'); + } + } finally { + IdeHelper::unsetCloudIdeEnvVars(); + } + } + /** * @return string[][] */ diff --git a/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php b/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php index 3a05cb2e3..4b9fe0ced 100644 --- a/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php @@ -94,6 +94,12 @@ public function testIdePhpVersionCommandOutsideIde(): void ]); } + public function testIdePhpVersionCommandHelpContainsIdeHelperText(): void + { + $help = $this->command->getHelp(); + $this->assertStringContainsString('This command will only work in an IDE terminal.', $help); + } + protected function mockRestartPhp(ObjectProphecy|LocalMachineHelper $localMachineHelper): ObjectProphecy { $process = $this->prophet->prophesize(Process::class); diff --git a/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php index 6690e59ee..4847608ac 100644 --- a/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php @@ -45,4 +45,10 @@ public function testIdeServiceRestartCommandInvalid(): void $this->expectExceptionMessage('Specify a valid service name'); $this->executeCommand(['service' => 'rambulator'], []); } + + public function testIdeServiceRestartCommandHelpContainsIdeHelperText(): void + { + $help = $this->command->getHelp(); + $this->assertStringContainsString('This command will only work in an IDE terminal.', $help); + } } diff --git a/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php index bcc2842a8..e6f68b1e4 100644 --- a/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php @@ -45,4 +45,10 @@ public function testIdeServiceStartCommandInvalid(): void $this->expectExceptionMessage('Specify a valid service name'); $this->executeCommand(['service' => 'rambulator'], []); } + + public function testIdeServiceStartCommandHelpContainsIdeHelperText(): void + { + $help = $this->command->getHelp(); + $this->assertStringContainsString('This command will only work in an IDE terminal.', $help); + } } diff --git a/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php index 00964e835..9f9c210d9 100644 --- a/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php @@ -45,4 +45,10 @@ public function testIdeServiceStopCommandInvalid(): void $this->expectExceptionMessage('Specify a valid service name'); $this->executeCommand(['service' => 'rambulator'], []); } + + public function testIdeServiceStopCommandHelpContainsIdeHelperText(): void + { + $help = $this->command->getHelp(); + $this->assertStringContainsString('This command will only work in an IDE terminal.', $help); + } } diff --git a/tests/phpunit/src/Commands/Ide/IdeShareCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeShareCommandTest.php index 062fba839..2035b5da8 100644 --- a/tests/phpunit/src/Commands/Ide/IdeShareCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeShareCommandTest.php @@ -56,4 +56,10 @@ public function testIdeShareRegenerateCommand(): void $this->assertStringContainsString('Your IDE Share URL: ', $output); $this->assertStringNotContainsString($this->shareCode, $output); } + + public function testIdeShareCommandHelpContainsIdeHelperText(): void + { + $help = $this->command->getHelp(); + $this->assertStringContainsString('This command will only work in an IDE terminal.', $help); + } } diff --git a/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php index 1588bbbe5..93d9e1287 100644 --- a/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php @@ -82,4 +82,10 @@ public function testXdebugCommandDisable(mixed $phpVersion): void $this->assertStringContainsString(';zend_extension=xdebug.so', file_get_contents($this->xdebugFilePath)); $this->assertStringContainsString("Xdebug PHP extension disabled", $this->getDisplay()); } + + public function testIdeXdebugToggleCommandHelpContainsIdeHelperText(): void + { + $help = $this->command->getHelp(); + $this->assertStringContainsString('This command will only work in an IDE terminal.', $help); + } } diff --git a/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php b/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php index 81742426e..c19c4b074 100644 --- a/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php @@ -60,4 +60,13 @@ public function testPromptWaitForSshReturnsFalse(): void { $this->runTestPromptWaitForSshReturnsFalse(); } + + /** + * @group brokenProphecy + */ + public function testIdeWizardCreateSshKeyCommandHelpContainsIdeHelperText(): void + { + $help = $this->command->getHelp(); + $this->assertStringContainsString('This command will only work in an IDE terminal.', $help); + } } diff --git a/tests/phpunit/src/Commands/Self/MakeDocsCommandTest.php b/tests/phpunit/src/Commands/Self/MakeDocsCommandTest.php index 1ae603ef9..1450a5705 100644 --- a/tests/phpunit/src/Commands/Self/MakeDocsCommandTest.php +++ b/tests/phpunit/src/Commands/Self/MakeDocsCommandTest.php @@ -34,4 +34,18 @@ public function testMakeDocsCommandDump(): void $this->executeCommand(['--dump' => $vfs->url()]); $this->assertStringContainsString('The completion command dumps', $vfs->getChild('completion.json')->getContent()); } + + /** + * Hidden commands must be excluded from dump (not in index, no file). + * When 'hidden' is true the command must be skipped (not in index, no file). + */ + public function testMakeDocsCommandDumpExcludesHiddenCommands(): void + { + $vfs = vfsStream::setup('root'); + $this->executeCommand(['--dump' => $vfs->url()]); + $index = json_decode($vfs->getChild('index.json')->getContent(), true); + $commandNames = array_column($index, 'command'); + $this->assertNotContains('self:make-docs', $commandNames, 'Hidden commands must be excluded from index'); + $this->assertFalse($vfs->hasChild('self:make-docs.json'), 'Hidden commands must not have a doc file'); + } }