From dfef735c896758ebdee27060139a7c620c7dfa0c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 11:38:10 +0100 Subject: [PATCH 01/11] Make ReadEnvVarCommand invokable --- .../src/Command/Config/ReadEnvVarCommand.php | 31 +++++++++---------- .../Command/Config/ReadEnvVarCommandTest.php | 4 +-- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/module/CLI/src/Command/Config/ReadEnvVarCommand.php b/module/CLI/src/Command/Config/ReadEnvVarCommand.php index e1cef3fd..e3a38be6 100644 --- a/module/CLI/src/Command/Config/ReadEnvVarCommand.php +++ b/module/CLI/src/Command/Config/ReadEnvVarCommand.php @@ -6,9 +6,10 @@ namespace Shlinkio\Shlink\CLI\Command\Config; use Closure; use Shlinkio\Shlink\Core\Config\EnvVars; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -18,6 +19,11 @@ use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function Shlinkio\Shlink\Core\enumValues; use function sprintf; +#[AsCommand( + name: ReadEnvVarCommand::NAME, + description: 'Display current value for an env var', + hidden: true, +)] class ReadEnvVarCommand extends Command { public const string NAME = 'env-var:read'; @@ -31,19 +37,10 @@ class ReadEnvVarCommand extends Command parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setHidden() - ->setDescription('Display current value for an env var') - ->addArgument('envVar', InputArgument::REQUIRED, 'The env var to read'); - } - protected function interact(InputInterface $input, OutputInterface $output): void { $io = new SymfonyStyle($input, $output); - $envVar = $input->getArgument('envVar'); + $envVar = $input->getArgument('env-var'); $validEnvVars = enumValues(EnvVars::class); if ($envVar === null) { @@ -54,14 +51,14 @@ class ReadEnvVarCommand extends Command throw new InvalidArgumentException(sprintf('%s is not a valid Shlink environment variable', $envVar)); } - $input->setArgument('envVar', $envVar); + $input->setArgument('env-var', $envVar); } - protected function execute(InputInterface $input, OutputInterface $output): int - { - $envVar = $input->getArgument('envVar'); - $output->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar))); - + public function __invoke( + SymfonyStyle $io, + #[Argument(description: 'The env var to read')] string $envVar, + ): int { + $io->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar))); return Command::SUCCESS; } } diff --git a/module/CLI/test/Command/Config/ReadEnvVarCommandTest.php b/module/CLI/test/Command/Config/ReadEnvVarCommandTest.php index c377cf86..e90f94af 100644 --- a/module/CLI/test/Command/Config/ReadEnvVarCommandTest.php +++ b/module/CLI/test/Command/Config/ReadEnvVarCommandTest.php @@ -28,13 +28,13 @@ class ReadEnvVarCommandTest extends TestCase $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('foo is not a valid Shlink environment variable'); - $this->commandTester->execute(['envVar' => 'foo']); + $this->commandTester->execute(['env-var' => 'foo']); } #[Test] public function valueIsPrintedIfProvidedEnvVarIsValid(): void { - $this->commandTester->execute(['envVar' => EnvVars::BASE_PATH->value]); + $this->commandTester->execute(['env-var' => EnvVars::BASE_PATH->value]); $output = $this->commandTester->getDisplay(); self::assertStringNotContainsString('Select the env var to read', $output); From 2d83b8d04610ee057714855b143184c3f0cc868a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 11:41:50 +0100 Subject: [PATCH 02/11] Make InitialApiKeyCommand invokable --- .../src/Command/Api/InitialApiKeyCommand.php | 32 ++++++++----------- .../Command/Api/InitialApiKeyCommandTest.php | 2 +- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/module/CLI/src/Command/Api/InitialApiKeyCommand.php b/module/CLI/src/Command/Api/InitialApiKeyCommand.php index 66968eb3..4c6698f1 100644 --- a/module/CLI/src/Command/Api/InitialApiKeyCommand.php +++ b/module/CLI/src/Command/Api/InitialApiKeyCommand.php @@ -5,11 +5,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Api; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +#[AsCommand( + name: InitialApiKeyCommand::NAME, + description: 'Tries to create initial API key' +)] class InitialApiKeyCommand extends Command { public const string NAME = 'api-key:initial'; @@ -19,22 +23,14 @@ class InitialApiKeyCommand extends Command parent::__construct(); } - protected function configure(): void - { - $this - ->setHidden() - ->setName(self::NAME) - ->setDescription('Tries to create initial API key') - ->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create'); - } + public function __invoke( + SymfonyStyle $io, + #[Argument('The initial API to create')] string $apiKey + ): int { + $result = $this->apiKeyService->createInitial($apiKey); - protected function execute(InputInterface $input, OutputInterface $output): int - { - $key = $input->getArgument('apiKey'); - $result = $this->apiKeyService->createInitial($key); - - if ($result === null && $output->isVerbose()) { - $output->writeln('Other API keys already exist. Initial API key creation skipped.'); + if ($result === null && $io->isVerbose()) { + $io->writeln('Other API keys already exist. Initial API key creation skipped.'); } return Command::SUCCESS; diff --git a/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php b/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php index e86cf0e5..b2311613 100644 --- a/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php +++ b/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php @@ -35,7 +35,7 @@ class InitialApiKeyCommandTest extends TestCase $this->apiKeyService->expects($this->once())->method('createInitial')->with('the_key')->willReturn($result); $this->commandTester->execute( - ['apiKey' => 'the_key'], + ['api-key' => 'the_key'], ['verbosity' => $verbose ? OutputInterface::VERBOSITY_VERBOSE : OutputInterface::VERBOSITY_NORMAL], ); $output = $this->commandTester->getDisplay(); From e7f4b84c6525ce53ce5729f91be86843efe0f5e6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 11:45:27 +0100 Subject: [PATCH 03/11] Make DomainRedirectsCommand invokable --- .../Command/Domain/DomainRedirectsCommand.php | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index 4c2e4350..1e272c12 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -7,8 +7,9 @@ namespace Shlinkio\Shlink\CLI\Command\Domain; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -18,6 +19,10 @@ use function array_map; use function sprintf; use function str_contains; +#[AsCommand( + name: DomainRedirectsCommand::NAME, + description: 'Set specific "not found" redirects for individual domains.', +)] class DomainRedirectsCommand extends Command { public const string NAME = 'domain:redirects'; @@ -27,18 +32,6 @@ class DomainRedirectsCommand extends Command parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Set specific "not found" redirects for individual domains.') - ->addArgument( - 'domain', - InputArgument::REQUIRED, - 'The domain authority to which you want to set the specific redirects', - ); - } - protected function interact(InputInterface $input, OutputInterface $output): void { /** @var string|null $domain */ @@ -67,10 +60,11 @@ class DomainRedirectsCommand extends Command $input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption); } - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $domainAuthority = $input->getArgument('domain'); + public function __invoke( + SymfonyStyle $io, + #[Argument('The domain authority to which you want to set the specific redirects', name: 'domain')] + string $domainAuthority, + ): int { $domain = $this->domainService->findByAuthority($domainAuthority); $ask = static function (string $message, string|null $current) use ($io): string|null { From 2142afae897d04c941e6cb39d37b2de98100d070 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 11:50:43 +0100 Subject: [PATCH 04/11] Make ListDomainsCommand invokable --- .../src/Command/Domain/ListDomainsCommand.php | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 935d272e..a66d6d7e 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -8,13 +8,17 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use function array_map; +#[AsCommand( + name: ListDomainsCommand::NAME, + description: 'List all domains that have been ever used for some short URL', +)] class ListDomainsCommand extends Command { public const string NAME = 'domain:list'; @@ -24,25 +28,17 @@ class ListDomainsCommand extends Command parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('List all domains that have been ever used for some short URL') - ->addOption( - 'show-redirects', - 'r', - InputOption::VALUE_NONE, - 'Will display an extra column with the information of the "not found" redirects for every domain.', - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { + public function __invoke( + SymfonyStyle $io, + #[Option( + 'Will display an extra column with the information of the "not found" redirects for every domain.', + shortcut: 'r', + )] + bool $showRedirects = false, + ): int { $domains = $this->domainService->listDomains(); - $showRedirects = $input->getOption('show-redirects'); $commonFields = ['Domain', 'Is default']; - $table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output); + $table = $showRedirects ? ShlinkTable::withRowSeparators($io) : ShlinkTable::default($io); $table->render( $showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields, @@ -53,7 +49,7 @@ class ListDomainsCommand extends Command ? [ ...$commonValues, $this->notFoundRedirectsToString($domain->notFoundRedirectConfig), - ] + ] : $commonValues; }, $domains), ); From 0fe28a5eb584b45b32f38a581510b421868d8173 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 11:56:40 +0100 Subject: [PATCH 05/11] Make MatomoSendVisitsCommand invokable --- .../Integration/MatomoSendVisitsCommand.php | 75 ++++++++----------- 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php index c1c22075..da9c6562 100644 --- a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php +++ b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php @@ -8,10 +8,10 @@ use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface; use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; @@ -19,22 +19,9 @@ use function Shlinkio\Shlink\Common\buildDateRange; use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly; use function sprintf; -class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface -{ - public const string NAME = 'integration:matomo:send-visits'; - - private readonly bool $matomoEnabled; - private SymfonyStyle $io; - - public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender) - { - $this->matomoEnabled = $matomoOptions->enabled; - parent::__construct(); - } - - protected function configure(): void - { - $help = <<%command.name% --since 2022-01-01 --until 2022-12-31 - HELP; + HELP +)] +class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface +{ + public const string NAME = 'integration:matomo:send-visits'; - $this - ->setName(self::NAME) - ->setDescription(sprintf( - '%sSend existing visits to the configured matomo instance', - $this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ', - )) - ->setHelp($help) - ->addOption( - 'since', - 's', - InputOption::VALUE_REQUIRED, - 'Only visits created since this date, inclusively, will be sent to Matomo', - ) - ->addOption( - 'until', - 'u', - InputOption::VALUE_REQUIRED, - 'Only visits created until this date, inclusively, will be sent to Matomo', - ); + private readonly bool $matomoEnabled; + private SymfonyStyle $io; + + public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender) + { + $this->matomoEnabled = $matomoOptions->enabled; + parent::__construct(); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function configure(): void { - $this->io = new SymfonyStyle($input, $output); + $this->setDescription(sprintf( + '%sSend existing visits to the configured matomo instance', + $this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ', + )); + } + + public function __invoke( + SymfonyStyle $io, + InputInterface $input, + #[Option('Only visits created since this date, inclusively, will be sent to Matomo', shortcut: 's')] + string|null $since = null, + #[Option('Only visits created until this date, inclusively, will be sent to Matomo', shortcut: 'u')] + string|null $until = null, + ): int { + $this->io = $io; if (! $this->matomoEnabled) { $this->io->warning('Matomo integration is not enabled in this Shlink instance'); @@ -87,8 +80,6 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra } // TODO Validate provided date formats - $since = $input->getOption('since'); - $until = $input->getOption('until'); $dateRange = buildDateRange( startDate: $since !== null ? Chronos::parse($since) : null, endDate: $until !== null ? Chronos::parse($until) : null, From 9ee709f0f342305a4a816be51bebb40848cfbb12 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 12:18:29 +0100 Subject: [PATCH 06/11] Make DeleteExpiredShortUrlsCommand invokable --- .../DeleteExpiredShortUrlsCommand.php | 46 ++++++++----------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php index 2b2abd01..626ac136 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php @@ -6,14 +6,18 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; +#[AsCommand( + name: DeleteExpiredShortUrlsCommand::NAME, + description: 'Deletes all short URLs that are considered expired, because they have a validUntil date in the past', +)] class DeleteExpiredShortUrlsCommand extends Command { public const string NAME = 'short-url:delete-expired'; @@ -23,32 +27,17 @@ class DeleteExpiredShortUrlsCommand extends Command parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription( - 'Deletes all short URLs that are considered expired, because they have a validUntil date in the past', - ) - ->addOption( - 'evaluate-max-visits', - mode: InputOption::VALUE_NONE, - description: 'Also take into consideration short URLs which have reached their max amount of visits.', - ) - ->addOption('force', 'f', InputOption::VALUE_NONE, 'Delete short URLs with no confirmation') - ->addOption( - 'dry-run', - mode: InputOption::VALUE_NONE, - description: 'Delete short URLs with no confirmation', - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $force = $input->getOption('force') || ! $input->isInteractive(); - $dryRun = $input->getOption('dry-run'); - $conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $input->getOption('evaluate-max-visits')); + public function __invoke( + SymfonyStyle $io, + InputInterface $input, + #[Option('Also take into consideration short URLs which have reached their max amount of visits.')] + bool $evaluateMaxVisits = false, + #[Option('Delete short URLs with no confirmation', shortcut: 'f')] bool $force = false, + #[Option('Only check how many short URLs would be affected, without actually deleting them')] + bool $dryRun = false, + ): int { + $conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $evaluateMaxVisits); + $force = $force || ! $input->isInteractive(); if (! $force && ! $dryRun) { $io->warning([ @@ -69,6 +58,7 @@ class DeleteExpiredShortUrlsCommand extends Command $result = $this->deleteShortUrlService->deleteExpiredShortUrls($conditions); $io->success(sprintf('%s expired short URLs have been deleted', $result)); + return self::SUCCESS; } } From 10173d2ab8512e6ea8670f03fcad9239a53ff409 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 12:24:18 +0100 Subject: [PATCH 07/11] Make DeleteTagsCommand invokable --- .../CLI/src/Command/Tag/DeleteTagsCommand.php | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index 2022a9dc..a879fb4d 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +#[AsCommand(name: DeleteTagsCommand::NAME, description: 'Deletes one or more tags.')] class DeleteTagsCommand extends Command { public const string NAME = 'tag:delete'; @@ -20,24 +20,13 @@ class DeleteTagsCommand extends Command parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Deletes one or more tags.') - ->addOption( - 'name', - 't', - InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'The name of the tags to delete', - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $tagNames = $input->getOption('name'); - + /** + * @param string[] $tags + */ + public function __invoke( + SymfonyStyle $io, + #[Option('The name of the tags to delete', name: 'name', shortcut: 't')] array $tagNames = [] + ): int { if (empty($tagNames)) { $io->warning('You have to provide at least one tag name'); return self::INVALID; @@ -45,6 +34,7 @@ class DeleteTagsCommand extends Command $this->tagService->deleteTags($tagNames); $io->success('Tags properly deleted'); + return self::SUCCESS; } } From 506ed475313a335add6d87a2104c9eb06c85526a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 12:25:52 +0100 Subject: [PATCH 08/11] Make ListTagsCommand invokable --- module/CLI/src/Command/Tag/ListTagsCommand.php | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index abd9a0dd..66497737 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -8,12 +8,13 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use function array_map; +#[AsCommand(ListTagsCommand::NAME, 'Lists existing tags.')] class ListTagsCommand extends Command { public const string NAME = 'tag:list'; @@ -23,16 +24,9 @@ class ListTagsCommand extends Command parent::__construct(); } - protected function configure(): void + public function __invoke(SymfonyStyle $io): int { - $this - ->setName(self::NAME) - ->setDescription('Lists existing tags.'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows()); + ShlinkTable::default($io)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows()); return self::SUCCESS; } From 6113c287682eaf217213a62f5f8ba85dda750772 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 12:28:04 +0100 Subject: [PATCH 09/11] Make RenameTagCommand invokable --- .../CLI/src/Command/Tag/RenameTagCommand.php | 26 ++++++------------- .../test/Command/Tag/RenameTagCommandTest.php | 8 +++--- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index 2ae0159c..f9e53f28 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -8,12 +8,12 @@ use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +#[AsCommand(RenameTagCommand::NAME, 'Renames one existing tag.')] class RenameTagCommand extends Command { public const string NAME = 'tag:rename'; @@ -23,21 +23,11 @@ class RenameTagCommand extends Command parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Renames one existing tag.') - ->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the tag.') - ->addArgument('newName', InputArgument::REQUIRED, 'New name of the tag.'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $oldName = $input->getArgument('oldName'); - $newName = $input->getArgument('newName'); - + public function __invoke( + SymfonyStyle $io, + #[Argument('Current name of the tag.')] string $oldName, + #[Argument('New name of the tag.')] string $newName, + ): int { try { $this->tagService->renameTag(Renaming::fromNames($oldName, $newName)); $io->success('Tag properly renamed.'); diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index e7fb630d..8681239a 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -36,8 +36,8 @@ class RenameTagCommandTest extends TestCase )->willThrowException(TagNotFoundException::fromTag('foo')); $this->commandTester->execute([ - 'oldName' => $oldName, - 'newName' => $newName, + 'old-name' => $oldName, + 'new-name' => $newName, ]); $output = $this->commandTester->getDisplay(); @@ -54,8 +54,8 @@ class RenameTagCommandTest extends TestCase )->willReturn(new Tag($newName)); $this->commandTester->execute([ - 'oldName' => $oldName, - 'newName' => $newName, + 'old-name' => $oldName, + 'new-name' => $newName, ]); $output = $this->commandTester->getDisplay(); From b69db91378427f6649310ca4bcafa6522362d03c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 12:30:15 +0100 Subject: [PATCH 10/11] Make DownloadGeoLiteDbCommand invokable --- .../Visit/DownloadGeoLiteDbCommand.php | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index 4d58a7d3..f76a4dbc 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -8,14 +8,17 @@ use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdaterInterface; use Shlinkio\Shlink\Core\Geolocation\GeolocationDownloadProgressHandlerInterface; use Shlinkio\Shlink\Core\Geolocation\GeolocationResult; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\ProgressBar; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; +#[AsCommand( + DownloadGeoLiteDbCommand::NAME, + 'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date copy if so.', +)] class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface { public const string NAME = 'visit:download-db'; @@ -28,19 +31,9 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro parent::__construct(); } - protected function configure(): void + public function __invoke(SymfonyStyle $io): int { - $this - ->setName(self::NAME) - ->setDescription( - 'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date ' - . 'copy if so.', - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $this->io = new SymfonyStyle($input, $output); + $this->io = $io; try { $result = $this->dbUpdater->checkDbUpdate($this); From 058c0ebfaff77003a4e72b13c9c34ba0d82919d4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 12:31:06 +0100 Subject: [PATCH 11/11] Update changelog --- CHANGELOG.md | 2 +- module/CLI/src/Command/Api/InitialApiKeyCommand.php | 4 ++-- .../CLI/src/Command/Integration/MatomoSendVisitsCommand.php | 2 +- module/CLI/src/Command/Tag/DeleteTagsCommand.php | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0402f290..bf87c663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2472](https://github.com/shlinkio/shlink/issues/2472) Add support for PHP 8.5 ### Changed -* *Nothing* +* [#2424](https://github.com/shlinkio/shlink/issues/2424) Make simple console commands invokable. ### Deprecated * *Nothing* diff --git a/module/CLI/src/Command/Api/InitialApiKeyCommand.php b/module/CLI/src/Command/Api/InitialApiKeyCommand.php index 4c6698f1..680135d8 100644 --- a/module/CLI/src/Command/Api/InitialApiKeyCommand.php +++ b/module/CLI/src/Command/Api/InitialApiKeyCommand.php @@ -12,7 +12,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand( name: InitialApiKeyCommand::NAME, - description: 'Tries to create initial API key' + description: 'Tries to create initial API key', )] class InitialApiKeyCommand extends Command { @@ -25,7 +25,7 @@ class InitialApiKeyCommand extends Command public function __invoke( SymfonyStyle $io, - #[Argument('The initial API to create')] string $apiKey + #[Argument('The initial API to create')] string $apiKey, ): int { $result = $this->apiKeyService->createInitial($apiKey); diff --git a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php index da9c6562..f5d8e84c 100644 --- a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php +++ b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php @@ -41,7 +41,7 @@ use function sprintf; Send all visits created during 2022: %command.name% --since 2022-01-01 --until 2022-12-31 - HELP + HELP, )] class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface { diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index a879fb4d..301cba26 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -21,11 +21,11 @@ class DeleteTagsCommand extends Command } /** - * @param string[] $tags + * @param string[] $tagNames */ public function __invoke( SymfonyStyle $io, - #[Option('The name of the tags to delete', name: 'name', shortcut: 't')] array $tagNames = [] + #[Option('The name of the tags to delete', name: 'name', shortcut: 't')] array $tagNames = [], ): int { if (empty($tagNames)) { $io->warning('You have to provide at least one tag name');