diff --git a/commands/list_models.go b/commands/list_models.go index 8741e251..690b249e 100644 --- a/commands/list_models.go +++ b/commands/list_models.go @@ -213,14 +213,15 @@ type Application struct { } type Command struct { - Name CommandName `json:"name"` - Usage []string `json:"usage"` - Aliases []string `json:"aliases"` - Description CleanString `json:"description"` - Help CleanString `json:"help"` - Examples []Example `json:"examples"` - Definition Definition `json:"definition"` - Hidden bool `json:"hidden"` + Name CommandName `json:"name"` + Usage []string `json:"usage"` + Aliases []string `json:"aliases"` + HiddenAliases []string `json:"hidden_aliases,omitempty"` + Description CleanString `json:"description"` + Help CleanString `json:"help"` + Examples []Example `json:"examples"` + Definition Definition `json:"definition"` + Hidden bool `json:"hidden"` } // indentLines adds a number of spaces to each line of the given text. diff --git a/integration-tests/hidden_aliases_test.go b/integration-tests/hidden_aliases_test.go new file mode 100644 index 00000000..f9fab83f --- /dev/null +++ b/integration-tests/hidden_aliases_test.go @@ -0,0 +1,59 @@ +package tests + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHiddenAliases(t *testing.T) { + f := newCommandFactory(t, "", "") + + cases := []struct { + alias string + command string + }{ + {"snapshots", "backup:list"}, + {"logs", "environment:logs"}, + {"user:role", "user:get"}, + {"environment:sql", "db:sql"}, + } + for _, c := range cases { + t.Run(c.alias, func(t *testing.T) { + var data struct { + Name string `json:"name"` + } + require.NoError(t, json.Unmarshal([]byte(f.Run("help", c.alias, "--format", "json")), &data)) + assert.Equal(t, c.command, data.Name) + }) + } + + help := f.Run("help", "snapshots") + assert.Contains(t, help, "Aliases: backups") + assert.NotContains(t, help, "snapshot") + + assert.NotContains(t, f.Run("list", "backup"), "snapshot") + + // Namespaces only used by hidden aliases are not listed. + _, stdErr, err := f.RunCombinedOutput("snapshot") + assert.Error(t, err) + assert.Contains(t, stdErr, `Command "snapshot" is not defined.`) + + var list struct { + Commands []struct { + Name string `json:"name"` + HiddenAliases []string `json:"hidden_aliases"` + } `json:"commands"` + } + require.NoError(t, json.Unmarshal([]byte(f.Run("list", "--format", "json")), &list)) + var found bool + for _, c := range list.Commands { + if c.Name == "backup:list" { + found = true + assert.Equal(t, []string{"snapshots", "snapshot:list"}, c.HiddenAliases) + } + } + assert.True(t, found) +} diff --git a/legacy/phpstan-baseline.neon b/legacy/phpstan-baseline.neon index 246d95cc..fe545a31 100644 --- a/legacy/phpstan-baseline.neon +++ b/legacy/phpstan-baseline.neon @@ -282,12 +282,6 @@ parameters: count: 1 path: src/Application.php - - - message: '#^Parameter \#1 \$value of class Symfony\\Component\\Console\\Completion\\Suggestion constructor expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Application.php - - message: '#^Access to an undefined property Platformsh\\Client\\Model\\ApiResourceBase\:\:\$created_at\.$#' identifier: property.notFound @@ -1056,24 +1050,6 @@ parameters: count: 1 path: src/Command/CommandBase.php - - - message: '#^Method Platformsh\\Cli\\Command\\CommandBase\:\:getVisibleAliases\(\) should return array\ but returns array\.$#' - identifier: return.type - count: 1 - path: src/Command/CommandBase.php - - - - message: '#^Parameter \#1 \$aliases of method Symfony\\Component\\Console\\Command\\Command\:\:setAliases\(\) expects iterable\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Command/CommandBase.php - - - - message: '#^Parameter \#1 \$array of function array_diff expects an array of values castable to string, array given\.$#' - identifier: argument.type - count: 1 - path: src/Command/CommandBase.php - - message: '#^Parameter \#1 \$array of function array_intersect expects array, mixed given\.$#' identifier: argument.type @@ -4716,12 +4692,6 @@ parameters: count: 1 path: src/Console/CustomMarkdownDescriptor.php - - - message: '#^Parameter \#2 \$array of function implode expects array\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Console/CustomMarkdownDescriptor.php - - message: '#^Parameter \#2 \$namespace of method Platformsh\\Cli\\Console\\DescriptorUtils\:\:describeNamespaces\(\) expects string\|null, mixed given\.$#' identifier: argument.type @@ -4821,7 +4791,7 @@ parameters: - message: '#^Parameter \#2 \$array of function implode expects array\, array given\.$#' identifier: argument.type - count: 2 + count: 1 path: src/Console/CustomTextDescriptor.php - diff --git a/legacy/phpstan-stubs/Command.stub b/legacy/phpstan-stubs/Command.stub new file mode 100644 index 00000000..07699953 --- /dev/null +++ b/legacy/phpstan-stubs/Command.stub @@ -0,0 +1,11 @@ +container()->get('console.command_loader'); - $this->setCommandLoader($loader); + /** @var string[] $hiddenAliases */ + $hiddenAliases = $this->container()->hasParameter(HiddenAliasesPass::PARAMETER) + ? $this->container()->getParameter(HiddenAliasesPass::PARAMETER) + : []; + $this->hiddenAliases = $hiddenAliases; + $this->commandLoader = new HiddenAliasesCommandLoader($loader, $hiddenAliases); + $this->setCommandLoader($this->commandLoader); // Set "welcome" as the default command. $this->setDefaultCommand(WelcomeCommand::getDefaultName()); @@ -110,15 +123,39 @@ public static function warmCaches(): void * {@inheritdoc} * * Prevent commands being enabled, according to config.yaml configuration. + * + * Hidden aliases are removed, so that they are not used for abbreviations + * or namespaces. + * + * @see self::get() */ - public function add(ConsoleCommand $command): ?ConsoleCommand + public function addCommand(callable|ConsoleCommand $command): ?ConsoleCommand { - if (!$this->config->isCommandEnabled($command->getName())) { - $command->setApplication(null); - return null; + if ($command instanceof ConsoleCommand) { + if (!$this->config->isCommandEnabled($command->getName())) { + $command->setApplication(null); + return null; + } + if (array_intersect($command->getAliases(), $this->hiddenAliases)) { + $command->setAliases(array_values(array_diff($command->getAliases(), $this->hiddenAliases))); + } + } + + return parent::addCommand($command); + } + + /** + * {@inheritdoc} + * + * Gets commands by their hidden aliases, which only work in full. + */ + public function get(string $name): ConsoleCommand + { + if (in_array($name, $this->hiddenAliases, true) && $this->commandLoader?->has($name)) { + $name = $this->commandLoader->get($name)->getName() ?? $name; } - return parent::add($command); + return parent::get($name); } /** @@ -153,6 +190,7 @@ private function container(bool $recompile = false): ContainerInterface $e->getMessage(), )); } + $this->container->addCompilerPass(new HiddenAliasesPass()); $this->container->addCompilerPass(new AddConsoleCommandPass()); $this->container->compile(); $dumper = new PhpDumper($this->container); @@ -231,7 +269,8 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti continue; } $suggestions->suggestValue(new Suggestion($name, $command->getDescription())); - foreach ($command->getAliases() as $alias) { + $aliases = $command instanceof CommandBase ? $command->getVisibleAliases() : $command->getAliases(); + foreach ($aliases as $alias) { $suggestions->suggestValue(new Suggestion($alias, $command->getDescription())); } } diff --git a/legacy/src/Command/Backup/BackupCreateCommand.php b/legacy/src/Command/Backup/BackupCreateCommand.php index 4d25e41f..8b8ec5c4 100644 --- a/legacy/src/Command/Backup/BackupCreateCommand.php +++ b/legacy/src/Command/Backup/BackupCreateCommand.php @@ -21,8 +21,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'backup:create', description: 'Make a backup of an environment', aliases: ['backup'])] +#[HiddenAliases(['snapshot:create', 'environment:backup'])] class BackupCreateCommand extends CommandBase { public function __construct(private readonly ActivityMonitor $activityMonitor, private readonly Api $api, private readonly Config $config, private readonly Io $io, private readonly QuestionHelper $questionHelper, private readonly Selector $selector) @@ -46,7 +48,6 @@ protected function configure(): void $this->addCompleter($this->selector); $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addHiddenOption('unsafe', null, InputOption::VALUE_NONE, 'Deprecated option: use --live instead'); - $this->setHiddenAliases(['snapshot:create', 'environment:backup']); $this->addExample('Make a backup of the current environment'); $this->addExample('Request a backup (and exit quickly)', '--no-wait'); $this->addExample('Make a backup avoiding downtime (but risking inconsistency)', '--live'); diff --git a/legacy/src/Command/Backup/BackupListCommand.php b/legacy/src/Command/Backup/BackupListCommand.php index cae81806..1d9744f2 100644 --- a/legacy/src/Command/Backup/BackupListCommand.php +++ b/legacy/src/Command/Backup/BackupListCommand.php @@ -16,8 +16,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'backup:list', description: 'List available backups of an environment', aliases: ['backups'])] +#[HiddenAliases(['snapshots', 'snapshot:list'])] class BackupListCommand extends CommandBase { /** @var array */ @@ -50,7 +52,6 @@ protected function configure(): void $this->selector->addProjectOption($this->getDefinition()); $this->selector->addEnvironmentOption($this->getDefinition()); $this->addCompleter($this->selector); - $this->setHiddenAliases(['snapshots', 'snapshot:list']); $this->addExample('Display backups including the "live" and "commit_id" columns', '-c+live,commit_id'); } diff --git a/legacy/src/Command/Backup/BackupRestoreCommand.php b/legacy/src/Command/Backup/BackupRestoreCommand.php index ee766192..0b4dd8d7 100644 --- a/legacy/src/Command/Backup/BackupRestoreCommand.php +++ b/legacy/src/Command/Backup/BackupRestoreCommand.php @@ -21,8 +21,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'backup:restore', description: 'Restore an environment backup')] +#[HiddenAliases(['environment:restore', 'snapshot:restore'])] class BackupRestoreCommand extends CommandBase { /** @var string[] */ @@ -49,7 +51,6 @@ protected function configure(): void $this->selector->addEnvironmentOption($this->getDefinition()); $this->addCompleter($this->selector); $this->activityMonitor->addWaitOptions($this->getDefinition()); - $this->setHiddenAliases(['environment:restore', 'snapshot:restore']); $this->addExample('Restore the most recent backup'); $this->addExample('Restore a specific backup', '92c9a4b2aa75422efb3d'); } diff --git a/legacy/src/Command/ClearCacheCommand.php b/legacy/src/Command/ClearCacheCommand.php index aa18ae22..1718ec99 100644 --- a/legacy/src/Command/ClearCacheCommand.php +++ b/legacy/src/Command/ClearCacheCommand.php @@ -8,20 +8,16 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'clear-cache', description: 'Clear the CLI cache', aliases: ['cc'])] +#[HiddenAliases(['clearcache'])] class ClearCacheCommand extends CommandBase { public function __construct(private readonly CacheProvider $cacheProvider) { parent::__construct(); } - protected function configure(): void - { - $this - ->setHiddenAliases(['clearcache']); - } - protected function execute(InputInterface $input, OutputInterface $output): int { $cache = $this->cacheProvider; diff --git a/legacy/src/Command/CommandBase.php b/legacy/src/Command/CommandBase.php index eb6bd33a..dc901e01 100644 --- a/legacy/src/Command/CommandBase.php +++ b/legacy/src/Command/CommandBase.php @@ -5,6 +5,7 @@ namespace Platformsh\Cli\Command; use Platformsh\Cli\Console\CompleterInterface; +use Platformsh\Cli\Console\HiddenAliases; use Platformsh\Cli\Console\HiddenInputOption; use Platformsh\Cli\Service\Config; use Symfony\Component\Console\Command\Command; @@ -42,12 +43,6 @@ abstract class CommandBase extends Command implements MultiAwareInterface protected bool $canBeRunMultipleTimes = true; protected bool $runningViaMulti = false; - /** - * @var string[] - * @see self::setHiddenAliases() - */ - private array $hiddenAliases = []; - /** * The command synopsis. * @var array @@ -117,20 +112,15 @@ protected function addHiddenOption(string $name, ?string $shortcut = null, ?int } /** - * Add aliases that should be hidden from help. - * - * @see parent::setAliases() + * Get aliases that should be hidden from help. * - * @param string[] $hiddenAliases - * - * @return static + * @return string[] */ - protected function setHiddenAliases(array $hiddenAliases): static + public function getHiddenAliases(): array { - $this->hiddenAliases = $hiddenAliases; - $this->setAliases(array_merge($this->getAliases(), $hiddenAliases)); + $attribute = (new \ReflectionClass($this))->getAttributes(HiddenAliases::class)[0] ?? null; - return $this; + return $attribute?->newInstance()->aliases ?? []; } /** @@ -140,7 +130,7 @@ protected function setHiddenAliases(array $hiddenAliases): static */ public function getVisibleAliases(): array { - return array_diff($this->getAliases(), $this->hiddenAliases); + return array_values(array_diff($this->getAliases(), $this->getHiddenAliases())); } /** diff --git a/legacy/src/Command/Db/DbDumpCommand.php b/legacy/src/Command/Db/DbDumpCommand.php index c81f3ca1..9f27d973 100644 --- a/legacy/src/Command/Db/DbDumpCommand.php +++ b/legacy/src/Command/Db/DbDumpCommand.php @@ -23,8 +23,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'db:dump', description: 'Create a local dump of the remote database')] +#[HiddenAliases(['sql-dump', 'environment:sql-dump'])] class DbDumpCommand extends CommandBase { public function __construct(private readonly Api $api, private readonly Config $config, private readonly Filesystem $filesystem, private readonly Git $git, private readonly QuestionHelper $questionHelper, private readonly Relationships $relationships, private readonly Selector $selector) @@ -50,7 +52,6 @@ protected function configure(): void $this->addCompleter($this->selector); Relationships::configureInput($this->getDefinition()); Ssh::configureInput($this->getDefinition()); - $this->setHiddenAliases(['sql-dump', 'environment:sql-dump']); $this->addExample('Create an SQL dump file'); $this->addExample('Create a gzipped SQL dump file named "dump.sql.gz"', '--gzip -f dump.sql.gz'); } diff --git a/legacy/src/Command/Db/DbSqlCommand.php b/legacy/src/Command/Db/DbSqlCommand.php index 7c8f5b40..25357c89 100644 --- a/legacy/src/Command/Db/DbSqlCommand.php +++ b/legacy/src/Command/Db/DbSqlCommand.php @@ -22,8 +22,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'db:sql', description: 'Run SQL on the remote database', aliases: ['sql'])] +#[HiddenAliases(['environment:sql'])] class DbSqlCommand extends CommandBase { public function __construct(private readonly Api $api, private readonly Io $io, private readonly QuestionHelper $questionHelper, private readonly Relationships $relationships, private readonly Selector $selector) @@ -45,7 +47,6 @@ protected function configure(): void $this->addExample('Open an SQL console on the remote database'); $this->addExample('View tables on the remote database', "'SHOW TABLES'"); $this->addExample('Import a dump file into the remote database', '< dump.sql'); - $this->setHiddenAliases(['environment:sql']); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/legacy/src/Command/Environment/EnvironmentDeleteCommand.php b/legacy/src/Command/Environment/EnvironmentDeleteCommand.php index f4ef2c63..d78aabde 100644 --- a/legacy/src/Command/Environment/EnvironmentDeleteCommand.php +++ b/legacy/src/Command/Environment/EnvironmentDeleteCommand.php @@ -22,8 +22,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'environment:delete', description: 'Delete one or more environments')] +#[HiddenAliases(['environment:deactivate'])] class EnvironmentDeleteCommand extends CommandBase { public function __construct( @@ -40,7 +42,6 @@ public function __construct( protected function configure(): void { $this - ->setHiddenAliases(['environment:deactivate']) ->addArgument('environment', InputArgument::IS_ARRAY, "The environment(s) to delete.\n" . Wildcard::HELP . "\n" . ArrayArgument::SPLIT_HELP) ->addOption('delete-branch', null, InputOption::VALUE_NONE, 'Delete Git branch(es) for inactive environments, without confirmation') ->addOption('no-delete-branch', null, InputOption::VALUE_NONE, 'Do not delete any Git branch(es) (inactive environments)') diff --git a/legacy/src/Command/Environment/EnvironmentInfoCommand.php b/legacy/src/Command/Environment/EnvironmentInfoCommand.php index 54ce36a5..115c46db 100644 --- a/legacy/src/Command/Environment/EnvironmentInfoCommand.php +++ b/legacy/src/Command/Environment/EnvironmentInfoCommand.php @@ -22,8 +22,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'environment:info', description: 'Read or set properties for an environment')] +#[HiddenAliases(['environment:metadata'])] class EnvironmentInfoCommand extends CommandBase { public function __construct(private readonly ActivityMonitor $activityMonitor, private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) @@ -50,7 +52,6 @@ protected function configure(): void ->addExample('Change the environment title', 'title "New feature"') ->addExample("Change the environment's parent branch", 'parent sprint-2') ->addExample("Unset the environment's parent branch", 'parent -'); - $this->setHiddenAliases(['environment:metadata']); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/legacy/src/Command/Environment/EnvironmentLogCommand.php b/legacy/src/Command/Environment/EnvironmentLogCommand.php index d6b8e5c7..f042ec30 100644 --- a/legacy/src/Command/Environment/EnvironmentLogCommand.php +++ b/legacy/src/Command/Environment/EnvironmentLogCommand.php @@ -20,8 +20,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'environment:logs', description: "Read an environment's logs", aliases: ['log'])] +#[HiddenAliases(['logs'])] class EnvironmentLogCommand extends CommandBase { public function __construct(private readonly CacheProvider $cacheProvider, private readonly Io $io, private readonly QuestionHelper $questionHelper, private readonly Selector $selector) @@ -46,7 +48,6 @@ protected function configure(): void $this->selector->addRemoteContainerOptions($this->getDefinition()); $this->selector->addTaskOption($this->getDefinition()); $this->addCompleter($this->selector); - $this->setHiddenAliases(['logs']); $this->addExample('Display a choice of logs that can be read'); $this->addExample('Read the deploy log', 'deploy'); $this->addExample('Read the access log continuously', 'access --tail'); diff --git a/legacy/src/Command/Integration/Activity/IntegrationActivityListCommand.php b/legacy/src/Command/Integration/Activity/IntegrationActivityListCommand.php index 10e0055d..bdc3bf54 100644 --- a/legacy/src/Command/Integration/Activity/IntegrationActivityListCommand.php +++ b/legacy/src/Command/Integration/Activity/IntegrationActivityListCommand.php @@ -23,8 +23,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'integration:activity:list', description: 'Get a list of activities for an integration', aliases: ['integration:activities'])] +#[HiddenAliases(['int:act', 'i:act'])] class IntegrationActivityListCommand extends IntegrationCommandBase { /** @var array */ @@ -52,7 +54,6 @@ public function __construct(private readonly ActivityLoader $activityLoader, pri protected function configure(): void { $this - ->setHiddenAliases(['int:act', 'i:act']) ->addArgument('id', InputArgument::OPTIONAL, 'An integration ID. Leave blank to choose from a list.') ->addOption( 'type', diff --git a/legacy/src/Command/Organization/OrganizationSubscriptionListCommand.php b/legacy/src/Command/Organization/OrganizationSubscriptionListCommand.php index fe7bfda4..4a76f7e3 100644 --- a/legacy/src/Command/Organization/OrganizationSubscriptionListCommand.php +++ b/legacy/src/Command/Organization/OrganizationSubscriptionListCommand.php @@ -16,8 +16,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'organization:subscription:list', description: 'List subscriptions within an organization', aliases: ['org:subs'])] +#[HiddenAliases(['organization:subscriptions'])] class OrganizationSubscriptionListCommand extends OrganizationCommandBase { /** The maximum page size allowed by the API. */ @@ -43,7 +45,7 @@ public function __construct(private readonly Api $api, private readonly Config $ protected function configure(): void { - $this->setHiddenAliases(['organization:subscriptions']) + $this ->addOption('page', null, InputOption::VALUE_REQUIRED, 'Page number. This enables pagination, despite the configuration or --count 0.') ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page (max: ' . self::MAX_COUNT . '). Use 0 to disable pagination.'); $this->selector->addOrganizationOptions($this->getDefinition(), true); diff --git a/legacy/src/Command/Organization/User/OrganizationUserListCommand.php b/legacy/src/Command/Organization/User/OrganizationUserListCommand.php index 9ef98476..2b333505 100644 --- a/legacy/src/Command/Organization/User/OrganizationUserListCommand.php +++ b/legacy/src/Command/Organization/User/OrganizationUserListCommand.php @@ -18,8 +18,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'organization:user:list', description: 'List organization users', aliases: ['org:users'])] +#[HiddenAliases(['organization:users'])] class OrganizationUserListCommand extends OrganizationCommandBase { /** @var array */ @@ -48,8 +50,7 @@ protected function configure(): void { $this->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination.') ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'A property to sort by (created_at or updated_at)', 'created_at') - ->addOption('reverse', null, InputOption::VALUE_NONE, 'Reverse the sort order') - ->setHiddenAliases(['organization:users']); + ->addOption('reverse', null, InputOption::VALUE_NONE, 'Reverse the sort order'); $this->selector->addOrganizationOptions($this->getDefinition(), true); $this->addCompleter($this->selector); PropertyFormatter::configureInput($this->getDefinition()); diff --git a/legacy/src/Command/Project/ProjectInfoCommand.php b/legacy/src/Command/Project/ProjectInfoCommand.php index e389a63a..df475f2f 100644 --- a/legacy/src/Command/Project/ProjectInfoCommand.php +++ b/legacy/src/Command/Project/ProjectInfoCommand.php @@ -19,8 +19,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'project:info', description: 'Read or set properties for a project')] +#[HiddenAliases(['project:metadata'])] class ProjectInfoCommand extends CommandBase { public function __construct(private readonly ActivityMonitor $activityMonitor, private readonly Api $api, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) @@ -42,7 +44,6 @@ protected function configure(): void $this->addExample('Read all project properties') ->addExample("Show the project's Git URL", 'git') ->addExample("Change the project's title", 'title "My project"'); - $this->setHiddenAliases(['project:metadata']); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/legacy/src/Command/Project/Variable/ProjectVariableGetCommand.php b/legacy/src/Command/Project/Variable/ProjectVariableGetCommand.php index a48d73ca..a186a97c 100644 --- a/legacy/src/Command/Project/Variable/ProjectVariableGetCommand.php +++ b/legacy/src/Command/Project/Variable/ProjectVariableGetCommand.php @@ -15,11 +15,13 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; /** * @deprecated Use variable:get and variable:list instead */ #[AsCommand(name: 'project:variable:get', description: 'View variable(s) for a project', aliases: ['project-variables', 'pvget'])] +#[HiddenAliases(['project:variable:list'])] class ProjectVariableGetCommand extends CommandBase { protected bool $hiddenInList = true; @@ -41,7 +43,6 @@ protected function configure(): void Table::configureInput($this->getDefinition()); $this->selector->addProjectOption($this->getDefinition()); $this->addCompleter($this->selector); - $this->setHiddenAliases(['project:variable:list']); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/legacy/src/Command/Route/RouteListCommand.php b/legacy/src/Command/Route/RouteListCommand.php index 31233e82..df55f153 100644 --- a/legacy/src/Command/Route/RouteListCommand.php +++ b/legacy/src/Command/Route/RouteListCommand.php @@ -19,8 +19,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'route:list', description: 'List all routes for an environment', aliases: ['routes'])] +#[HiddenAliases(['environment:routes'])] class RouteListCommand extends CommandBase { /** @var array */ @@ -42,7 +44,6 @@ protected function configure(): void $this ->addArgument('environment', InputArgument::OPTIONAL, 'The environment ID') ->addOption('refresh', null, InputOption::VALUE_NONE, 'Bypass the cache of routes'); - $this->setHiddenAliases(['environment:routes']); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); $this->selector->addProjectOption($this->getDefinition()); $this->selector->addEnvironmentOption($this->getDefinition()); diff --git a/legacy/src/Command/Self/SelfInstallCommand.php b/legacy/src/Command/Self/SelfInstallCommand.php index 54ba3627..5d6905fb 100644 --- a/legacy/src/Command/Self/SelfInstallCommand.php +++ b/legacy/src/Command/Self/SelfInstallCommand.php @@ -20,8 +20,10 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'self:install', description: 'Install or update CLI configuration files')] +#[HiddenAliases(['local:install'])] class SelfInstallCommand extends CommandBase { public const INSTALLED_FILENAME = 'self_installed'; @@ -34,7 +36,6 @@ protected function configure(): void { $this ->addOption('shell-type', null, InputOption::VALUE_REQUIRED, 'The shell type for autocompletion (bash or zsh)'); - $this->setHiddenAliases(['local:install']); $cliName = $this->config->getStr('application.name'); $this->setHelp( <<setHiddenAliases(['self-update']) ->addOption('no-major', null, InputOption::VALUE_NONE, 'Only update between minor or patch versions') ->addOption('unstable', null, InputOption::VALUE_NONE, 'Update to a new unstable version, if available') ->addOption('manifest', null, InputOption::VALUE_REQUIRED, 'Override the manifest file location') diff --git a/legacy/src/Command/User/UserGetCommand.php b/legacy/src/Command/User/UserGetCommand.php index 05f94401..29b067ad 100644 --- a/legacy/src/Command/User/UserGetCommand.php +++ b/legacy/src/Command/User/UserGetCommand.php @@ -22,8 +22,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Platformsh\Cli\Console\HiddenAliases; #[AsCommand(name: 'user:get', description: "View a user's role(s)")] +#[HiddenAliases(['user:role'])] class UserGetCommand extends CommandBase { public function __construct(private readonly AccessApi $accessApi, private readonly ActivityMonitor $activityMonitor, private readonly Io $io, private readonly QuestionHelper $questionHelper, private readonly Selector $selector, private readonly SubCommandRunner $subCommandRunner) @@ -43,7 +45,6 @@ protected function configure(): void $this->activityMonitor->addWaitOptions($this->getDefinition()); // Backwards compatibility. - $this->setHiddenAliases(['user:role']); $this->addOption('role', 'r', InputOption::VALUE_REQUIRED, "[Deprecated: use user:update to change a user's role(s)]"); $this->addExample("View Alice's role on the project", 'alice@example.com'); diff --git a/legacy/src/Console/CustomJsonDescriptor.php b/legacy/src/Console/CustomJsonDescriptor.php index 69d48f00..4f1084b5 100644 --- a/legacy/src/Console/CustomJsonDescriptor.php +++ b/legacy/src/Console/CustomJsonDescriptor.php @@ -140,12 +140,14 @@ private function getCommandData(Command $command): array $command->getSynopsis(); $command->mergeApplicationDefinition(false); $aliases = $command instanceof CommandBase ? $command->getVisibleAliases() : $command->getAliases(); + $hiddenAliases = $command instanceof CommandBase ? $command->getHiddenAliases() : []; $examples = $command instanceof CommandBase ? $command->getExamples() : []; return [ 'name' => $command->getName(), 'usage' => array_merge([$command->getSynopsis()], $command->getUsages()), 'aliases' => $aliases, + 'hidden_aliases' => $hiddenAliases, 'description' => $command->getDescription(), 'help' => $command->getProcessedHelp(), 'examples' => $examples, diff --git a/legacy/src/Console/CustomTextDescriptor.php b/legacy/src/Console/CustomTextDescriptor.php index cde00b1a..b585e869 100644 --- a/legacy/src/Console/CustomTextDescriptor.php +++ b/legacy/src/Console/CustomTextDescriptor.php @@ -184,7 +184,8 @@ protected function getColumnWidth(array $commands): int|float { $width = 0; foreach ($commands as $command) { - $aliasesString = $this->formatAliases($command->getAliases()); + $aliases = $command instanceof CommandBase ? $command->getVisibleAliases() : $command->getAliases(); + $aliasesString = $this->formatAliases($aliases); $commandWidth = strlen((string) $command->getName()) + strlen($aliasesString); $width = max($commandWidth, $width); } diff --git a/legacy/src/Console/HiddenAliases.php b/legacy/src/Console/HiddenAliases.php new file mode 100644 index 00000000..ba424b92 --- /dev/null +++ b/legacy/src/Console/HiddenAliases.php @@ -0,0 +1,19 @@ +loader->get($name); + } + + public function has(string $name): bool + { + return $this->loader->has($name); + } + + public function getNames(): array + { + return array_values(array_diff($this->loader->getNames(), $this->hiddenAliases)); + } +} diff --git a/legacy/src/Console/HiddenAliasesPass.php b/legacy/src/Console/HiddenAliasesPass.php new file mode 100644 index 00000000..cf17c541 --- /dev/null +++ b/legacy/src/Console/HiddenAliasesPass.php @@ -0,0 +1,53 @@ +findTaggedServiceIds('console.command')) as $id) { + $definition = $container->getDefinition($id); + $class = $definition->getClass(); + $reflection = $class !== null ? $container->getReflectionClass($class, false) : null; + $asCommand = $reflection?->getAttributes(AsCommand::class)[0] ?? null; + if ($asCommand !== null) { + $names = array_merge($names, explode('|', $asCommand->newInstance()->name)); + } + $attribute = $reflection?->getAttributes(HiddenAliases::class)[0] ?? null; + if ($attribute === null) { + continue; + } + foreach ($attribute->newInstance()->aliases as $alias) { + $definition->addTag('console.command', ['command' => $alias]); + $all[] = $alias; + } + } + if ($clashes = array_unique(array_merge(array_intersect($all, $names), array_diff_assoc($all, array_unique($all))))) { + throw new LogicException('Hidden aliases clash with other command names or aliases: ' . implode(', ', $clashes)); + } + $container->setParameter(self::PARAMETER, $all); + } +} diff --git a/legacy/tests/Console/HiddenAliasesPassTest.php b/legacy/tests/Console/HiddenAliasesPassTest.php new file mode 100644 index 00000000..63f278c5 --- /dev/null +++ b/legacy/tests/Console/HiddenAliasesPassTest.php @@ -0,0 +1,52 @@ +container([HiddenAliasesPassFooCommand::class]); + (new HiddenAliasesPass())->process($container); + $this->assertSame(['foo:old'], $container->getParameter(HiddenAliasesPass::PARAMETER)); + } + + public function testClash(): void + { + $container = $this->container([HiddenAliasesPassFooCommand::class, HiddenAliasesPassBarCommand::class]); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('foos'); + (new HiddenAliasesPass())->process($container); + } + + /** + * @param class-string[] $classes + */ + private function container(array $classes): ContainerBuilder + { + $container = new ContainerBuilder(); + foreach ($classes as $class) { + $container->register($class, $class)->addTag('console.command'); + } + + return $container; + } +} diff --git a/legacy/tests/Console/HiddenAliasesTest.php b/legacy/tests/Console/HiddenAliasesTest.php new file mode 100644 index 00000000..86f7dac3 --- /dev/null +++ b/legacy/tests/Console/HiddenAliasesTest.php @@ -0,0 +1,135 @@ +app = new Application(new Config([], __DIR__ . '/../data/mock-cli-config.yaml')); + $this->app->setIO(new ArrayInput([]), new NullOutput()); + } + + /** + * @return array + */ + public static function aliasProvider(): array + { + return [ + ['snapshots', 'backup:list'], + ['snapshot:list', 'backup:list'], + ['logs', 'environment:logs'], + ['user:role', 'user:get'], + ['environment:sql', 'db:sql'], + ]; + } + + #[DataProvider('aliasProvider')] + public function testFindHiddenAlias(string $alias, string $expected): void + { + $this->assertSame($expected, $this->app->find($alias)->getName()); + } + + public function testAllHiddenAliasesResolve(): void + { + $count = 0; + foreach ($this->app->all() as $name => $command) { + $command = $this->load($command); + if ($command->getName() !== $name || !$command instanceof CommandBase) { + continue; + } + foreach ($command->getHiddenAliases() as $alias) { + $this->assertSame($name, $this->app->find($alias)->getName(), "Hidden alias $alias"); + $count++; + } + } + $this->assertGreaterThan(0, $count); + } + + public function testHiddenAliasesAreNotVisible(): void + { + $command = $this->load($this->app->find('backup:list')); + $this->assertInstanceOf(CommandBase::class, $command); + $this->assertSame(['backups'], $command->getVisibleAliases()); + $this->assertSame(['snapshots', 'snapshot:list'], $command->getHiddenAliases()); + + $output = new BufferedOutput(); + (new CustomTextDescriptor('mock-cli'))->describe($output, $command); + $help = $output->fetch(); + $this->assertStringContainsString('Aliases: backups', $help); + $this->assertStringNotContainsString('snapshot', $help); + + (new CustomJsonDescriptor())->describe($output, $command); + $data = json_decode($output->fetch(), true); + $this->assertIsArray($data); + $this->assertSame(['backups'], $data['aliases']); + $this->assertSame(['snapshots', 'snapshot:list'], $data['hidden_aliases']); + } + + public function testHiddenAliasesDoNotDefineNamespaces(): void + { + $this->assertSame('db', $this->app->findDescribableNamespace('db')); + foreach (['snapshot', 'int', 'i'] as $name) { + $this->assertNull($this->app->findDescribableNamespace($name), $name); + } + } + + public function testHiddenAliasesAreNotAbbreviated(): void + { + $this->assertSame('db:dump', $this->app->find('sql-dump')->getName()); + $this->expectException(CommandNotFoundException::class); + $this->app->find('sql-dum'); + } + + public function testHasAndGetAgreeForHiddenAliases(): void + { + $this->assertTrue($this->app->has('snapshots')); + $this->assertSame('backup:list', $this->app->get('snapshots')->getName()); + } + + public function testDisabledCommands(): void + { + $config = Yaml::parseFile(__DIR__ . '/../data/mock-cli-config.yaml'); + $this->assertIsArray($config); + $this->assertIsArray($config['application']); + $config['application']['disabled_commands'] = ['completion', 'backup:list']; + $file = tempnam(sys_get_temp_dir(), 'cli-config-'); + $this->assertIsString($file); + file_put_contents($file, Yaml::dump($config)); + try { + $app = new Application(new Config([], $file)); + $app->setIO(new ArrayInput([]), new NullOutput()); + $this->assertFalse($app->has('completion')); + $this->assertFalse($app->has('backup:list')); + $this->assertFalse($app->has('snapshots')); + $this->assertTrue($app->has('backup:get')); + } finally { + unlink($file); + } + } + + private function load(Command $command): Command + { + return $command instanceof LazyCommand ? $command->getCommand() : $command; + } +}