From 65bd3c8283c2d45b12ca5fd0f1a7600b7cf47616 Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Fri, 29 May 2026 19:42:27 +0200 Subject: [PATCH 1/5] refactor!: discover console commands via #[AsCommand] attribute Console commands are now registered via Symfony's native #[AsCommand] attribute and discovered through ClassDiscovery (core + addons), instead of the hardcoded loader map and the "console_commands" package.yml property. - Remove the "console_commands" package.yml property (and its JSON schema) - Add #[AsCommand] (name/description/help) to all core commands - Add AvailableInSetupInterface to gate which commands show during setup - CommandLoader returns LazyCommand, so "console list" no longer instantiates every command - only the executed command is instantiated - AbstractCommand::$addon is now a lazily resolved, non-nullable property (replaces getAddon()/setAddon()); reading it on a core command throws - Update rector upgrade rules accordingly --- .tools/psalm/baseline.xml | 8 -- rector.php | 4 +- schemas/package.json | 7 - src/Console/Application.php | 8 +- src/Console/Command/AbstractCommand.php | 42 ++++-- src/Console/Command/AddonActivateCommand.php | 3 +- .../Command/AddonDeactivateCommand.php | 3 +- src/Console/Command/AddonInstallCommand.php | 3 +- src/Console/Command/AddonListCommand.php | 3 +- src/Console/Command/AddonUninstallCommand.php | 3 +- .../Command/AssetsCompileStylesCommand.php | 8 +- src/Console/Command/AssetsSyncCommand.php | 3 +- .../Command/AvailableInSetupInterface.php | 12 ++ src/Console/Command/CacheClearCommand.php | 11 +- src/Console/Command/ConfigGetCommand.php | 6 +- src/Console/Command/ConfigSetCommand.php | 35 ++--- src/Console/Command/CronjobRunCommand.php | 3 +- .../DatabaseConnectionOptionsCommand.php | 40 +++--- .../Command/DatabaseDumpSchemaCommand.php | 3 +- .../Command/DatabaseSetConnectionCommand.php | 10 +- src/Console/Command/MigrateCommand.php | 8 +- src/Console/Command/SetupCheckCommand.php | 12 +- src/Console/Command/SetupRunCommand.php | 5 +- src/Console/Command/SystemReportCommand.php | 3 +- src/Console/Command/UserCreateCommand.php | 3 +- src/Console/Command/UserDeleteCommand.php | 3 +- src/Console/Command/UserListCommand.php | 3 +- .../Command/UserSetPasswordCommand.php | 3 +- src/Console/CommandLoader.php | 128 +++++++----------- 29 files changed, 184 insertions(+), 199 deletions(-) create mode 100644 src/Console/Command/AvailableInSetupInterface.php diff --git a/.tools/psalm/baseline.xml b/.tools/psalm/baseline.xml index 2a21c2b792..0e499978a9 100644 --- a/.tools/psalm/baseline.xml +++ b/.tools/psalm/baseline.xml @@ -1604,9 +1604,6 @@ - - - @@ -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..a8f9bd4863 100644 --- a/src/Console/Command/AbstractCommand.php +++ b/src/Console/Command/AbstractCommand.php @@ -3,27 +3,28 @@ 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; + /** + * 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(); } protected function getStyle(InputInterface $input, OutputInterface $output): SymfonyStyle @@ -45,4 +46,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..1eddc8240c 100644 --- a/src/Console/Command/AddonActivateCommand.php +++ b/src/Console/Command/AddonActivateCommand.php @@ -5,6 +5,7 @@ use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -13,13 +14,13 @@ /** * @internal */ +#[AsCommand(name: 'addon:activate', description: 'Activates the selected addon')] 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 = []; diff --git a/src/Console/Command/AddonDeactivateCommand.php b/src/Console/Command/AddonDeactivateCommand.php index c893268ff7..1864cebf3f 100644 --- a/src/Console/Command/AddonDeactivateCommand.php +++ b/src/Console/Command/AddonDeactivateCommand.php @@ -5,6 +5,7 @@ use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -13,13 +14,13 @@ /** * @internal */ +#[AsCommand(name: 'addon:deactivate', description: 'Deactivates the selected addon')] 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 = []; diff --git a/src/Console/Command/AddonInstallCommand.php b/src/Console/Command/AddonInstallCommand.php index 2de21aec3a..37ab551fec 100644 --- a/src/Console/Command/AddonInstallCommand.php +++ b/src/Console/Command/AddonInstallCommand.php @@ -5,6 +5,7 @@ use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; @@ -16,13 +17,13 @@ /** * @internal */ +#[AsCommand(name: 'addon:install', description: 'Installs the selected addon')] 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 = []; diff --git a/src/Console/Command/AddonListCommand.php b/src/Console/Command/AddonListCommand.php index 583d219deb..fba3fbeecb 100644 --- a/src/Console/Command/AddonListCommand.php +++ b/src/Console/Command/AddonListCommand.php @@ -5,6 +5,7 @@ use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -15,13 +16,13 @@ /** * @internal */ +#[AsCommand(name: 'addon:list', description: 'List available addons')] 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') diff --git a/src/Console/Command/AddonUninstallCommand.php b/src/Console/Command/AddonUninstallCommand.php index 65b75809cf..3d6e319d95 100644 --- a/src/Console/Command/AddonUninstallCommand.php +++ b/src/Console/Command/AddonUninstallCommand.php @@ -5,6 +5,7 @@ use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\AddonManager; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -13,13 +14,13 @@ /** * @internal */ +#[AsCommand(name: 'addon:uninstall', description: 'Uninstalls the selected addon')] 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 = []; diff --git a/src/Console/Command/AssetsCompileStylesCommand.php b/src/Console/Command/AssetsCompileStylesCommand.php index 81b263c886..82b97972bd 100644 --- a/src/Console/Command/AssetsCompileStylesCommand.php +++ b/src/Console/Command/AssetsCompileStylesCommand.php @@ -4,6 +4,7 @@ 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; @@ -11,14 +12,9 @@ /** * @internal */ +#[AsCommand(name: 'assets:compile-styles', description: 'Converts Backend SCSS files to CSS')] class AssetsCompileStylesCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this->setDescription('Converts Backend SCSS files to CSS'); - } - #[Override] protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Console/Command/AssetsSyncCommand.php b/src/Console/Command/AssetsSyncCommand.php index 6c61b2ac7f..1fd693340c 100644 --- a/src/Console/Command/AssetsSyncCommand.php +++ b/src/Console/Command/AssetsSyncCommand.php @@ -8,6 +8,7 @@ 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; @@ -18,13 +19,13 @@ /** * @internal */ +#[AsCommand(name: 'assets:sync', description: 'Sync assets within the assets-dir with the sources-dir')] 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()), '/'), 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 { diff --git a/src/Console/Command/ConfigGetCommand.php b/src/Console/Command/ConfigGetCommand.php index 38bd09eeda..30a745f2e7 100644 --- a/src/Console/Command/ConfigGetCommand.php +++ b/src/Console/Command/ConfigGetCommand.php @@ -5,6 +5,7 @@ use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Core; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; @@ -17,12 +18,13 @@ /** * @internal */ -class ConfigGetCommand extends AbstractCommand implements StandaloneInterface +#[AsCommand(name: 'config:get', description: 'Get config variables')] +class ConfigGetCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { #[Override] protected function configure(): void { - $this->setDescription('Get config variables') + $this ->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') diff --git a/src/Console/Command/ConfigSetCommand.php b/src/Console/Command/ConfigSetCommand.php index 7dff269e5f..d7604c8c5f 100644 --- a/src/Console/Command/ConfigSetCommand.php +++ b/src/Console/Command/ConfigSetCommand.php @@ -6,6 +6,7 @@ use Redaxo\Core\Filesystem\File; use Redaxo\Core\Filesystem\Path; use Redaxo\Core\Util\Type; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; @@ -20,30 +21,32 @@ /** * @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, +)] +class ConfigSetCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { #[Override] protected function configure(): void { - $this->setDescription('Set config variables') + $this ->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 - ) ; } diff --git a/src/Console/Command/CronjobRunCommand.php b/src/Console/Command/CronjobRunCommand.php index e64eb7824f..559d7ed1ad 100644 --- a/src/Console/Command/CronjobRunCommand.php +++ b/src/Console/Command/CronjobRunCommand.php @@ -6,6 +6,7 @@ use Redaxo\Core\Core; use Redaxo\Core\Cronjob\CronjobManager; use Redaxo\Core\Database\Sql; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; @@ -20,13 +21,13 @@ /** * @internal */ +#[AsCommand(name: 'cronjob:run', description: 'Executes cronjobs of the "script" environment')] 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) ; } diff --git a/src/Console/Command/DatabaseConnectionOptionsCommand.php b/src/Console/Command/DatabaseConnectionOptionsCommand.php index e970cf600e..8c0dc052a8 100644 --- a/src/Console/Command/DatabaseConnectionOptionsCommand.php +++ b/src/Console/Command/DatabaseConnectionOptionsCommand.php @@ -4,6 +4,7 @@ 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; @@ -11,29 +12,24 @@ /** * @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, +)] +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 { diff --git a/src/Console/Command/DatabaseDumpSchemaCommand.php b/src/Console/Command/DatabaseDumpSchemaCommand.php index cc4e1242bc..a2f3cbc77f 100644 --- a/src/Console/Command/DatabaseDumpSchemaCommand.php +++ b/src/Console/Command/DatabaseDumpSchemaCommand.php @@ -7,6 +7,7 @@ use Redaxo\Core\Database\SchemaDumper; use Redaxo\Core\Database\Sql; use Redaxo\Core\Database\Table; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; @@ -18,13 +19,13 @@ /** * @internal */ +#[AsCommand(name: 'db:dump-schema', description: 'Dumps the schema of db tables as php code')] 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()); }) diff --git a/src/Console/Command/DatabaseSetConnectionCommand.php b/src/Console/Command/DatabaseSetConnectionCommand.php index d68acd55d2..9d2d4f92ba 100644 --- a/src/Console/Command/DatabaseSetConnectionCommand.php +++ b/src/Console/Command/DatabaseSetConnectionCommand.php @@ -6,6 +6,7 @@ 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\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; @@ -15,14 +16,17 @@ /** * @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.', +)] +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') diff --git a/src/Console/Command/MigrateCommand.php b/src/Console/Command/MigrateCommand.php index a275b94430..cbd2993751 100644 --- a/src/Console/Command/MigrateCommand.php +++ b/src/Console/Command/MigrateCommand.php @@ -13,6 +13,7 @@ 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; @@ -23,14 +24,9 @@ /** * @internal */ +#[AsCommand(name: 'migrate', description: 'Runs install scripts of core and addons to ensure schema is up to date')] class MigrateCommand extends AbstractCommand implements StandaloneInterface { - #[Override] - protected function configure(): void - { - $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 { diff --git a/src/Console/Command/SetupCheckCommand.php b/src/Console/Command/SetupCheckCommand.php index 6f7fa58f0f..c8f65fb200 100644 --- a/src/Console/Command/SetupCheckCommand.php +++ b/src/Console/Command/SetupCheckCommand.php @@ -8,6 +8,7 @@ use Redaxo\Core\Filesystem\Path; use Redaxo\Core\Setup\Setup; use Redaxo\Core\Translation\I18n; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -18,16 +19,9 @@ /** * @internal */ -class SetupCheckCommand extends AbstractCommand +#[AsCommand(name: 'setup:check', description: 'Check the commandline interface (CLI) environment for REDAXO requirements')] +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 { diff --git a/src/Console/Command/SetupRunCommand.php b/src/Console/Command/SetupRunCommand.php index f16ee0047d..8c5aad8473 100644 --- a/src/Console/Command/SetupRunCommand.php +++ b/src/Console/Command/SetupRunCommand.php @@ -17,6 +17,7 @@ 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\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; @@ -39,7 +40,8 @@ /** * @internal */ -class SetupRunCommand extends AbstractCommand implements OnlySetupAddonsInterface +#[AsCommand(name: 'setup:run', description: 'Perform redaxo setup')] +class SetupRunCommand extends AbstractCommand implements OnlySetupAddonsInterface, AvailableInSetupInterface { private SymfonyStyle $io; private InputInterface $input; @@ -49,7 +51,6 @@ class SetupRunCommand extends AbstractCommand implements OnlySetupAddonsInterfac 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/"') diff --git a/src/Console/Command/SystemReportCommand.php b/src/Console/Command/SystemReportCommand.php index 5e487d1600..0885137127 100644 --- a/src/Console/Command/SystemReportCommand.php +++ b/src/Console/Command/SystemReportCommand.php @@ -4,6 +4,7 @@ use Override; use Redaxo\Core\SystemReport; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Helper\Table; @@ -21,13 +22,13 @@ /** * @internal */ +#[AsCommand(name: 'system:report', description: 'Shows the system report')] 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']) ; } diff --git a/src/Console/Command/UserCreateCommand.php b/src/Console/Command/UserCreateCommand.php index 512fb0aa7c..72444287c4 100644 --- a/src/Console/Command/UserCreateCommand.php +++ b/src/Console/Command/UserCreateCommand.php @@ -7,6 +7,7 @@ use Redaxo\Core\Database\Sql; use Redaxo\Core\Security\BackendLogin; use Redaxo\Core\Security\BackendPasswordPolicy; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; @@ -19,13 +20,13 @@ /** * @internal */ +#[AsCommand(name: 'user:create', description: 'Create a new user')] 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') diff --git a/src/Console/Command/UserDeleteCommand.php b/src/Console/Command/UserDeleteCommand.php index 7b865bb7b2..829b1b5a29 100644 --- a/src/Console/Command/UserDeleteCommand.php +++ b/src/Console/Command/UserDeleteCommand.php @@ -8,6 +8,7 @@ use Redaxo\Core\ExtensionPoint\Extension; use Redaxo\Core\ExtensionPoint\ExtensionPoint; use Redaxo\Core\Security\User; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -18,13 +19,13 @@ /** * @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'); diff --git a/src/Console/Command/UserListCommand.php b/src/Console/Command/UserListCommand.php index 5295dc5c97..eebd8830c5 100644 --- a/src/Console/Command/UserListCommand.php +++ b/src/Console/Command/UserListCommand.php @@ -5,6 +5,7 @@ use Override; use Redaxo\Core\Core; use Redaxo\Core\Database\Sql; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputArgument; @@ -16,13 +17,13 @@ /** * @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'); diff --git a/src/Console/Command/UserSetPasswordCommand.php b/src/Console/Command/UserSetPasswordCommand.php index 80bf2a02d5..4383d2e51c 100644 --- a/src/Console/Command/UserSetPasswordCommand.php +++ b/src/Console/Command/UserSetPasswordCommand.php @@ -10,6 +10,7 @@ use Redaxo\Core\Security\BackendLogin; use Redaxo\Core\Security\BackendPasswordPolicy; use Redaxo\Core\Security\User; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; @@ -22,13 +23,13 @@ /** * @internal */ +#[AsCommand(name: 'user:set-password', description: 'Sets a new password for a user')] 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'); 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] From bfd9b4d8052280d2d2eee3366fef4421fe79dae8 Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Sat, 30 May 2026 01:16:08 +0200 Subject: [PATCH 2/5] refactor: migrate console commands to invokable __invoke() style Replace configure()/execute() with Symfony 8.1 invokable commands: arguments and options are declared via #[Argument]/#[Option] attributes on the __invoke() parameters, with SymfonyStyle/OutputInterface injected directly. The generated input definitions (names, modes, defaults, shortcuts, descriptions) are unchanged. Completion suggestions are inline `static function` closures right on the attributes (PHP 8.5 allows static closures in constant expressions) - the same inline style the commands used in configure() before, so no separate public suggest* helper methods are needed. AssetsSyncCommand keeps configure() for its runtime-computed help text. The now-unused AbstractCommand::getStyle() helper is removed. Also fixes a pre-existing missing space in UserListCommand's completion query ("SELECT login FROM" . table -> "FROM " . table) that produced invalid SQL. --- .tools/psalm/baseline.xml | 2 +- src/Console/Command/AbstractCommand.php | 8 -- src/Console/Command/AddonActivateCommand.php | 42 +++------- .../Command/AddonDeactivateCommand.php | 42 +++------- src/Console/Command/AddonInstallCommand.php | 46 ++++------- src/Console/Command/AddonListCommand.php | 51 ++++-------- src/Console/Command/AddonUninstallCommand.php | 42 +++------- .../Command/AssetsCompileStylesCommand.php | 8 +- src/Console/Command/AssetsSyncCommand.php | 6 +- src/Console/Command/CacheClearCommand.php | 8 +- src/Console/Command/ConfigGetCommand.php | 32 +++----- src/Console/Command/ConfigSetCommand.php | 36 +++------ src/Console/Command/CronjobRunCommand.php | 25 +++--- .../DatabaseConnectionOptionsCommand.php | 5 +- .../Command/DatabaseDumpSchemaCommand.php | 29 +++---- .../Command/DatabaseSetConnectionCommand.php | 40 ++++------ src/Console/Command/MigrateCommand.php | 9 +-- src/Console/Command/SetupCheckCommand.php | 8 +- src/Console/Command/SetupRunCommand.php | 77 ++++++++----------- src/Console/Command/SystemReportCommand.php | 33 +++----- src/Console/Command/UserCreateCommand.php | 42 ++++------ src/Console/Command/UserDeleteCommand.php | 31 +++----- src/Console/Command/UserListCommand.php | 35 ++++----- .../Command/UserSetPasswordCommand.php | 41 ++++------ 24 files changed, 223 insertions(+), 475 deletions(-) diff --git a/.tools/psalm/baseline.xml b/.tools/psalm/baseline.xml index 0e499978a9..f0a9eeac41 100644 --- a/.tools/psalm/baseline.xml +++ b/.tools/psalm/baseline.xml @@ -1673,7 +1673,7 @@ - getArgument('table')]]> + diff --git a/src/Console/Command/AbstractCommand.php b/src/Console/Command/AbstractCommand.php index a8f9bd4863..44be940d3c 100644 --- a/src/Console/Command/AbstractCommand.php +++ b/src/Console/Command/AbstractCommand.php @@ -6,9 +6,6 @@ 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; @@ -27,11 +24,6 @@ abstract class AbstractCommand extends Command get => $this->addon ??= $this->resolveAddon(); } - protected function getStyle(InputInterface $input, OutputInterface $output): SymfonyStyle - { - return new SymfonyStyle($input, $output); - } - /** * Decodes a html message for use in the CLI, e.g. provided by I18n. * diff --git a/src/Console/Command/AddonActivateCommand.php b/src/Console/Command/AddonActivateCommand.php index 1eddc8240c..c55f1c2aa5 100644 --- a/src/Console/Command/AddonActivateCommand.php +++ b/src/Console/Command/AddonActivateCommand.php @@ -2,14 +2,12 @@ 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 @@ -17,39 +15,19 @@ #[AsCommand(name: 'addon:activate', description: 'Activates the selected addon')] class AddonActivateCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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 1864cebf3f..0969dc7d80 100644 --- a/src/Console/Command/AddonDeactivateCommand.php +++ b/src/Console/Command/AddonDeactivateCommand.php @@ -2,14 +2,12 @@ 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 @@ -17,39 +15,19 @@ #[AsCommand(name: 'addon:deactivate', description: 'Deactivates the selected addon')] class AddonDeactivateCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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 37ab551fec..9f2d45b5a5 100644 --- a/src/Console/Command/AddonInstallCommand.php +++ b/src/Console/Command/AddonInstallCommand.php @@ -2,17 +2,17 @@ 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 @@ -20,41 +20,27 @@ #[AsCommand(name: 'addon:install', description: 'Installs the selected addon')] class AddonInstallCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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 fba3fbeecb..31b34ebf68 100644 --- a/src/Console/Command/AddonListCommand.php +++ b/src/Console/Command/AddonListCommand.php @@ -2,14 +2,12 @@ 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; @@ -19,36 +17,19 @@ #[AsCommand(name: 'addon:list', description: 'List available addons')] class AddonListCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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 = []; @@ -62,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; } @@ -86,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 3d6e319d95..360f9fe81b 100644 --- a/src/Console/Command/AddonUninstallCommand.php +++ b/src/Console/Command/AddonUninstallCommand.php @@ -2,14 +2,12 @@ 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 @@ -17,39 +15,19 @@ #[AsCommand(name: 'addon:uninstall', description: 'Uninstalls the selected addon')] class AddonUninstallCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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 82b97972bd..b2839c126f 100644 --- a/src/Console/Command/AssetsCompileStylesCommand.php +++ b/src/Console/Command/AssetsCompileStylesCommand.php @@ -2,12 +2,10 @@ 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 @@ -15,10 +13,8 @@ #[AsCommand(name: 'assets:compile-styles', description: 'Converts Backend SCSS files to CSS')] class AssetsCompileStylesCommand extends AbstractCommand { - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int + public function __invoke(SymfonyStyle $io): 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 1fd693340c..0888347dd4 100644 --- a/src/Console/Command/AssetsSyncCommand.php +++ b/src/Console/Command/AssetsSyncCommand.php @@ -10,8 +10,6 @@ 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; @@ -33,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/CacheClearCommand.php b/src/Console/Command/CacheClearCommand.php index ab03825491..80bcc65517 100644 --- a/src/Console/Command/CacheClearCommand.php +++ b/src/Console/Command/CacheClearCommand.php @@ -2,12 +2,10 @@ namespace Redaxo\Core\Console\Command; -use Override; use Redaxo\Core\Cache; 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 @@ -15,11 +13,9 @@ #[AsCommand(name: 'cache:clear', description: 'Clears the redaxo core cache')] class CacheClearCommand extends AbstractCommand implements AvailableInSetupInterface { - #[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 30a745f2e7..afcf8d76cd 100644 --- a/src/Console/Command/ConfigGetCommand.php +++ b/src/Console/Command/ConfigGetCommand.php @@ -2,16 +2,15 @@ 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; @@ -21,23 +20,13 @@ #[AsCommand(name: 'config:get', description: 'Get config variables')] class ConfigGetCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { - #[Override] - protected function configure(): void - { - $this - ->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'); } @@ -45,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 d7604c8c5f..45e1f56c7b 100644 --- a/src/Console/Command/ConfigSetCommand.php +++ b/src/Console/Command/ConfigSetCommand.php @@ -2,17 +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; @@ -39,27 +37,13 @@ )] class ConfigSetCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { - #[Override] - protected function configure(): void - { - $this - ->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') - ; - } - - #[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 559d7ed1ad..aafebadafe 100644 --- a/src/Console/Command/CronjobRunCommand.php +++ b/src/Console/Command/CronjobRunCommand.php @@ -2,16 +2,14 @@ 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; @@ -24,22 +22,17 @@ #[AsCommand(name: 'cronjob:run', description: 'Executes cronjobs of the "script" environment')] class CronjobRunCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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( + InputInterface $input, + 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); + // read the raw option value to preserve the original behavior: VALUE_OPTIONAL without + // a value yields null here (not the attribute-resolved bool true) + /** @var bool|string|null $job */ $job = $input->getOption('job'); if (false !== $job) { diff --git a/src/Console/Command/DatabaseConnectionOptionsCommand.php b/src/Console/Command/DatabaseConnectionOptionsCommand.php index 8c0dc052a8..55cc082e86 100644 --- a/src/Console/Command/DatabaseConnectionOptionsCommand.php +++ b/src/Console/Command/DatabaseConnectionOptionsCommand.php @@ -2,11 +2,9 @@ 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; /** @@ -30,8 +28,7 @@ )] class DatabaseConnectionOptionsCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { - #[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 a2f3cbc77f..865c12573b 100644 --- a/src/Console/Command/DatabaseDumpSchemaCommand.php +++ b/src/Console/Command/DatabaseDumpSchemaCommand.php @@ -2,17 +2,16 @@ 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; @@ -22,20 +21,14 @@ #[AsCommand(name: 'db:dump-schema', description: 'Dumps the schema of db tables as php code')] class DatabaseDumpSchemaCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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())); @@ -45,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 9d2d4f92ba..ae0d431916 100644 --- a/src/Console/Command/DatabaseSetConnectionCommand.php +++ b/src/Console/Command/DatabaseSetConnectionCommand.php @@ -2,16 +2,14 @@ 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 @@ -23,41 +21,33 @@ )] class DatabaseSetConnectionCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { - #[Override] - protected function configure(): void - { - $this - ->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; } @@ -77,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/MigrateCommand.php b/src/Console/Command/MigrateCommand.php index cbd2993751..bf3ae210d5 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; @@ -15,8 +14,7 @@ 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; @@ -27,11 +25,8 @@ #[AsCommand(name: 'migrate', description: 'Runs install scripts of core and addons to ensure schema is up to date')] class MigrateCommand extends AbstractCommand implements StandaloneInterface { - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int + public function __invoke(SymfonyStyle $io): 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 c8f65fb200..255a6c3162 100644 --- a/src/Console/Command/SetupCheckCommand.php +++ b/src/Console/Command/SetupCheckCommand.php @@ -2,15 +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\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use function count; @@ -22,11 +20,9 @@ #[AsCommand(name: 'setup:check', description: 'Check the commandline interface (CLI) environment for REDAXO requirements')] class SetupCheckCommand extends AbstractCommand implements AvailableInSetupInterface { - #[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 8c5aad8473..bb7b6fb7c9 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; @@ -18,11 +17,10 @@ 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; @@ -47,37 +45,31 @@ class SetupRunCommand extends AbstractCommand implements OnlySetupAddonsInterfac private InputInterface $input; private bool $forceAsking = false; - #[Override] - protected function configure(): void - { - $this - ->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; @@ -256,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', @@ -282,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; } @@ -322,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 0885137127..0f31c34f96 100644 --- a/src/Console/Command/SystemReportCommand.php +++ b/src/Console/Command/SystemReportCommand.php @@ -2,16 +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; @@ -25,23 +24,15 @@ #[AsCommand(name: 'system:report', description: 'Shows the system report')] class SystemReportCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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(); @@ -52,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 72444287c4..f8e66a8d2c 100644 --- a/src/Console/Command/UserCreateCommand.php +++ b/src/Console/Command/UserCreateCommand.php @@ -2,18 +2,16 @@ 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; @@ -23,25 +21,14 @@ #[AsCommand(name: 'user:create', description: 'Create a new user')] class UserCreateCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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')) @@ -54,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); } @@ -76,7 +62,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int throw new InvalidArgumentException('Missing password.'); } - $name = $input->getOption('name'); if (!$name) { $name = $login; } @@ -84,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 829b1b5a29..2c8e504fd7 100644 --- a/src/Console/Command/UserDeleteCommand.php +++ b/src/Console/Command/UserDeleteCommand.php @@ -2,17 +2,15 @@ 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; @@ -22,23 +20,14 @@ #[AsCommand(name: 'user:delete', description: 'Deletes an user by the specified login name.')] final class UserDeleteCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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 eebd8830c5..7dfab1f7b6 100644 --- a/src/Console/Command/UserListCommand.php +++ b/src/Console/Command/UserListCommand.php @@ -2,15 +2,14 @@ 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; @@ -20,22 +19,14 @@ #[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 - ->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 @@ -47,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 4383d2e51c..f57a4b91d8 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,13 +9,12 @@ 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; @@ -26,25 +24,16 @@ #[AsCommand(name: 'user:set-password', description: 'Sets a new password for a user')] class UserSetPasswordCommand extends AbstractCommand { - #[Override] - protected function configure(): void - { - $this - ->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 @@ -61,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); } @@ -94,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', '', [ From c617bade6e26004769e69422dd1d47bd6b485309 Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Fri, 29 May 2026 23:47:03 +0200 Subject: [PATCH 3/5] fix(console): restore interactive job picker for "cronjob:run --job" Running "cronjob:run --job" without a value is documented to let you pick a job interactively, but since 6.x it errored out instead: the option value was cast with (int), turning the value-less null into 0, so executeSingleJob() never received null and the interactive ChoiceQuestion branch was unreachable. Use the typed bool|string option value directly - a value-less --job resolves to true, which now maps to null (interactive selection); a given id is cast to int as before. Restores the 5.x behavior. --- src/Console/Command/CronjobRunCommand.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Console/Command/CronjobRunCommand.php b/src/Console/Command/CronjobRunCommand.php index aafebadafe..88869eda93 100644 --- a/src/Console/Command/CronjobRunCommand.php +++ b/src/Console/Command/CronjobRunCommand.php @@ -9,7 +9,6 @@ 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\Question\ChoiceQuestion; use Symfony\Component\Console\Style\SymfonyStyle; @@ -23,20 +22,15 @@ class CronjobRunCommand extends AbstractCommand { public function __invoke( - InputInterface $input, 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); - // read the raw option value to preserve the original behavior: VALUE_OPTIONAL without - // a value yields null here (not the attribute-resolved bool true) - /** @var bool|string|null $job */ - $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(); From fcd5ed6c28ceb7f864495a49af3b14b13c7e14f2 Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Sat, 30 May 2026 01:17:37 +0200 Subject: [PATCH 4/5] refactor: make console command classes final The concrete command classes are @internal leaf classes that are instantiated via the command loader, never extended. Marking them final makes that explicit, finishes what UserListCommand/UserDeleteCommand already started, and clears the ClassMustBeFinal hints. AbstractCommand stays abstract. --- src/Console/Command/AddonActivateCommand.php | 2 +- src/Console/Command/AddonDeactivateCommand.php | 2 +- src/Console/Command/AddonInstallCommand.php | 2 +- src/Console/Command/AddonListCommand.php | 2 +- src/Console/Command/AddonUninstallCommand.php | 2 +- src/Console/Command/AssetsCompileStylesCommand.php | 2 +- src/Console/Command/AssetsSyncCommand.php | 2 +- src/Console/Command/CacheClearCommand.php | 2 +- src/Console/Command/ConfigGetCommand.php | 2 +- src/Console/Command/ConfigSetCommand.php | 2 +- src/Console/Command/CronjobRunCommand.php | 2 +- src/Console/Command/DatabaseConnectionOptionsCommand.php | 2 +- src/Console/Command/DatabaseDumpSchemaCommand.php | 2 +- src/Console/Command/DatabaseSetConnectionCommand.php | 2 +- src/Console/Command/ListCommand.php | 2 +- src/Console/Command/MigrateCommand.php | 2 +- src/Console/Command/SetupCheckCommand.php | 2 +- src/Console/Command/SetupRunCommand.php | 2 +- src/Console/Command/SystemReportCommand.php | 2 +- src/Console/Command/UserCreateCommand.php | 2 +- src/Console/Command/UserSetPasswordCommand.php | 2 +- 21 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Console/Command/AddonActivateCommand.php b/src/Console/Command/AddonActivateCommand.php index c55f1c2aa5..d04fc049d7 100644 --- a/src/Console/Command/AddonActivateCommand.php +++ b/src/Console/Command/AddonActivateCommand.php @@ -13,7 +13,7 @@ * @internal */ #[AsCommand(name: 'addon:activate', description: 'Activates the selected addon')] -class AddonActivateCommand extends AbstractCommand +final class AddonActivateCommand extends AbstractCommand { public function __invoke( SymfonyStyle $io, diff --git a/src/Console/Command/AddonDeactivateCommand.php b/src/Console/Command/AddonDeactivateCommand.php index 0969dc7d80..aee5a6ded1 100644 --- a/src/Console/Command/AddonDeactivateCommand.php +++ b/src/Console/Command/AddonDeactivateCommand.php @@ -13,7 +13,7 @@ * @internal */ #[AsCommand(name: 'addon:deactivate', description: 'Deactivates the selected addon')] -class AddonDeactivateCommand extends AbstractCommand +final class AddonDeactivateCommand extends AbstractCommand { public function __invoke( SymfonyStyle $io, diff --git a/src/Console/Command/AddonInstallCommand.php b/src/Console/Command/AddonInstallCommand.php index 9f2d45b5a5..2a45c572ec 100644 --- a/src/Console/Command/AddonInstallCommand.php +++ b/src/Console/Command/AddonInstallCommand.php @@ -18,7 +18,7 @@ * @internal */ #[AsCommand(name: 'addon:install', description: 'Installs the selected addon')] -class AddonInstallCommand extends AbstractCommand +final class AddonInstallCommand extends AbstractCommand { public function __invoke( InputInterface $input, diff --git a/src/Console/Command/AddonListCommand.php b/src/Console/Command/AddonListCommand.php index 31b34ebf68..fc4d68952a 100644 --- a/src/Console/Command/AddonListCommand.php +++ b/src/Console/Command/AddonListCommand.php @@ -15,7 +15,7 @@ * @internal */ #[AsCommand(name: 'addon:list', description: 'List available addons')] -class AddonListCommand extends AbstractCommand +final class AddonListCommand extends AbstractCommand { public function __invoke( SymfonyStyle $io, diff --git a/src/Console/Command/AddonUninstallCommand.php b/src/Console/Command/AddonUninstallCommand.php index 360f9fe81b..ed0e3189e2 100644 --- a/src/Console/Command/AddonUninstallCommand.php +++ b/src/Console/Command/AddonUninstallCommand.php @@ -13,7 +13,7 @@ * @internal */ #[AsCommand(name: 'addon:uninstall', description: 'Uninstalls the selected addon')] -class AddonUninstallCommand extends AbstractCommand +final class AddonUninstallCommand extends AbstractCommand { public function __invoke( SymfonyStyle $io, diff --git a/src/Console/Command/AssetsCompileStylesCommand.php b/src/Console/Command/AssetsCompileStylesCommand.php index b2839c126f..5aa95b8674 100644 --- a/src/Console/Command/AssetsCompileStylesCommand.php +++ b/src/Console/Command/AssetsCompileStylesCommand.php @@ -11,7 +11,7 @@ * @internal */ #[AsCommand(name: 'assets:compile-styles', description: 'Converts Backend SCSS files to CSS')] -class AssetsCompileStylesCommand extends AbstractCommand +final class AssetsCompileStylesCommand extends AbstractCommand { public function __invoke(SymfonyStyle $io): int { diff --git a/src/Console/Command/AssetsSyncCommand.php b/src/Console/Command/AssetsSyncCommand.php index 0888347dd4..14d1e4dcb9 100644 --- a/src/Console/Command/AssetsSyncCommand.php +++ b/src/Console/Command/AssetsSyncCommand.php @@ -18,7 +18,7 @@ * @internal */ #[AsCommand(name: 'assets:sync', description: 'Sync assets within the assets-dir with the sources-dir')] -class AssetsSyncCommand extends AbstractCommand +final class AssetsSyncCommand extends AbstractCommand { #[Override] protected function configure(): void diff --git a/src/Console/Command/CacheClearCommand.php b/src/Console/Command/CacheClearCommand.php index 80bcc65517..9ea2d5c07b 100644 --- a/src/Console/Command/CacheClearCommand.php +++ b/src/Console/Command/CacheClearCommand.php @@ -11,7 +11,7 @@ * @internal */ #[AsCommand(name: 'cache:clear', description: 'Clears the redaxo core cache')] -class CacheClearCommand extends AbstractCommand implements AvailableInSetupInterface +final class CacheClearCommand extends AbstractCommand implements AvailableInSetupInterface { public function __invoke(SymfonyStyle $io): int { diff --git a/src/Console/Command/ConfigGetCommand.php b/src/Console/Command/ConfigGetCommand.php index afcf8d76cd..8df95436d6 100644 --- a/src/Console/Command/ConfigGetCommand.php +++ b/src/Console/Command/ConfigGetCommand.php @@ -18,7 +18,7 @@ * @internal */ #[AsCommand(name: 'config:get', description: 'Get config variables')] -class ConfigGetCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface +final class ConfigGetCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { public function __invoke( SymfonyStyle $io, diff --git a/src/Console/Command/ConfigSetCommand.php b/src/Console/Command/ConfigSetCommand.php index 45e1f56c7b..8aa2b8052f 100644 --- a/src/Console/Command/ConfigSetCommand.php +++ b/src/Console/Command/ConfigSetCommand.php @@ -35,7 +35,7 @@ %command.full_name% error_email mail@example.org EOF, )] -class ConfigSetCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface +final class ConfigSetCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { public function __invoke( SymfonyStyle $io, diff --git a/src/Console/Command/CronjobRunCommand.php b/src/Console/Command/CronjobRunCommand.php index 88869eda93..dcb6fc8e24 100644 --- a/src/Console/Command/CronjobRunCommand.php +++ b/src/Console/Command/CronjobRunCommand.php @@ -19,7 +19,7 @@ * @internal */ #[AsCommand(name: 'cronjob:run', description: 'Executes cronjobs of the "script" environment')] -class CronjobRunCommand extends AbstractCommand +final class CronjobRunCommand extends AbstractCommand { public function __invoke( SymfonyStyle $io, diff --git a/src/Console/Command/DatabaseConnectionOptionsCommand.php b/src/Console/Command/DatabaseConnectionOptionsCommand.php index 55cc082e86..05e74fbcd3 100644 --- a/src/Console/Command/DatabaseConnectionOptionsCommand.php +++ b/src/Console/Command/DatabaseConnectionOptionsCommand.php @@ -26,7 +26,7 @@ %command.full_name% | xargs sh -c 'mysql "$0" "$@" < dump.sql' EOF, )] -class DatabaseConnectionOptionsCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface +final class DatabaseConnectionOptionsCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { public function __invoke(OutputInterface $output): int { diff --git a/src/Console/Command/DatabaseDumpSchemaCommand.php b/src/Console/Command/DatabaseDumpSchemaCommand.php index 865c12573b..23232c59a0 100644 --- a/src/Console/Command/DatabaseDumpSchemaCommand.php +++ b/src/Console/Command/DatabaseDumpSchemaCommand.php @@ -19,7 +19,7 @@ * @internal */ #[AsCommand(name: 'db:dump-schema', description: 'Dumps the schema of db tables as php code')] -class DatabaseDumpSchemaCommand extends AbstractCommand +final class DatabaseDumpSchemaCommand extends AbstractCommand { public function __invoke( OutputInterface $output, diff --git a/src/Console/Command/DatabaseSetConnectionCommand.php b/src/Console/Command/DatabaseSetConnectionCommand.php index ae0d431916..61fcd657c5 100644 --- a/src/Console/Command/DatabaseSetConnectionCommand.php +++ b/src/Console/Command/DatabaseSetConnectionCommand.php @@ -19,7 +19,7 @@ description: 'Sets database connection credentials.', help: 'Checks by default if a database connection can be established with the new settings.', )] -class DatabaseSetConnectionCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface +final class DatabaseSetConnectionCommand extends AbstractCommand implements StandaloneInterface, AvailableInSetupInterface { public function __invoke( SymfonyStyle $io, 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 bf3ae210d5..0db55f92a2 100644 --- a/src/Console/Command/MigrateCommand.php +++ b/src/Console/Command/MigrateCommand.php @@ -23,7 +23,7 @@ * @internal */ #[AsCommand(name: 'migrate', description: 'Runs install scripts of core and addons to ensure schema is up to date')] -class MigrateCommand extends AbstractCommand implements StandaloneInterface +final class MigrateCommand extends AbstractCommand implements StandaloneInterface { public function __invoke(SymfonyStyle $io): int { diff --git a/src/Console/Command/SetupCheckCommand.php b/src/Console/Command/SetupCheckCommand.php index 255a6c3162..8b6b54cea3 100644 --- a/src/Console/Command/SetupCheckCommand.php +++ b/src/Console/Command/SetupCheckCommand.php @@ -18,7 +18,7 @@ * @internal */ #[AsCommand(name: 'setup:check', description: 'Check the commandline interface (CLI) environment for REDAXO requirements')] -class SetupCheckCommand extends AbstractCommand implements AvailableInSetupInterface +final class SetupCheckCommand extends AbstractCommand implements AvailableInSetupInterface { public function __invoke(SymfonyStyle $io): int { diff --git a/src/Console/Command/SetupRunCommand.php b/src/Console/Command/SetupRunCommand.php index bb7b6fb7c9..bd021b172a 100644 --- a/src/Console/Command/SetupRunCommand.php +++ b/src/Console/Command/SetupRunCommand.php @@ -39,7 +39,7 @@ * @internal */ #[AsCommand(name: 'setup:run', description: 'Perform redaxo setup')] -class SetupRunCommand extends AbstractCommand implements OnlySetupAddonsInterface, AvailableInSetupInterface +final class SetupRunCommand extends AbstractCommand implements OnlySetupAddonsInterface, AvailableInSetupInterface { private SymfonyStyle $io; private InputInterface $input; diff --git a/src/Console/Command/SystemReportCommand.php b/src/Console/Command/SystemReportCommand.php index 0f31c34f96..0bf0584419 100644 --- a/src/Console/Command/SystemReportCommand.php +++ b/src/Console/Command/SystemReportCommand.php @@ -22,7 +22,7 @@ * @internal */ #[AsCommand(name: 'system:report', description: 'Shows the system report')] -class SystemReportCommand extends AbstractCommand +final class SystemReportCommand extends AbstractCommand { private const array FORMATS = ['cli', 'markdown']; diff --git a/src/Console/Command/UserCreateCommand.php b/src/Console/Command/UserCreateCommand.php index f8e66a8d2c..00fd39dfae 100644 --- a/src/Console/Command/UserCreateCommand.php +++ b/src/Console/Command/UserCreateCommand.php @@ -19,7 +19,7 @@ * @internal */ #[AsCommand(name: 'user:create', description: 'Create a new user')] -class UserCreateCommand extends AbstractCommand +final class UserCreateCommand extends AbstractCommand { public function __invoke( SymfonyStyle $io, diff --git a/src/Console/Command/UserSetPasswordCommand.php b/src/Console/Command/UserSetPasswordCommand.php index f57a4b91d8..6e7005ab15 100644 --- a/src/Console/Command/UserSetPasswordCommand.php +++ b/src/Console/Command/UserSetPasswordCommand.php @@ -22,7 +22,7 @@ * @internal */ #[AsCommand(name: 'user:set-password', description: 'Sets a new password for a user')] -class UserSetPasswordCommand extends AbstractCommand +final class UserSetPasswordCommand extends AbstractCommand { public function __invoke( SymfonyStyle $io, From 9bd53dca66a8c2d18b66b87fc5a0e2128dd2fa64 Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Sat, 30 May 2026 09:36:16 +0200 Subject: [PATCH 5/5] test: adjust config command tests to renamed "key" argument The config:get/set argument was renamed from "config-key" to "key" (a breaking change; positional invocation is unaffected). Update the command tests to pass the argument under its new name. --- tests/Console/Command/ConfigGetCommandTest.php | 6 +++--- tests/Console/Command/ConfigSetCommandTest.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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'));