Merge pull request #2509 from acelaya-forks/invokable-commands

Invokable commands
This commit is contained in:
Alejandro Celaya
2025-11-01 12:37:04 +01:00
committed by GitHub
14 changed files with 146 additions and 215 deletions

View File

@@ -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*

View File

@@ -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('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
if ($result === null && $io->isVerbose()) {
$io->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
}
return Command::SUCCESS;

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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),
);

View File

@@ -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 = <<<HELP
#[AsCommand(
name: MatomoSendVisitsCommand::NAME,
help: <<<HELP
This command allows you to send existing visits from this Shlink instance to the configured Matomo server.
Its intention is to allow you to configure Matomo at some point in time, and still have your whole visits
@@ -54,32 +41,38 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
Send all visits created during 2022:
<info>%command.name% --since 2022-01-01 --until 2022-12-31</info>
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 ? '' : '<comment>[MATOMO INTEGRATION DISABLED]</comment> ',
));
}
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,

View File

@@ -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;
}
}

View File

@@ -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[] $tagNames
*/
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;
}
}

View File

@@ -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;
}

View File

@@ -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.');

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();