From 736c3c7877d00680b3a9f3463dff11ae0acdb633 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 23:31:03 +0100 Subject: [PATCH 1/3] fix(legacy): make hidden command aliases resolve again Commands are lazy-loaded via the DI container, and Symfony's AddConsoleCommandPass only maps the name and aliases from the AsCommand attribute. Its setAliases() method call also runs after the constructor, so aliases added by setHiddenAliases() in configure() were overwritten. As a result, aliases such as `snapshots`, `logs`, `user:role` and `environment:sql` failed with "Command is not defined". Hidden aliases are now declared with a #[HiddenAliases] attribute. HiddenAliasesPass adds them as extra console.command tags before AddConsoleCommandPass runs, so they are in the lazy command map. CommandBase reads the attribute to keep them out of help, lists and command name completion. The JSON descriptor now has a `hidden_aliases` field, so a command index built from `list --format=json` can include them. A PHPStan stub types Command::getAliases() as string[], which removes several baseline entries. Co-Authored-By: Claude Opus 5.5 --- integration-tests/hidden_aliases_test.go | 38 ++++++++ legacy/phpstan-baseline.neon | 32 +------ legacy/phpstan-stubs/Command.stub | 11 +++ legacy/phpstan.neon | 1 + legacy/src/Application.php | 5 +- .../Command/Backup/BackupCreateCommand.php | 3 +- .../src/Command/Backup/BackupListCommand.php | 3 +- .../Command/Backup/BackupRestoreCommand.php | 3 +- legacy/src/Command/ClearCacheCommand.php | 8 +- legacy/src/Command/CommandBase.php | 24 ++--- legacy/src/Command/Db/DbDumpCommand.php | 3 +- legacy/src/Command/Db/DbSqlCommand.php | 3 +- .../Environment/EnvironmentDeleteCommand.php | 3 +- .../Environment/EnvironmentInfoCommand.php | 3 +- .../Environment/EnvironmentLogCommand.php | 3 +- .../IntegrationActivityListCommand.php | 3 +- .../OrganizationSubscriptionListCommand.php | 4 +- .../User/OrganizationUserListCommand.php | 5 +- .../Command/Project/ProjectInfoCommand.php | 3 +- .../Variable/ProjectVariableGetCommand.php | 3 +- legacy/src/Command/Route/RouteListCommand.php | 3 +- .../src/Command/Self/SelfInstallCommand.php | 3 +- legacy/src/Command/Self/SelfUpdateCommand.php | 3 +- legacy/src/Command/User/UserGetCommand.php | 3 +- legacy/src/Console/CustomJsonDescriptor.php | 2 + legacy/src/Console/CustomTextDescriptor.php | 3 +- legacy/src/Console/HiddenAliases.php | 19 ++++ legacy/src/Console/HiddenAliasesPass.php | 35 +++++++ legacy/tests/Console/HiddenAliasesTest.php | 91 +++++++++++++++++++ 29 files changed, 249 insertions(+), 74 deletions(-) create mode 100644 integration-tests/hidden_aliases_test.go create mode 100644 legacy/phpstan-stubs/Command.stub create mode 100644 legacy/src/Console/HiddenAliases.php create mode 100644 legacy/src/Console/HiddenAliasesPass.php create mode 100644 legacy/tests/Console/HiddenAliasesTest.php diff --git a/integration-tests/hidden_aliases_test.go b/integration-tests/hidden_aliases_test.go new file mode 100644 index 000000000..0a4dd1a8a --- /dev/null +++ b/integration-tests/hidden_aliases_test.go @@ -0,0 +1,38 @@ +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") +} diff --git a/legacy/phpstan-baseline.neon b/legacy/phpstan-baseline.neon index 246d95cc6..fe545a31d 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 000000000..07699953b --- /dev/null +++ b/legacy/phpstan-stubs/Command.stub @@ -0,0 +1,11 @@ +getMessage(), )); } + $this->container->addCompilerPass(new HiddenAliasesPass()); $this->container->addCompilerPass(new AddConsoleCommandPass()); $this->container->compile(); $dumper = new PhpDumper($this->container); @@ -231,7 +233,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 4d25e41fa..8b8ec5c40 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 cae818061..1d9744f26 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 ee7661926..0b4dd8d79 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 aa18ae229..1718ec99a 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 eb6bd33a3..dc901e018 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 c81f3ca11..9f27d9739 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 7c8f5b405..25357c898 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 f4ef2c63a..d78aabdef 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 54ce36a58..115c46db3 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 d6b8e5c78..f042ec304 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 10e0055d4..bdc3bf546 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 fe7bfda42..4a76f7e3f 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 9ef984762..2b3335050 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 e389a63af..df475f2f7 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 a48d73ca9..a186a97cb 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 31233e822..df55f1536 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 54ba36271..5d6905fbf 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 05f94401f..29b067adb 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 69d48f00e..4f1084b54 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 cde00b1af..b585e869a 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 000000000..ba424b924 --- /dev/null +++ b/legacy/src/Console/HiddenAliases.php @@ -0,0 +1,19 @@ +findTaggedServiceIds('console.command')) as $id) { + $definition = $container->getDefinition($id); + $class = $definition->getClass(); + $reflection = $class !== null ? $container->getReflectionClass($class, false) : null; + $attribute = $reflection?->getAttributes(HiddenAliases::class)[0] ?? null; + if ($attribute === null) { + continue; + } + foreach ($attribute->newInstance()->aliases as $alias) { + $definition->addTag('console.command', ['command' => $alias]); + } + } + } +} diff --git a/legacy/tests/Console/HiddenAliasesTest.php b/legacy/tests/Console/HiddenAliasesTest.php new file mode 100644 index 000000000..633ef1e9d --- /dev/null +++ b/legacy/tests/Console/HiddenAliasesTest.php @@ -0,0 +1,91 @@ +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']); + } + + private function load(Command $command): Command + { + return $command instanceof LazyCommand ? $command->getCommand() : $command; + } +} From d3b35a9b7c14c2b3d2001b83902d72f610527d32 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 00:06:02 +0100 Subject: [PATCH 2/3] fix(legacy): keep hidden aliases out of abbreviations and namespaces Registering hidden aliases in the lazy command map also made them define namespaces (`snapshot`, `int`, `i`) and match abbreviations. `upsun snapshot` printed an empty namespace listing instead of "Command is not defined", and hidden aliases appeared in suggestions. Hidden aliases now only work in full: - HiddenAliasesPass lists them in a container parameter. - HiddenAliasesCommandLoader leaves them out of getNames(), which Symfony uses for abbreviations and suggestions. - Application::addCommand() removes them from each command's aliases, so they do not define namespaces, and Application::find() resolves them by exact name. The Go list model now keeps the `hidden_aliases` field, so it is not dropped from `list --format=json`. Co-Authored-By: Claude Opus 5.5 --- commands/list_models.go | 17 ++++---- integration-tests/hidden_aliases_test.go | 21 +++++++++ legacy/src/Application.php | 43 ++++++++++++++++++- .../Console/HiddenAliasesCommandLoader.php | 40 +++++++++++++++++ legacy/src/Console/HiddenAliasesPass.php | 8 ++++ legacy/tests/Console/HiddenAliasesTest.php | 16 +++++++ 6 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 legacy/src/Console/HiddenAliasesCommandLoader.php diff --git a/commands/list_models.go b/commands/list_models.go index 8741e2511..690b249ef 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 index 0a4dd1a8a..f9fab83f0 100644 --- a/integration-tests/hidden_aliases_test.go +++ b/integration-tests/hidden_aliases_test.go @@ -35,4 +35,25 @@ func TestHiddenAliases(t *testing.T) { 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/src/Application.php b/legacy/src/Application.php index 2cdf81ce8..57f23cdee 100644 --- a/legacy/src/Application.php +++ b/legacy/src/Application.php @@ -11,6 +11,7 @@ use Platformsh\Cli\Command\WelcomeCommand; use Platformsh\Cli\Command\MultiAwareInterface; use Platformsh\Cli\Console\EventSubscriber; +use Platformsh\Cli\Console\HiddenAliasesCommandLoader; use Platformsh\Cli\Console\HiddenAliasesPass; use Platformsh\Cli\Console\HiddenInputOption; use Platformsh\Cli\Service\Config; @@ -55,6 +56,11 @@ class Application extends ParentApplication private bool $runningViaMulti = false; + /** @var string[] */ + private array $hiddenAliases = []; + + private ?CommandLoaderInterface $commandLoader = null; + public function __construct(?Config $config = null) { // Initialize configuration (from config.yaml). @@ -76,7 +82,11 @@ public function __construct(?Config $config = null) // services tagged with "console.command"). /** @var CommandLoaderInterface $loader */ $loader = $this->container()->get('console.command_loader'); - $this->setCommandLoader($loader); + /** @var string[] $hiddenAliases */ + $hiddenAliases = $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()); @@ -122,6 +132,37 @@ public function add(ConsoleCommand $command): ?ConsoleCommand return parent::add($command); } + /** + * {@inheritdoc} + * + * Hidden aliases are removed, so that they are not used for abbreviations + * or namespaces. + * + * @see self::find() + */ + public function addCommand(callable|ConsoleCommand $command): ?ConsoleCommand + { + if ($command instanceof ConsoleCommand && array_intersect($command->getAliases(), $this->hiddenAliases)) { + $command->setAliases(array_values(array_diff($command->getAliases(), $this->hiddenAliases))); + } + + return parent::addCommand($command); + } + + /** + * {@inheritdoc} + * + * Finds commands by their hidden aliases, which only work in full. + */ + public function find(string $name): ConsoleCommand + { + if (in_array($name, $this->hiddenAliases, true) && $this->commandLoader?->has($name)) { + return $this->get($this->commandLoader->get($name)->getName() ?? $name); + } + + return parent::find($name); + } + /** * Returns the Dependency Injection Container for the whole application. * diff --git a/legacy/src/Console/HiddenAliasesCommandLoader.php b/legacy/src/Console/HiddenAliasesCommandLoader.php new file mode 100644 index 000000000..633d93799 --- /dev/null +++ b/legacy/src/Console/HiddenAliasesCommandLoader.php @@ -0,0 +1,40 @@ +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 index 1c123639c..a2ae11383 100644 --- a/legacy/src/Console/HiddenAliasesPass.php +++ b/legacy/src/Console/HiddenAliasesPass.php @@ -10,6 +10,9 @@ /** * Registers hidden aliases so that lazily-loaded commands can be found by them. * + * It also lists all hidden aliases in a container parameter, for + * HiddenAliasesCommandLoader. + * * This must run before Symfony's AddConsoleCommandPass, which reads extra * "console.command" tags as aliases. * @@ -17,8 +20,11 @@ */ final class HiddenAliasesPass implements CompilerPassInterface { + public const PARAMETER = 'cli.hidden_aliases'; + public function process(ContainerBuilder $container): void { + $all = []; foreach (array_keys($container->findTaggedServiceIds('console.command')) as $id) { $definition = $container->getDefinition($id); $class = $definition->getClass(); @@ -29,7 +35,9 @@ public function process(ContainerBuilder $container): void } foreach ($attribute->newInstance()->aliases as $alias) { $definition->addTag('console.command', ['command' => $alias]); + $all[] = $alias; } } + $container->setParameter(self::PARAMETER, $all); } } diff --git a/legacy/tests/Console/HiddenAliasesTest.php b/legacy/tests/Console/HiddenAliasesTest.php index 633ef1e9d..0e4dd6b7c 100644 --- a/legacy/tests/Console/HiddenAliasesTest.php +++ b/legacy/tests/Console/HiddenAliasesTest.php @@ -13,6 +13,7 @@ use Platformsh\Cli\Service\Config; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\LazyCommand; +use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\NullOutput; @@ -84,6 +85,21 @@ public function testHiddenAliasesAreNotVisible(): void $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'); + } + private function load(Command $command): Command { return $command instanceof LazyCommand ? $command->getCommand() : $command; From 48ccd7320d724126c5b7368c9b4478170b8c4388 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 00:20:03 +0100 Subject: [PATCH 3/3] fix(legacy): apply disabled commands and hidden aliases in addCommand() Symfony 7.4 registers commands via addCommand(). Overriding both add() and addCommand() made it skip add() for default commands too, so the disabled_commands check never ran. The check now lives in addCommand(), and add() is no longer overridden. Hidden aliases are resolved in get() instead of find(), so has() and get() agree for them. HiddenAliasesPass fails the build when a hidden alias clashes with a command name, an alias, or another hidden alias. The application falls back to no hidden aliases when a stale container cache lacks the parameter. Co-Authored-By: Claude Opus 5.5 --- legacy/src/Application.php | 37 ++++++------- legacy/src/Console/HiddenAliasesPass.php | 10 ++++ .../tests/Console/HiddenAliasesPassTest.php | 52 +++++++++++++++++++ legacy/tests/Console/HiddenAliasesTest.php | 28 ++++++++++ 4 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 legacy/tests/Console/HiddenAliasesPassTest.php diff --git a/legacy/src/Application.php b/legacy/src/Application.php index 57f23cdee..382eeabff 100644 --- a/legacy/src/Application.php +++ b/legacy/src/Application.php @@ -83,7 +83,9 @@ public function __construct(?Config $config = null) /** @var CommandLoaderInterface $loader */ $loader = $this->container()->get('console.command_loader'); /** @var string[] $hiddenAliases */ - $hiddenAliases = $this->container()->getParameter(HiddenAliasesPass::PARAMETER); + $hiddenAliases = $this->container()->hasParameter(HiddenAliasesPass::PARAMETER) + ? $this->container()->getParameter(HiddenAliasesPass::PARAMETER) + : []; $this->hiddenAliases = $hiddenAliases; $this->commandLoader = new HiddenAliasesCommandLoader($loader, $hiddenAliases); $this->setCommandLoader($this->commandLoader); @@ -121,29 +123,22 @@ public static function warmCaches(): void * {@inheritdoc} * * Prevent commands being enabled, according to config.yaml configuration. - */ - public function add(ConsoleCommand $command): ?ConsoleCommand - { - if (!$this->config->isCommandEnabled($command->getName())) { - $command->setApplication(null); - return null; - } - - return parent::add($command); - } - - /** - * {@inheritdoc} * * Hidden aliases are removed, so that they are not used for abbreviations * or namespaces. * - * @see self::find() + * @see self::get() */ public function addCommand(callable|ConsoleCommand $command): ?ConsoleCommand { - if ($command instanceof ConsoleCommand && array_intersect($command->getAliases(), $this->hiddenAliases)) { - $command->setAliases(array_values(array_diff($command->getAliases(), $this->hiddenAliases))); + 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); @@ -152,15 +147,15 @@ public function addCommand(callable|ConsoleCommand $command): ?ConsoleCommand /** * {@inheritdoc} * - * Finds commands by their hidden aliases, which only work in full. + * Gets commands by their hidden aliases, which only work in full. */ - public function find(string $name): ConsoleCommand + public function get(string $name): ConsoleCommand { if (in_array($name, $this->hiddenAliases, true) && $this->commandLoader?->has($name)) { - return $this->get($this->commandLoader->get($name)->getName() ?? $name); + $name = $this->commandLoader->get($name)->getName() ?? $name; } - return parent::find($name); + return parent::get($name); } /** diff --git a/legacy/src/Console/HiddenAliasesPass.php b/legacy/src/Console/HiddenAliasesPass.php index a2ae11383..cf17c5418 100644 --- a/legacy/src/Console/HiddenAliasesPass.php +++ b/legacy/src/Console/HiddenAliasesPass.php @@ -4,8 +4,10 @@ namespace Platformsh\Cli\Console; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; /** * Registers hidden aliases so that lazily-loaded commands can be found by them. @@ -25,10 +27,15 @@ final class HiddenAliasesPass implements CompilerPassInterface public function process(ContainerBuilder $container): void { $all = []; + $names = []; foreach (array_keys($container->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; @@ -38,6 +45,9 @@ public function process(ContainerBuilder $container): void $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 000000000..63f278c58 --- /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 index 0e4dd6b7c..86f7dac32 100644 --- a/legacy/tests/Console/HiddenAliasesTest.php +++ b/legacy/tests/Console/HiddenAliasesTest.php @@ -17,6 +17,7 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\NullOutput; +use Symfony\Component\Yaml\Yaml; class HiddenAliasesTest extends TestCase { @@ -100,6 +101,33 @@ public function testHiddenAliasesAreNotAbbreviated(): void $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;