diff --git a/.tools/psalm/baseline.xml b/.tools/psalm/baseline.xml index 2a21c2b792..f0a9eeac41 100644 --- a/.tools/psalm/baseline.xml +++ b/.tools/psalm/baseline.xml @@ -1604,9 +1604,6 @@ - - - @@ -1676,7 +1673,7 @@ - getArgument('table')]]> + @@ -1770,11 +1767,6 @@ - - - - - diff --git a/rector.php b/rector.php index 82c7528094..0765c5b47c 100644 --- a/rector.php +++ b/rector.php @@ -416,8 +416,6 @@ new MethodCallRename(Addon\Addon::class, 'getInstalledPackages', 'getInstalledAddons'), new MethodCallRename(Addon\Addon::class, 'getAvailablePackages', 'getAvailableAddons'), new MethodCallRename(Addon\Addon::class, 'getSetupPackages', 'getSetupAddons'), - new MethodCallRename(Console\Command\AbstractCommand::class, 'getPackage', 'getAddon'), - new MethodCallRename(Console\Command\AbstractCommand::class, 'setPackage', 'setAddon'), new MethodCallRename(ApiFunction\Result::class, 'toJSON', 'toJson'), new MethodCallRename(ApiFunction\Result::class, 'fromJSON', 'fromJson'), @@ -524,6 +522,8 @@ new MethodCallToPropertyFetch(ApiFunction\Result::class, 'getMessage', 'message'), new MethodCallToPropertyFetch(ApiFunction\Result::class, 'requiresReboot', 'requiresReboot'), + new MethodCallToPropertyFetch(Console\Command\AbstractCommand::class, 'getPackage', 'addon'), + new MethodCallToPropertyFetch(Cronjob\Type\AbstractType::class, 'getMessage', 'message'), new MethodCallToPropertyFetch(Cronjob\Type\AbstractType::class, 'hasMessage', 'message'), new MethodCallToPropertyFetch(Cronjob\CronjobExecutor::class, 'getMessage', 'message'), diff --git a/schemas/package.json b/schemas/package.json index 7f5127fdff..558380f7f7 100644 --- a/schemas/package.json +++ b/schemas/package.json @@ -23,13 +23,6 @@ "default_config": { "description": "Default values for Redaxo\\Core\\Config", "type": "object" - }, - "console_commands": { - "description": "Console command names and their corresponding class", - "type": "object", - "additionalProperties": { - "type": "string" - } } }, "additionalProperties": true, diff --git a/src/Console/Application.php b/src/Console/Application.php index 64f8be8d9d..866c9802fb 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -18,6 +18,7 @@ use Redaxo\Core\Filesystem\Path; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Command\LazyCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -68,6 +69,11 @@ public function doRun(InputInterface $input, OutputInterface $output): int #[Override] protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output): int { + // Unwrap the LazyCommand returned by the CommandLoader to get the actual command instance. + if ($command instanceof LazyCommand) { + $command = $command->getCommand(); + } + if ($command instanceof AbstractCommand) { $this->loadPackages($command); } @@ -105,7 +111,7 @@ private function loadPackages(AbstractCommand $command): void if ('ydeploy:migrate' === $command->getName()) { // boot only the ydeploy package, which provides the migrate command - $command->getAddon()->boot(); + $command->addon->boot(); return; } diff --git a/src/Console/Command/AbstractCommand.php b/src/Console/Command/AbstractCommand.php index c5836dc829..44be940d3c 100644 --- a/src/Console/Command/AbstractCommand.php +++ b/src/Console/Command/AbstractCommand.php @@ -3,32 +3,25 @@ namespace Redaxo\Core\Console\Command; use Redaxo\Core\Addon\Addon; +use Redaxo\Core\Exception\LogicException; +use ReflectionObject; 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 sprintf; +use function str_starts_with; + +use const DIRECTORY_SEPARATOR; use const ENT_QUOTES; abstract class AbstractCommand extends Command { - protected ?Addon $addon = null; - - /** @internal */ - public function setAddon(?Addon $addon = null): void - { - $this->addon = $addon; - } - - /** @return Addon|null In core commands it returns `null`, otherwise the corresponding addon object */ - public function getAddon(): ?Addon - { - return $this->addon; - } - - protected function getStyle(InputInterface $input, OutputInterface $output): SymfonyStyle - { - return new SymfonyStyle($input, $output); + /** + * The addon the command belongs to, resolved lazily from the location of the command class. + * + * Only available for addon commands; reading it on a core command throws a {@see LogicException}. + */ + public private(set) Addon $addon { + get => $this->addon ??= $this->resolveAddon(); } /** @@ -45,4 +38,21 @@ protected function decodeMessage(string $message): string return htmlspecialchars_decode($message, ENT_QUOTES); } + + private function resolveAddon(): Addon + { + $file = new ReflectionObject($this)->getFileName(); + + if (false !== $file) { + $file = realpath($file) ?: $file; + + foreach (Addon::getAvailableAddons() as $addon) { + if (str_starts_with($file, $addon->path . DIRECTORY_SEPARATOR)) { + return $addon; + } + } + } + + throw new LogicException(sprintf('Command "%s" does not belong to an addon.', $this::class)); + } } diff --git a/src/Console/Command/AddonActivateCommand.php b/src/Console/Command/AddonActivateCommand.php index f350846615..d04fc049d7 100644 --- a/src/Console/Command/AddonActivateCommand.php +++ b/src/Console/Command/AddonActivateCommand.php @@ -2,53 +2,32 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; +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; /** * @internal */ -class AddonActivateCommand extends AbstractCommand +#[AsCommand(name: 'addon:activate', description: 'Activates the selected addon')] +final class AddonActivateCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Activates the selected addon') - ->addArgument('addon-id', InputArgument::REQUIRED, 'The id of the addon, e.g. "yform"', null, static function () { - $packageNames = []; - - foreach (Addon::getRegisteredAddons() as $package) { - if ($package->isAvailable()) { - continue; - } - - $packageNames[] = $package->name; - } - - return $packageNames; - }); - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - - $packageId = $input->getArgument('addon-id'); - + public function __invoke( + SymfonyStyle $io, + #[Argument('The id of the addon, e.g. "yform"', suggestedValues: static function (): array { + return array_keys(array_filter(Addon::getRegisteredAddons(), static fn (Addon $addon): bool => !$addon->isAvailable())); + })] string $addon, + ): int { // the package manager don't know new packages in the addon folder // so we need to make them available AddonManager::synchronizeWithFileSystem(); - $package = Addon::get($packageId); + $package = Addon::get($addon); if (!$package) { - $io->error('Addon "' . $packageId . '" doesn\'t exists!'); + $io->error('Addon "' . $addon . '" doesn\'t exists!'); return Command::FAILURE; } diff --git a/src/Console/Command/AddonDeactivateCommand.php b/src/Console/Command/AddonDeactivateCommand.php index c893268ff7..aee5a6ded1 100644 --- a/src/Console/Command/AddonDeactivateCommand.php +++ b/src/Console/Command/AddonDeactivateCommand.php @@ -2,53 +2,32 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; +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; /** * @internal */ -class AddonDeactivateCommand extends AbstractCommand +#[AsCommand(name: 'addon:deactivate', description: 'Deactivates the selected addon')] +final class AddonDeactivateCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Deactivates the selected addon') - ->addArgument('addon-id', InputArgument::REQUIRED, 'The id of the addon, e.g. "yform"', null, static function () { - $packageNames = []; - - foreach (Addon::getRegisteredAddons() as $package) { - if (!$package->isAvailable()) { - continue; - } - - $packageNames[] = $package->name; - } - - return $packageNames; - }); - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - - $packageId = $input->getArgument('addon-id'); - + public function __invoke( + SymfonyStyle $io, + #[Argument('The name of the addon, e.g. "yform"', suggestedValues: static function (): array { + return array_keys(Addon::getAvailableAddons()); + })] string $addon, + ): int { // the package manager don't know new packages in the addon folder // so we need to make them available AddonManager::synchronizeWithFileSystem(); - $package = Addon::get($packageId); + $package = Addon::get($addon); if (!$package) { - $io->error('Addon "' . $packageId . '" doesn\'t exists!'); + $io->error('Addon "' . $addon . '" doesn\'t exists!'); return Command::FAILURE; } diff --git a/src/Console/Command/AddonInstallCommand.php b/src/Console/Command/AddonInstallCommand.php index 2de21aec3a..2a45c572ec 100644 --- a/src/Console/Command/AddonInstallCommand.php +++ b/src/Console/Command/AddonInstallCommand.php @@ -2,58 +2,45 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; /** * @internal */ -class AddonInstallCommand extends AbstractCommand +#[AsCommand(name: 'addon:install', description: 'Installs the selected addon')] +final class AddonInstallCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Installs the selected addon') - ->addArgument('addon-id', InputArgument::REQUIRED, 'The id of the addon, e.g. "yform"', null, static function () { - $packageNames = []; - - foreach (Addon::getRegisteredAddons() as $package) { - // allow all packages, because we support --re-intall for already installed ones - $packageNames[] = $package->name; - } - - return $packageNames; - }) - ->addOption('re-install', '-r', InputOption::VALUE_NONE, 'Allows to reinstall the addon without asking the User'); - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - - $packageId = $input->getArgument('addon-id'); - + public function __invoke( + InputInterface $input, + OutputInterface $output, + SymfonyStyle $io, + #[Argument('The name of the addon, e.g. "yform"', suggestedValues: static function (): array { + // allow all packages, because we support --re-intall for already installed ones + return array_keys(Addon::getRegisteredAddons()); + })] string $addon, + #[Option('Allows to reinstall the addon without asking the User', shortcut: 'r')] bool $reInstall = false, + ): int { // the package manager don't know new packages in the addon folder // so we need to make them available AddonManager::synchronizeWithFileSystem(); - $package = Addon::get($packageId); + $package = Addon::get($addon); if (!$package) { - $io->error('Addon "' . $packageId . '" doesn\'t exists!'); + $io->error('Addon "' . $addon . '" doesn\'t exists!'); return Command::FAILURE; } - if ($package->isInstalled() && !$input->getOption('re-install')) { + if ($package->isInstalled() && !$reInstall) { /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); $question = new ConfirmationQuestion('Addon "' . $package->name . '" is already installed. Should it be reinstalled? (y/n) ', false); diff --git a/src/Console/Command/AddonListCommand.php b/src/Console/Command/AddonListCommand.php index 583d219deb..fc4d68952a 100644 --- a/src/Console/Command/AddonListCommand.php +++ b/src/Console/Command/AddonListCommand.php @@ -2,52 +2,34 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; +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 count; /** * @internal */ -class AddonListCommand extends AbstractCommand +#[AsCommand(name: 'addon:list', description: 'List available addons')] +final class AddonListCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('List available addons') - ->addOption('search', 's', InputOption::VALUE_REQUIRED, 'filter list') - ->addOption('addon', 'p', InputOption::VALUE_REQUIRED, 'search for exactly this addon-id') - ->addOption('installed-only', 'i', InputOption::VALUE_NONE, 'only list installed addons') - ->addOption('activated-only', 'a', InputOption::VALUE_NONE, 'only list active addons') - ->addOption('error-when-empty', null, InputOption::VALUE_NONE, 'if no addon matches your filter the command exits with error-code 1, otherwise with 0') - ->addOption('json', null, InputOption::VALUE_NONE, 'output table as json') - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - + public function __invoke( + SymfonyStyle $io, + #[Option('filter list', shortcut: 's')] ?string $search = null, + #[Option('search for exactly this addon', shortcut: 'p')] ?string $addon = null, + #[Option('only list installed addons', shortcut: 'i')] bool $installedOnly = false, + #[Option('only list active addons', shortcut: 'a')] bool $activatedOnly = false, + #[Option('if no addon matches your filter the command exits with error-code 1, otherwise with 0')] bool $errorWhenEmpty = false, + #[Option('output table as json')] bool $json = false, + ): int { // the package manager don't know new packages in the addon folder // so we need to make them available AddonManager::synchronizeWithFileSystem(); - $search = $input->getOption('search'); - $packageId = $input->getOption('addon'); - - $installedOnly = false !== $input->getOption('installed-only'); - $activatedOnly = false !== $input->getOption('activated-only'); - $jsonOutput = false !== $input->getOption('json'); - $usingExitCode = false !== $input->getOption('error-when-empty'); - $packages = Addon::getRegisteredAddons(); $rows = []; @@ -61,12 +43,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'license' => $package->getLicense(), ]; - if (!$jsonOutput) { + if (!$json) { $rowdata['installed'] = $rowdata['installed'] ? 'yes' : 'no'; $rowdata['activated'] = $rowdata['activated'] ? 'yes' : 'no'; } - if (null !== $packageId && $packageId !== $package->name) { + if (null !== $addon && $addon !== $package->name) { continue; } @@ -85,12 +67,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $rows[] = $rowdata; } - if ($jsonOutput) { + if ($json) { $io->writeln(json_encode($rows)); - return $usingExitCode && 0 === count($rows) ? Command::FAILURE : Command::SUCCESS; + return $errorWhenEmpty && 0 === count($rows) ? Command::FAILURE : Command::SUCCESS; } $io->table(['addon-id', 'author', 'version', 'installed', 'activated', 'license'], $rows); - return $usingExitCode && 0 === count($rows) ? Command::FAILURE : Command::SUCCESS; + return $errorWhenEmpty && 0 === count($rows) ? Command::FAILURE : Command::SUCCESS; } } diff --git a/src/Console/Command/AddonUninstallCommand.php b/src/Console/Command/AddonUninstallCommand.php index 65b75809cf..ed0e3189e2 100644 --- a/src/Console/Command/AddonUninstallCommand.php +++ b/src/Console/Command/AddonUninstallCommand.php @@ -2,53 +2,32 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; +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; /** * @internal */ -class AddonUninstallCommand extends AbstractCommand +#[AsCommand(name: 'addon:uninstall', description: 'Uninstalls the selected addon')] +final class AddonUninstallCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Uninstalls the selected addon') - ->addArgument('addon-id', InputArgument::REQUIRED, 'The id of the addon, e.g. "yform"', null, static function () { - $packageNames = []; - - foreach (Addon::getRegisteredAddons() as $package) { - if (!$package->isInstalled()) { - continue; - } - - $packageNames[] = $package->name; - } - - return $packageNames; - }); - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - - $packageId = $input->getArgument('addon-id'); - + public function __invoke( + SymfonyStyle $io, + #[Argument('The name of the addon, e.g. "yform"', suggestedValues: static function (): array { + return array_keys(Addon::getInstalledAddons()); + })] string $addon, + ): int { // the package manager don't know new packages in the addon folder // so we need to make them available AddonManager::synchronizeWithFileSystem(); - $package = Addon::get($packageId); + $package = Addon::get($addon); if (!$package) { - $io->error('Addon "' . $packageId . '" doesn\'t exists!'); + $io->error('Addon "' . $addon . '" doesn\'t exists!'); return Command::FAILURE; } diff --git a/src/Console/Command/AssetsCompileStylesCommand.php b/src/Console/Command/AssetsCompileStylesCommand.php index 81b263c886..5aa95b8674 100644 --- a/src/Console/Command/AssetsCompileStylesCommand.php +++ b/src/Console/Command/AssetsCompileStylesCommand.php @@ -2,27 +2,19 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Backend\Style; +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; /** * @internal */ -class AssetsCompileStylesCommand extends AbstractCommand +#[AsCommand(name: 'assets:compile-styles', description: 'Converts Backend SCSS files to CSS')] +final class AssetsCompileStylesCommand extends AbstractCommand { - #[Override] - protected function configure(): void + public function __invoke(SymfonyStyle $io): int { - $this->setDescription('Converts Backend SCSS files to CSS'); - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); $io->title('Backend style scss compiler'); Style::compile(); diff --git a/src/Console/Command/AssetsSyncCommand.php b/src/Console/Command/AssetsSyncCommand.php index 6c61b2ac7f..14d1e4dcb9 100644 --- a/src/Console/Command/AssetsSyncCommand.php +++ b/src/Console/Command/AssetsSyncCommand.php @@ -8,9 +8,8 @@ use Redaxo\Core\Filesystem\File; use Redaxo\Core\Filesystem\Finder; use Redaxo\Core\Filesystem\Path; +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 sprintf; @@ -18,13 +17,13 @@ /** * @internal */ -class AssetsSyncCommand extends AbstractCommand +#[AsCommand(name: 'assets:sync', description: 'Sync assets within the assets-dir with the sources-dir')] +final class AssetsSyncCommand extends AbstractCommand { #[Override] protected function configure(): void { $this - ->setDescription('Sync assets within the assets-dir with the sources-dir') ->setHelp(sprintf( 'Sync folders and files of /%s with source assets folders', rtrim(Path::relative(Path::assets()), '/'), @@ -32,11 +31,9 @@ protected function configure(): void ; } - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int + public function __invoke(SymfonyStyle $io): int { $created = $updated = $errored = 0; - $io = $this->getStyle($input, $output); foreach (Addon::getInstalledAddons() as $package) { $assetsPublicPath = $package->getAssetsPath(); diff --git a/src/Console/Command/AvailableInSetupInterface.php b/src/Console/Command/AvailableInSetupInterface.php new file mode 100644 index 0000000000..4a170c0336 --- /dev/null +++ b/src/Console/Command/AvailableInSetupInterface.php @@ -0,0 +1,12 @@ +setDescription('Clears the redaxo core cache'); - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int + public function __invoke(SymfonyStyle $io): int { $successMsg = Cache::delete(); - $io = $this->getStyle($input, $output); $io->success($this->decodeMessage($successMsg)); return Command::SUCCESS; diff --git a/src/Console/Command/ConfigGetCommand.php b/src/Console/Command/ConfigGetCommand.php index 38bd09eeda..8df95436d6 100644 --- a/src/Console/Command/ConfigGetCommand.php +++ b/src/Console/Command/ConfigGetCommand.php @@ -2,40 +2,31 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Core; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; 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\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use function is_array; /** * @internal */ -class ConfigGetCommand extends AbstractCommand implements StandaloneInterface +#[AsCommand(name: 'config:get', description: 'Get config variables')] +final class ConfigGetCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { - #[Override] - protected function configure(): void - { - $this->setDescription('Get config variables') - ->addArgument('config-key', InputArgument::REQUIRED, 'config path separated by periods, e.g. "setup" or "db.1.host"') - ->addOption('type', 't', InputOption::VALUE_REQUIRED, 'php type of the returned value, e.g. "octal"', 'string') - ->addOption('addon', 'p', InputOption::VALUE_REQUIRED, 'addon to inspect, defaults to redaxo-core', 'core') - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - $key = $input->getArgument('config-key'); - $type = $input->getOption('type'); - + public function __invoke( + SymfonyStyle $io, + OutputInterface $output, + #[Argument('config path separated by periods, e.g. "setup" or "db.1.host"')] string $key, + #[Option('php type of the returned value, e.g. "octal"', shortcut: 't')] string $type = 'string', + #[Option('addon to inspect, defaults to redaxo-core', name: 'addon', shortcut: 'p')] string $package = 'core', + ): int { if (!$key) { throw new InvalidArgumentException('config-key is required'); } @@ -43,7 +34,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $path = explode('.', $key); $propertyKey = array_shift($path); - $package = $input->getOption('addon'); if ('core' === $package) { $config = Core::getProperty($propertyKey); } else { diff --git a/src/Console/Command/ConfigSetCommand.php b/src/Console/Command/ConfigSetCommand.php index 7dff269e5f..8aa2b8052f 100644 --- a/src/Console/Command/ConfigSetCommand.php +++ b/src/Console/Command/ConfigSetCommand.php @@ -2,16 +2,15 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Filesystem\File; use Redaxo\Core\Filesystem\Path; use Redaxo\Core\Util\Type; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; 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\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use function count; use function in_array; @@ -20,43 +19,31 @@ /** * @internal */ -class ConfigSetCommand extends AbstractCommand implements StandaloneInterface +#[AsCommand( + name: 'config:set', + description: 'Set config variables', + help: <<<'EOF' + Set config variables in config.yml. + + Example: enable setup + %command.full_name% --type boolean setup true + + Example: set password min length to 8 + %command.full_name% --type integer password_policy.length.min 8 + + Example: set error email + %command.full_name% error_email mail@example.org + EOF, +)] +final class ConfigSetCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { - #[Override] - protected function configure(): void - { - $this->setDescription('Set config variables') - ->addArgument('config-key', InputArgument::REQUIRED, 'config path separated by periods, e.g. "setup" or "db.1.host"') - ->addArgument('value', InputArgument::OPTIONAL, 'new value for config key, e.g. "somestring" or "1"') - ->addOption('type', 't', InputOption::VALUE_REQUIRED, 'php type of new value, e.g. "bool", "octal" or "int"', 'string') - ->addOption('unset', null, InputOption::VALUE_NONE, 'sets the config key to null') - ->setHelp(<<<'EOF' - Set config variables in config.yml. - - Example: enable setup - %command.full_name% --type boolean setup true - - Example: set password min length to 8 - %command.full_name% --type integer password_policy.length.min 8 - - Example: set error email - %command.full_name% error_email mail@example.org - - EOF - ) - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - - $key = $input->getArgument('config-key'); - $value = $input->getArgument('value'); - $unset = $input->getOption('unset'); - $type = $input->getOption('type'); - + public function __invoke( + SymfonyStyle $io, + #[Argument('config path separated by periods, e.g. "setup" or "db.1.host"')] string $key, + #[Argument('new value for config key, e.g. "somestring" or "1"')] ?string $value = null, + #[Option('php type of new value, e.g. "bool", "octal" or "int"', shortcut: 't')] string $type = 'string', + #[Option('sets the config key to null')] bool $unset = false, + ): int { if (null === $value && false === $unset) { throw new InvalidArgumentException('No new value specified'); } diff --git a/src/Console/Command/CronjobRunCommand.php b/src/Console/Command/CronjobRunCommand.php index e64eb7824f..dcb6fc8e24 100644 --- a/src/Console/Command/CronjobRunCommand.php +++ b/src/Console/Command/CronjobRunCommand.php @@ -2,15 +2,13 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Core; use Redaxo\Core\Cronjob\CronjobManager; use Redaxo\Core\Database\Sql; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Style\SymfonyStyle; @@ -20,29 +18,19 @@ /** * @internal */ -class CronjobRunCommand extends AbstractCommand +#[AsCommand(name: 'cronjob:run', description: 'Executes cronjobs of the "script" environment')] +final class CronjobRunCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Executes cronjobs of the "script" environment') - ->addOption('job', null, InputOption::VALUE_OPTIONAL, 'Execute single job (selected interactively or given by id)', false) - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - + public function __invoke( + SymfonyStyle $io, + #[Option('Execute single job (selected interactively or given by id)')] bool|string $job = false, + ): int { // indicator constant, kept for BC define('REX_CRONJOB_SCRIPT', true); - $job = $input->getOption('job'); - if (false !== $job) { - return $this->executeSingleJob($io, (int) $job); + // `true` means the option was given without a value (--job) -> select the job interactively + return $this->executeSingleJob($io, true === $job ? null : (int) $job); } $manager = CronjobManager::factory(); diff --git a/src/Console/Command/DatabaseConnectionOptionsCommand.php b/src/Console/Command/DatabaseConnectionOptionsCommand.php index e970cf600e..05e74fbcd3 100644 --- a/src/Console/Command/DatabaseConnectionOptionsCommand.php +++ b/src/Console/Command/DatabaseConnectionOptionsCommand.php @@ -2,40 +2,33 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Core; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; /** * @internal */ -class DatabaseConnectionOptionsCommand extends AbstractCommand implements StandaloneInterface +#[AsCommand( + name: 'db:connection-options', + description: 'Dumps the db connection options for the mysql cli tool', + help: <<<'EOF' + Dumps the db connection options for the mysql cli tool. + + Example: run interactive mysql shell + %command.full_name% | xargs -o mysql + + Example: dump the database + %command.full_name% | xargs mysqldump > dump.sql + + Example: import a dump file + %command.full_name% | xargs sh -c 'mysql "$0" "$@" < dump.sql' + EOF, +)] +final class DatabaseConnectionOptionsCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Dumps the db connection options for the mysql cli tool') - ->setHelp(<<<'EOF' - Dumps the db connection options for the mysql cli tool. - - Example: run interactive mysql shell - %command.full_name% | xargs -o mysql - - Example: dump the database - %command.full_name% | xargs mysqldump > dump.sql - - Example: import a dump file - %command.full_name% | xargs sh -c 'mysql "$0" "$@" < dump.sql' - EOF - ) - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int + public function __invoke(OutputInterface $output): int { $db = Core::getDbConfig(1); diff --git a/src/Console/Command/DatabaseDumpSchemaCommand.php b/src/Console/Command/DatabaseDumpSchemaCommand.php index cc4e1242bc..23232c59a0 100644 --- a/src/Console/Command/DatabaseDumpSchemaCommand.php +++ b/src/Console/Command/DatabaseDumpSchemaCommand.php @@ -2,39 +2,33 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Core; use Redaxo\Core\Database\SchemaDumper; use Redaxo\Core\Database\Sql; use Redaxo\Core\Database\Table; +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; use function sprintf; /** * @internal */ -class DatabaseDumpSchemaCommand extends AbstractCommand +#[AsCommand(name: 'db:dump-schema', description: 'Dumps the schema of db tables as php code')] +final class DatabaseDumpSchemaCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Dumps the schema of db tables as php code') - ->addArgument('table', InputArgument::REQUIRED, 'Database table', null, static function () { - return Sql::factory()->getTables(Core::getTablePrefix()); - }) - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $table = Table::get($input->getArgument('table')); + public function __invoke( + OutputInterface $output, + SymfonyStyle $io, + #[Argument('Database table', suggestedValues: static function (): array { + return Sql::factory()->getTables(Core::getTablePrefix()); + })] string $table, + ): int { + $table = Table::get($table); if (!$table->exists()) { throw new InvalidArgumentException(sprintf('Table "%s" does not exist.', $table->getName())); @@ -44,7 +38,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->write($generator->dumpTable($table)); - $io = $this->getStyle($input, $output)->getErrorStyle(); + $io = $io->getErrorStyle(); $io->success('Generated schema for table "' . $table->getName() . '".'); return Command::SUCCESS; diff --git a/src/Console/Command/DatabaseSetConnectionCommand.php b/src/Console/Command/DatabaseSetConnectionCommand.php index d68acd55d2..61fcd657c5 100644 --- a/src/Console/Command/DatabaseSetConnectionCommand.php +++ b/src/Console/Command/DatabaseSetConnectionCommand.php @@ -2,58 +2,52 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Database\Sql; use Redaxo\Core\Filesystem\File; use Redaxo\Core\Filesystem\Path; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; /** * @internal */ -class DatabaseSetConnectionCommand extends AbstractCommand implements StandaloneInterface +#[AsCommand( + name: 'db:set-connection', + description: 'Sets database connection credentials.', + help: 'Checks by default if a database connection can be established with the new settings.', +)] +final class DatabaseSetConnectionCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Sets database connection credentials.') - ->setHelp('Checks by default if a database connection can be established with the new settings.') - ->addOption('host', null, InputOption::VALUE_REQUIRED, 'database host') - ->addOption('login', null, InputOption::VALUE_REQUIRED, 'database user') - ->addOption('password', null, InputOption::VALUE_REQUIRED, 'database password') - ->addOption('database', null, InputOption::VALUE_REQUIRED, 'database name') - ->addOption('force', 'f', InputOption::VALUE_NONE, 'Save credentials even if validation fails.'); - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - + public function __invoke( + SymfonyStyle $io, + #[Option('database host')] ?string $host = null, + #[Option('database user')] ?string $login = null, + #[Option('database password')] ?string $password = null, + #[Option('database name')] ?string $database = null, + #[Option('Save credentials even if validation fails.', shortcut: 'f')] bool $force = false, + ): int { $configFile = Path::coreData('config.yml'); $config = File::getConfig($configFile); $db = ($config['db'][1] ?? []) + ['host' => '', 'login' => '', 'password' => '', 'name' => '']; $changed = false; - if (null !== $host = $input->getOption('host')) { + if (null !== $host) { $db['host'] = $host; $changed = true; } - if (null !== $login = $input->getOption('login')) { + if (null !== $login) { $db['login'] = $login; $changed = true; } - if (null !== $password = $input->getOption('password')) { + if (null !== $password) { $db['password'] = $password; $changed = true; } - if (null !== $database = $input->getOption('database')) { + if (null !== $database) { $db['name'] = $database; $changed = true; } @@ -73,7 +67,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (true !== $settingsValid) { $io->error("Can't connect to database:\n" . $settingsValid); - if (!$input->getOption('force')) { + if (!$force) { return Command::FAILURE; } } else { diff --git a/src/Console/Command/ListCommand.php b/src/Console/Command/ListCommand.php index f03234a2a2..81ccfe6cb2 100644 --- a/src/Console/Command/ListCommand.php +++ b/src/Console/Command/ListCommand.php @@ -13,7 +13,7 @@ /** * @internal */ -class ListCommand extends SymfonyListCommand +final class ListCommand extends SymfonyListCommand { #[Override] protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Console/Command/MigrateCommand.php b/src/Console/Command/MigrateCommand.php index a275b94430..0db55f92a2 100644 --- a/src/Console/Command/MigrateCommand.php +++ b/src/Console/Command/MigrateCommand.php @@ -2,7 +2,6 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; use Redaxo\Core\Core; @@ -13,9 +12,9 @@ use Redaxo\Core\Setup\Setup; use Redaxo\Core\Translation\I18n; use Redaxo\Core\Util\Version; +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 Throwable; use function sprintf; @@ -23,19 +22,11 @@ /** * @internal */ -class MigrateCommand extends AbstractCommand implements StandaloneInterface +#[AsCommand(name: 'migrate', description: 'Runs install scripts of core and addons to ensure schema is up to date')] +final class MigrateCommand extends AbstractCommand implements StandaloneInterface { - #[Override] - protected function configure(): void + public function __invoke(SymfonyStyle $io): int { - $this->setDescription('Runs install scripts of core and addons to ensure schema is up to date'); - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - // verify the database server meets the minimum version requirements $sql = Sql::factory(); $dbType = $sql->getDbType(); diff --git a/src/Console/Command/SetupCheckCommand.php b/src/Console/Command/SetupCheckCommand.php index 6f7fa58f0f..8b6b54cea3 100644 --- a/src/Console/Command/SetupCheckCommand.php +++ b/src/Console/Command/SetupCheckCommand.php @@ -2,14 +2,13 @@ namespace Redaxo\Core\Console\Command; -use Override; use PDOException; use Redaxo\Core\Filesystem\File; use Redaxo\Core\Filesystem\Path; use Redaxo\Core\Setup\Setup; use Redaxo\Core\Translation\I18n; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Style\SymfonyStyle; use function count; @@ -18,21 +17,12 @@ /** * @internal */ -class SetupCheckCommand extends AbstractCommand +#[AsCommand(name: 'setup:check', description: 'Check the commandline interface (CLI) environment for REDAXO requirements')] +final class SetupCheckCommand extends AbstractCommand implements AvailableInSetupInterface { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Check the commandline interface (CLI) environment for REDAXO requirements') - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int + public function __invoke(SymfonyStyle $io): int { $exitCode = 0; - $io = $this->getStyle($input, $output); $errors = Setup::checkEnvironment(); if (0 == count($errors)) { diff --git a/src/Console/Command/SetupRunCommand.php b/src/Console/Command/SetupRunCommand.php index f16ee0047d..bd021b172a 100644 --- a/src/Console/Command/SetupRunCommand.php +++ b/src/Console/Command/SetupRunCommand.php @@ -3,7 +3,6 @@ namespace Redaxo\Core\Console\Command; use DateTimeZone; -use Override; use PDOException; use Redaxo\Core\Backup\Backup; use Redaxo\Core\Core; @@ -17,11 +16,11 @@ use Redaxo\Core\Setup\Setup; use Redaxo\Core\Translation\I18n; use Redaxo\Core\Util\Type; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Question\Question; @@ -39,44 +38,38 @@ /** * @internal */ -class SetupRunCommand extends AbstractCommand implements OnlySetupAddonsInterface +#[AsCommand(name: 'setup:run', description: 'Perform redaxo setup')] +final class SetupRunCommand extends AbstractCommand implements OnlySetupAddonsInterface, AvailableInSetupInterface { private SymfonyStyle $io; private InputInterface $input; private bool $forceAsking = false; - #[Override] - protected function configure(): void - { - $this - ->setDescription('Perform redaxo setup') - ->addOption('lang', null, InputOption::VALUE_REQUIRED, 'System language e.g. "de_de" or "en_gb"', null, static fn () => I18n::getLocales()) - ->addOption('agree-license', null, InputOption::VALUE_NONE, 'Accept license terms and conditions') // BC, not used anymore - ->addOption('server', null, InputOption::VALUE_REQUIRED, 'Website URL e.g. "https://example.org/"') - ->addOption('servername', null, InputOption::VALUE_REQUIRED, 'Website name') - ->addOption('error-email', null, InputOption::VALUE_REQUIRED, 'Error mail address e.g. "info@example.org"') - ->addOption('timezone', null, InputOption::VALUE_REQUIRED, 'Timezone e.g. "Europe/Berlin"', null, static fn () => DateTimeZone::listIdentifiers()) - ->addOption('db-host', null, InputOption::VALUE_REQUIRED, 'Database hostname e.g. "localhost" or "127.0.0.1"') - ->addOption('db-login', null, InputOption::VALUE_REQUIRED, 'Database username e.g. "root"') - ->addOption('db-password', null, InputOption::VALUE_REQUIRED, 'Database user password') - ->addOption('db-name', null, InputOption::VALUE_REQUIRED, 'Database name e.g. "redaxo"') - ->addOption('db-createdb', null, InputOption::VALUE_REQUIRED, 'Creates the database "yes" or "no"', null, ['yes', 'no']) - ->addOption('db-setup', null, InputOption::VALUE_REQUIRED, 'Database setup mode e.g. "normal", "override" or "import"', null, ['normal', 'override', 'import']) - ->addOption('db-import', null, InputOption::VALUE_REQUIRED, 'Database import filename if "import" is used as --db-setup') - ->addOption('db-ssl-ca', null, InputOption::VALUE_OPTIONAL, 'Path to SSL Certificate Authority file or use without value to enable CA mode', false) - ->addOption('db-ssl-key', null, InputOption::VALUE_REQUIRED, 'Path to SSL key file') - ->addOption('db-ssl-cert', null, InputOption::VALUE_REQUIRED, 'Path to SSL certificate file') - ->addOption('db-ssl-verify-server-cert', null, InputOption::VALUE_REQUIRED, 'Verify SSL server certificate (yes/no)', null, ['yes', 'no']) - ->addOption('admin-username', null, InputOption::VALUE_REQUIRED, 'Creates a redaxo admin user with the given username') - ->addOption('admin-password', null, InputOption::VALUE_REQUIRED, 'Sets the password for the admin user account') - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - + public function __invoke( + InputInterface $input, + SymfonyStyle $io, + #[Option('System language e.g. "de_de" or "en_gb"', suggestedValues: I18n::getLocales(...))] ?string $lang = null, + #[Option('Accept license terms and conditions')] bool $agreeLicense = false, // BC, not used anymore + #[Option('Website URL e.g. "https://example.org/"')] ?string $server = null, + #[Option('Website name')] ?string $servername = null, + #[Option('Error mail address e.g. "info@example.org"')] ?string $errorEmail = null, + #[Option('Timezone e.g. "Europe/Berlin"', suggestedValues: static function () { + return DateTimeZone::listIdentifiers(); + })] ?string $timezone = null, + #[Option('Database hostname e.g. "localhost" or "127.0.0.1"')] ?string $dbHost = null, + #[Option('Database username e.g. "root"')] ?string $dbLogin = null, + #[Option('Database user password')] ?string $dbPassword = null, + #[Option('Database name e.g. "redaxo"')] ?string $dbName = null, + #[Option('Creates the database "yes" or "no"', suggestedValues: ['yes', 'no'])] ?string $dbCreatedb = null, + #[Option('Database setup mode e.g. "normal", "override" or "import"', suggestedValues: ['normal', 'override', 'import'])] ?string $dbSetup = null, + #[Option('Database import filename if "import" is used as --db-setup')] ?string $dbImport = null, + #[Option('Path to SSL Certificate Authority file or use without value to enable CA mode')] bool|string $dbSslCa = false, + #[Option('Path to SSL key file')] ?string $dbSslKey = null, + #[Option('Path to SSL certificate file')] ?string $dbSslCert = null, + #[Option('Verify SSL server certificate (yes/no)', suggestedValues: ['yes', 'no'])] ?string $dbSslVerifyServerCert = null, + #[Option('Creates a redaxo admin user with the given username')] ?string $adminUsername = null, + #[Option('Sets the password for the admin user account')] ?string $adminPassword = null, + ): int { $this->io = $io; $this->input = $input; @@ -255,8 +248,7 @@ static function ($value) use ($timezones) { $sslRequired = $input->isInteractive() && $this->io->confirm('Configure SSL database connection?', false); $sslConfigured = false; // Track if any SSL option was configured - $sslCa = $input->getOption('db-ssl-ca'); - if ($sslRequired && ($this->forceAsking || false === $sslCa)) { + if ($sslRequired && ($this->forceAsking || false === $dbSslCa)) { /** @var string $sslCaChoice */ $sslCaChoice = $this->io->choice('SSL Certificate Authority', [ 'none' => 'No CA verification', @@ -281,18 +273,18 @@ static function ($value) use ($timezones) { })); $sslConfigured = true; } - } elseif (false === $sslCa) { + } elseif (false === $dbSslCa) { $config['db'][1]['ssl_ca'] = null; - } elseif (null === $sslCa || true === $sslCa) { + } elseif (true === $dbSslCa) { $config['db'][1]['ssl_ca'] = true; $io->success('Using SSL system CA verification'); $sslConfigured = true; } else { - if (!is_file($sslCa) || !is_readable($sslCa)) { - throw new InvalidArgumentException('SSL CA file not found or not readable: ' . $sslCa); + if (!is_file($dbSslCa) || !is_readable($dbSslCa)) { + throw new InvalidArgumentException('SSL CA file not found or not readable: ' . $dbSslCa); } - $config['db'][1]['ssl_ca'] = $sslCa; - $io->success(sprintf('Using SSL CA file "%s"', $sslCa)); + $config['db'][1]['ssl_ca'] = $dbSslCa; + $io->success(sprintf('Using SSL CA file "%s"', $dbSslCa)); $sslConfigured = true; } @@ -321,7 +313,7 @@ static function ($value) use ($timezones) { $config['db'][1][$key] = $value; } - $sslVerifyServerCert = $input->getOption('db-ssl-verify-server-cert'); + $sslVerifyServerCert = $dbSslVerifyServerCert; if ( $sslRequired && $sslConfigured && (null === $sslVerifyServerCert || $this->forceAsking) diff --git a/src/Console/Command/SystemReportCommand.php b/src/Console/Command/SystemReportCommand.php index 5e487d1600..0bf0584419 100644 --- a/src/Console/Command/SystemReportCommand.php +++ b/src/Console/Command/SystemReportCommand.php @@ -2,15 +2,15 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\SystemReport; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Helper\TableStyle; -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 in_array; use function is_bool; @@ -21,26 +21,18 @@ /** * @internal */ -class SystemReportCommand extends AbstractCommand +#[AsCommand(name: 'system:report', description: 'Shows the system report')] +final class SystemReportCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Shows the system report') - ->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format ("cli", "markdown")', 'cli', ['cli', 'markdown']) - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $formats = ['cli', 'markdown']; - - $format = $input->getOption('format'); - - if (!in_array($format, $formats, true)) { - throw new InvalidOptionException(sprintf('Invalid value "%s" for --format option, allowed values: %s', $format, implode(', ', $formats))); + private const array FORMATS = ['cli', 'markdown']; + + public function __invoke( + OutputInterface $output, + SymfonyStyle $io, + #[Option('Output format ("cli", "markdown")', shortcut: 'f', suggestedValues: self::FORMATS)] string $format = 'cli', + ): int { + if (!in_array($format, self::FORMATS, true)) { + throw new InvalidOptionException(sprintf('Invalid value "%s" for --format option, allowed values: %s', $format, implode(', ', self::FORMATS))); } $report = SystemReport::factory(); @@ -51,8 +43,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - $io = $this->getStyle($input, $output); - $io->title('System report'); $tables = []; diff --git a/src/Console/Command/UserCreateCommand.php b/src/Console/Command/UserCreateCommand.php index 512fb0aa7c..00fd39dfae 100644 --- a/src/Console/Command/UserCreateCommand.php +++ b/src/Console/Command/UserCreateCommand.php @@ -2,45 +2,33 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Core; use Redaxo\Core\Database\Sql; use Redaxo\Core\Security\BackendLogin; use Redaxo\Core\Security\BackendPasswordPolicy; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; 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\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; /** * @internal */ -class UserCreateCommand extends AbstractCommand +#[AsCommand(name: 'user:create', description: 'Create a new user')] +final class UserCreateCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Create a new user') - ->addArgument('login', InputArgument::REQUIRED, 'Login') - ->addArgument('password', InputArgument::OPTIONAL, 'Password') - ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Name') - ->addOption('admin', null, InputOption::VALUE_NONE, 'Grant admin permissions') - ->addOption('password-change-required', null, InputOption::VALUE_NONE, 'Require password change after login') - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - - $login = $input->getArgument('login'); - + public function __invoke( + SymfonyStyle $io, + #[Argument('Login')] string $login, + #[Argument('Password')] ?string $password = null, + #[Option('Name')] ?string $name = null, + #[Option('Grant admin permissions')] bool $admin = false, + #[Option('Require password change after login')] bool $passwordChangeRequired = false, + ): int { $user = Sql::factory(); $user ->setTable(Core::getTable('user')) @@ -53,7 +41,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $passwordPolicy = BackendPasswordPolicy::factory(); - $password = $input->getArgument('password'); if ($password && true !== $msg = $passwordPolicy->check($password)) { throw new InvalidArgumentException($msg); } @@ -75,7 +62,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int throw new InvalidArgumentException('Missing password.'); } - $name = $input->getOption('name'); if (!$name) { $name = $login; } @@ -83,18 +69,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $passwordHash = BackendLogin::passwordHash($password); $user = Sql::factory(); - // $user->setDebug(); $user->setTable(Core::getTablePrefix() . 'user'); $user->setValue('name', $name); $user->setValue('login', $login); $user->setValue('password', $passwordHash); - $user->setValue('admin', $input->getOption('admin') ? 1 : 0); + $user->setValue('admin', $admin ? 1 : 0); $user->setValue('login_tries', 0); $user->addGlobalCreateFields('console'); $user->addGlobalUpdateFields('console'); $user->setDateTimeValue('password_changed', time()); $user->setArrayValue('previous_passwords', $passwordPolicy->updatePreviousPasswords(null, $passwordHash)); - $user->setValue('password_change_required', (int) $input->getOption('password-change-required')); + $user->setValue('password_change_required', (int) $passwordChangeRequired); $user->setValue('status', '1'); $user->insert(); diff --git a/src/Console/Command/UserDeleteCommand.php b/src/Console/Command/UserDeleteCommand.php index 7b865bb7b2..2c8e504fd7 100644 --- a/src/Console/Command/UserDeleteCommand.php +++ b/src/Console/Command/UserDeleteCommand.php @@ -2,42 +2,32 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Core; use Redaxo\Core\Database\Sql; use Redaxo\Core\ExtensionPoint\Extension; use Redaxo\Core\ExtensionPoint\ExtensionPoint; use Redaxo\Core\Security\User; +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; use function sprintf; /** * @internal */ +#[AsCommand(name: 'user:delete', description: 'Deletes an user by the specified login name.')] final class UserDeleteCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Deletes an user by the specified login name.') - ->addArgument('user', InputArgument::REQUIRED, 'Username', null, static function () { - /** @var list */ - return array_column(Sql::factory()->getArray('SELECT login FROM ' . Core::getTable('user')), 'login'); - }) - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - - $username = $input->getArgument('user'); + public function __invoke( + SymfonyStyle $io, + #[Argument('Username', suggestedValues: static function (): array { + /** @var list */ + return array_column(Sql::factory()->getArray('SELECT login FROM ' . Core::getTable('user')), 'login'); + })] string $user, + ): int { + $username = $user; $user = User::forLogin($username); diff --git a/src/Console/Command/UserListCommand.php b/src/Console/Command/UserListCommand.php index 5295dc5c97..7dfab1f7b6 100644 --- a/src/Console/Command/UserListCommand.php +++ b/src/Console/Command/UserListCommand.php @@ -2,39 +2,31 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Core; use Redaxo\Core\Database\Sql; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; /** * @internal */ +#[AsCommand(name: 'user:list', description: 'List all users or a specific user by login name')] final class UserListCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('List all users or a specific user by login name') - ->addArgument('user', InputArgument::OPTIONAL, 'Username', null, static function () { - /** @var list */ - return array_column(Sql::factory()->getArray('SELECT login FROM' . Core::getTable('user')), 'login'); - }); - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - - $username = $input->getArgument('user'); + public function __invoke( + OutputInterface $output, + SymfonyStyle $io, + #[Argument('Username', suggestedValues: static function (): array { + /** @var list */ + return array_column(Sql::factory()->getArray('SELECT login FROM ' . Core::getTable('user')), 'login'); + })] ?string $user = null, + ): int { $sql = Sql::factory(); $query = ' SELECT @@ -46,13 +38,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int `lastlogin` FROM ' . Core::getTable('user') . ' '; - if ($username) { + if ($user) { $sql->setQuery($query . ' WHERE login = :login', [ - 'login' => $username, + 'login' => $user, ]); if (0 === $sql->getRows()) { - $io->error(sprintf('The user "%s" does not exist.', $username)); + $io->error(sprintf('The user "%s" does not exist.', $user)); return Command::FAILURE; } } else { diff --git a/src/Console/Command/UserSetPasswordCommand.php b/src/Console/Command/UserSetPasswordCommand.php index 80bf2a02d5..6e7005ab15 100644 --- a/src/Console/Command/UserSetPasswordCommand.php +++ b/src/Console/Command/UserSetPasswordCommand.php @@ -2,7 +2,6 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Core; use Redaxo\Core\Database\Sql; use Redaxo\Core\ExtensionPoint\Extension; @@ -10,40 +9,31 @@ use Redaxo\Core\Security\BackendLogin; use Redaxo\Core\Security\BackendPasswordPolicy; use Redaxo\Core\Security\User; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; 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\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; /** * @internal */ -class UserSetPasswordCommand extends AbstractCommand +#[AsCommand(name: 'user:set-password', description: 'Sets a new password for a user')] +final class UserSetPasswordCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->setDescription('Sets a new password for a user') - ->addArgument('user', InputArgument::REQUIRED, 'Username', null, static function () { - /** @var list */ - return array_column(Sql::factory()->getArray('SELECT login FROM ' . Core::getTable('user')), 'login'); - }) - ->addArgument('password', InputArgument::OPTIONAL, 'Password') - ->addOption('password-change-required', null, InputOption::VALUE_NONE, 'Require password change after login') - ; - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getStyle($input, $output); - - $username = $input->getArgument('user'); + public function __invoke( + SymfonyStyle $io, + #[Argument('Username', suggestedValues: static function (): array { + /** @var list */ + return array_column(Sql::factory()->getArray('SELECT login FROM ' . Core::getTable('user')), 'login'); + })] string $user, + #[Argument('Password')] ?string $password = null, + #[Option('Require password change after login')] bool $passwordChangeRequired = false, + ): int { + $username = $user; $user = Sql::factory(); $user @@ -60,8 +50,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $passwordPolicy = BackendPasswordPolicy::factory(); - $password = $input->getArgument('password'); - if ($password && true !== $msg = $passwordPolicy->check($password, $id)) { throw new InvalidArgumentException($msg); } @@ -93,7 +81,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->addGlobalUpdateFields('console') ->setDateTimeValue('password_changed', time()) ->setArrayValue('previous_passwords', $passwordPolicy->updatePreviousPasswords($user, $passwordHash)) - ->setValue('password_change_required', (int) $input->getOption('password-change-required')) + ->setValue('password_change_required', (int) $passwordChangeRequired) ->update(); Extension::registerPoint(new ExtensionPoint('PASSWORD_UPDATED', '', [ diff --git a/src/Console/CommandLoader.php b/src/Console/CommandLoader.php index f0fc36e523..236d50f3a6 100644 --- a/src/Console/CommandLoader.php +++ b/src/Console/CommandLoader.php @@ -3,121 +3,85 @@ namespace Redaxo\Core\Console; use Override; -use Redaxo\Core\Addon\Addon; +use Redaxo\Core\ClassDiscovery; use Redaxo\Core\Console\Command\AbstractCommand; -use Redaxo\Core\Console\Command\AddonActivateCommand; -use Redaxo\Core\Console\Command\AddonDeactivateCommand; -use Redaxo\Core\Console\Command\AddonInstallCommand; -use Redaxo\Core\Console\Command\AddonListCommand; -use Redaxo\Core\Console\Command\AddonUninstallCommand; -use Redaxo\Core\Console\Command\AssetsCompileStylesCommand; -use Redaxo\Core\Console\Command\AssetsSyncCommand; -use Redaxo\Core\Console\Command\CacheClearCommand; -use Redaxo\Core\Console\Command\ConfigGetCommand; -use Redaxo\Core\Console\Command\ConfigSetCommand; -use Redaxo\Core\Console\Command\CronjobRunCommand; -use Redaxo\Core\Console\Command\DatabaseConnectionOptionsCommand; -use Redaxo\Core\Console\Command\DatabaseDumpSchemaCommand; -use Redaxo\Core\Console\Command\DatabaseSetConnectionCommand; -use Redaxo\Core\Console\Command\MigrateCommand; -use Redaxo\Core\Console\Command\SetupCheckCommand; -use Redaxo\Core\Console\Command\SetupRunCommand; -use Redaxo\Core\Console\Command\SystemReportCommand; -use Redaxo\Core\Console\Command\UserCreateCommand; -use Redaxo\Core\Console\Command\UserDeleteCommand; -use Redaxo\Core\Console\Command\UserListCommand; -use Redaxo\Core\Console\Command\UserSetPasswordCommand; +use Redaxo\Core\Console\Command\AvailableInSetupInterface; use Redaxo\Core\Core; -use Redaxo\Core\Exception\RuntimeException; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Command\LazyCommand; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; use Symfony\Component\Console\Exception\CommandNotFoundException; -use function gettype; -use function is_array; +use function array_shift; +use function array_slice; +use function explode; +use function is_a; use function sprintf; /** + * Discovers all commands that are marked with the {@see AsCommand} attribute and extend {@see AbstractCommand}, + * both in the core and in the active addons. Addons therefore register their commands simply by adding the + * attribute to a command class — no `package.yml` configuration is required. + * + * Commands are returned as {@see LazyCommand}, so that listing the commands (e.g. `console list`) does not + * instantiate every command class — only the command that is actually executed is instantiated. + * * @internal */ final class CommandLoader implements CommandLoaderInterface { - /** @var array, addon?: Addon}> */ + /** @var array, name: string, aliases: list, hidden: bool, description: string}> */ private array $commands = []; public function __construct() { - $commands = [ - 'cache:clear' => CacheClearCommand::class, - 'config:get' => ConfigGetCommand::class, - 'config:set' => ConfigSetCommand::class, - 'db:connection-options' => DatabaseConnectionOptionsCommand::class, - 'db:set-connection' => DatabaseSetConnectionCommand::class, - 'setup:check' => SetupCheckCommand::class, - 'setup:run' => SetupRunCommand::class, - ]; + $isSetup = Core::isSetup(); - if (!Core::isSetup()) { - $commands = array_merge($commands, [ - 'assets:sync' => AssetsSyncCommand::class, - 'assets:compile-styles' => AssetsCompileStylesCommand::class, - 'cronjob:run' => CronjobRunCommand::class, - 'db:dump-schema' => DatabaseDumpSchemaCommand::class, - 'addon:activate' => AddonActivateCommand::class, - 'addon:deactivate' => AddonDeactivateCommand::class, - 'addon:list' => AddonListCommand::class, - 'addon:install' => AddonInstallCommand::class, - 'addon:uninstall' => AddonUninstallCommand::class, - 'migrate' => MigrateCommand::class, - 'system:report' => SystemReportCommand::class, - 'user:create' => UserCreateCommand::class, - 'user:delete' => UserDeleteCommand::class, - 'user:list' => UserListCommand::class, - 'user:set-password' => UserSetPasswordCommand::class, - ]); - } - - foreach ($commands as $command => $class) { - $this->commands[$command] = ['class' => $class]; - } - - foreach (Addon::getAvailableAddons() as $addon) { - /** @var array> $commands */ - $commands = $addon->getProperty('console_commands'); - - if (!$commands) { + foreach (ClassDiscovery::getInstance()->discoverByAttribute(AsCommand::class, AbstractCommand::class) as $class => $attribute) { + // Before the setup is completed only the explicitly marked commands are available. + if ($isSetup && !is_a($class, AvailableInSetupInterface::class, true)) { continue; } - if (!is_array($commands)) { - throw new RuntimeException('Expecting "console_commands" property to be an array, got "' . gettype($commands) . '" from package.yml of "' . $addon->name . '"'); + // The name may contain aliases, separated by "|" (and an empty first segment for hidden commands). + $names = explode('|', $attribute->name); + $hidden = '' === $names[0]; + if ($hidden) { + array_shift($names); } - foreach ($commands as $command => $class) { - $this->commands[$command] = [ - 'addon' => $addon, - 'class' => $class, - ]; + $command = [ + 'class' => $class, + 'name' => $names[0], + 'aliases' => array_slice($names, 1), + 'hidden' => $hidden, + 'description' => $attribute->description ?? '', + ]; + + foreach ($names as $name) { + $this->commands[$name] = $command; } } } #[Override] - public function get(string $name): AbstractCommand + public function get(string $name): Command { if (!isset($this->commands[$name])) { throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name)); } - $class = $this->commands[$name]['class']; - - $command = new $class(); - $command->setName($name); - - if (isset($this->commands[$name]['addon'])) { - $command->setAddon($this->commands[$name]['addon']); - } + $command = $this->commands[$name]; + $class = $command['class']; - return $command; + return new LazyCommand( + $command['name'], + $command['aliases'], + $command['description'], + $command['hidden'], + static fn (): AbstractCommand => new $class(), + ); } #[Override] diff --git a/tests/Console/Command/ConfigGetCommandTest.php b/tests/Console/Command/ConfigGetCommandTest.php index 9e8b67af06..2bbf31e0a2 100644 --- a/tests/Console/Command/ConfigGetCommandTest.php +++ b/tests/Console/Command/ConfigGetCommandTest.php @@ -15,7 +15,7 @@ public function testKeyFound(string $expectedValue, string $key): void { $commandTester = new CommandTester(new ConfigGetCommand()); $commandTester->execute([ - 'config-key' => $key, + 'key' => $key, ]); self::assertEquals($expectedValue, $commandTester->getDisplay(true)); self::assertEquals(0, $commandTester->getStatusCode()); @@ -34,7 +34,7 @@ public function testKeyNotFound(): void { $commandTester = new CommandTester(new ConfigGetCommand()); $commandTester->execute([ - 'config-key' => 'foo.bar', + 'key' => 'foo.bar', ]); self::assertEquals(1, $commandTester->getStatusCode()); } @@ -43,7 +43,7 @@ public function testAddonKeyFound(): void { $commandTester = new CommandTester(new ConfigGetCommand()); $commandTester->execute([ - 'config-key' => 'author', + 'key' => 'author', '--addon' => 'test', ], ); self::assertEquals("\"Yakamara Media GmbH & Co. KG, REDAXO team & community\"\n", $commandTester->getDisplay(true)); diff --git a/tests/Console/Command/ConfigSetCommandTest.php b/tests/Console/Command/ConfigSetCommandTest.php index 8d70c53b88..6a5a37c81e 100644 --- a/tests/Console/Command/ConfigSetCommandTest.php +++ b/tests/Console/Command/ConfigSetCommandTest.php @@ -35,7 +35,7 @@ public function testSetBoolean(bool $expectedValue, string $value): void $commandTester = new CommandTester(new ConfigSetCommand()); $commandTester->execute([ '--type' => 'bool', - 'config-key' => 'test', + 'key' => 'test', 'value' => $value, ]); $config = File::getConfig(Path::coreData('config.yml'));