diff --git a/.circleci/config.yml b/.circleci/config.yml index bcc6ceeaa5..60d83153d8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,8 @@ defaults: &defaults PHPUNIT_ARGS: "" PHP_SENDMAIL_PATH: /dev/null # https://drupal.slack.com/archives/CGKLP028K/p1702031434143329?thread_ts=1702024395.751479&cid=CGKLP028K - WODBY_TAG: 8.2 + WODBY_TAG: 8.3 + WODBY_LATEST: 8.4 # These are not working. We disable the mode in .docker/zz-php.ini PHP_EXTENSIONS_DISABLE: xdebug PHP_XDEBUG_MODE: off @@ -39,7 +40,7 @@ executors: - "UNISH_DB_URL=sqlite://localhost/:memory:?module=sqlite" sqlite-highest: docker: - - image: wodby/php:latest + - image: wodby/php:$WODBY_LATEST environment: - "UNISH_DB_URL=sqlite://localhost/:memory:?module=sqlite" mysql-lowest: @@ -51,7 +52,7 @@ executors: - image: cimg/mysql:5.7.38 mysql-highest: docker: - - image: wodby/php:latest + - image: wodby/php:$WODBY_LATEST environment: - MYSQL_HOST=127.0.0.1 - UNISH_DB_URL=mysql://root:@127.0.0.1/unish_dev?module=mysql @@ -69,7 +70,7 @@ executors: POSTGRES_USER: unish postgres-highest: docker: - - image: wodby/php:latest + - image: wodby/php:$WODBY_LATEST environment: - UNISH_DB_URL=pgsql://unish:unish@127.0.0.1/unish_dev?module=pgsql - image: wodby/postgres:latest @@ -87,7 +88,7 @@ jobs: code_style: <<: *defaults docker: - - image: wodby/php:8.2 + - image: wodby/php:$WODBY_LATEST steps: - checkout - run: cp .docker/zz-php.ini /usr/local/etc/php/conf.d/ @@ -136,9 +137,7 @@ jobs: and: - equal: [ lowest, << parameters.release >> ] steps: - - run: composer -n config platform.php --unset - - run: composer -n require --dev drupal/core-recommended:11.x-dev --no-update - - run: composer -n update --with-all-dependencies + - run: $HOME/drush/.circleci/highest.sh - run: composer -n unit -- --log-junit /tmp/results/unit/junit.xml - run: composer -n << parameters.suite >> -- --log-junit /tmp/results/<< parameters.suite >>/junit.xml diff --git a/.circleci/highest.sh b/.circleci/highest.sh new file mode 100755 index 0000000000..22370c4aa5 --- /dev/null +++ b/.circleci/highest.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +composer -n config platform.php --unset +composer -n require --dev drupal/core-recommended:11.x-dev --no-update +composer -n update --with-all-dependencies diff --git a/.ddev/web-build/Dockerfile b/.ddev/web-build/Dockerfile deleted file mode 100644 index 0101bec1f1..0000000000 --- a/.ddev/web-build/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -ARG BASE_IMAGE -FROM $BASE_IMAGE - -# Install Autocast https://github.com/k9withabone/autocast/tree/main#installation -# https://stackoverflow.com/questions/67092242/how-can-i-add-to-the-path-in-the-ddev-web-container-for-drush-for-example -RUN echo 'export PATH="$PATH:$HOME/.cargo/bin"' >/etc/bashrc/commandline-addons.bashrc -# https://ddev.readthedocs.io/en/latest/users/extend/customizing-images/#adding-extra-dockerfiles-for-webimage-and-dbimage -USER $uid -RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash -RUN cargo-binstall --no-confirm autocast -USER root diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index d171f11f0c..0000000000 --- a/appveyor.yml +++ /dev/null @@ -1,80 +0,0 @@ -build: false -shallow_clone: false -platform: 'x86' -clone_folder: C:\projects\work -branches: - only: - - 13.x - - ## Cache composer bits -cache: - - '%LOCALAPPDATA%\Composer\files -> composer.lock' - -services: - - mysql - -init: - #https://github.com/composer/composer/blob/master/appveyor.yml - #- SET ANSICON=121x90 (121x90) - -# Inspired by https://github.com/Codeception/base/blob/master/appveyor.yml and https://github.com/phpmd/phpmd/blob/master/appveyor.yml -install: - - ps: Set-Service wuauserv -StartupType Manual - - git clone -q https://github.com/acquia/DevDesktopCommon.git #For tar, cksum, ... - - SET PATH=%APPVEYOR_BUILD_FOLDER%/DevDesktopCommon/bintools-win/msys/bin;%PATH% - - SET PATH=C:\Program Files\MySql\MySQL Server 5.7\bin\;%PATH% - - choco search php --exact --all-versions -r - #Install PHP per https://blog.wyrihaximus.net/2016/11/running-php-unit-tests-on-windows-using-appveyor-and-chocolatey/ - - ps: appveyor-retry cinst --limit-output --ignore-checksums -y php --version 8.2.18 - - cd c:\tools\php82 - - copy php.ini-production php.ini - - # https://github.com/php-coveralls/php-coveralls/pull/223/files - # - appveyor DownloadFile https://curl.se/ca/cacert.pem -FileName C:\cacert.pem - - curl -fsS -o C:\cacert.pem https://curl.se/ca/cacert.pem - - echo curl.cainfo=C:\cacert.pem >> php.ini - - - echo extension_dir=ext >> php.ini - - echo extension=php_openssl.dll >> php.ini - - echo date.timezone="UTC" >> php.ini - - echo variables_order="EGPCS" >> php.ini #May be unneeded. - - echo mbstring.http_input=pass >> php.ini - - echo mbstring.http_output=pass >> php.ini - - echo sendmail_path=nul >> php.ini - - echo extension=php_mbstring.dll >> php.ini - - echo extension=php_curl.dll >> php.ini - - echo extension=php_pdo_mysql.dll >> php.ini - - echo extension=php_pdo_pgsql.dll >> php.ini - - echo extension=php_pdo_sqlite.dll >> php.ini - - echo extension=php_pgsql.dll >> php.ini - - echo extension=php_gd.dll >> php.ini - - echo extension=php_fileinfo.dll >> php.ini - - echo memory_limit=256M >> php.ini - - SET PATH=C:\tools\php82;%PATH% - #Install Composer - - cd %APPVEYOR_BUILD_FOLDER% - #- appveyor DownloadFile https://getcomposer.org/composer.phar - - php -r "readfile('http://getcomposer.org/installer');" | php - - echo @php %cd%\composer.phar %%* > composer.bat - # Install dependencies via Composer. - - php composer.phar install --prefer-dist -n - - SET PATH=%APPVEYOR_BUILD_FOLDER%;%APPVEYOR_BUILD_FOLDER%/vendor/bin;%PATH% - # Uncomment this and on_finish line below to enable RDP into build machine https://www.appveyor.com/docs/how-to/rdp-to-build-worker/ - # - ps: iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) - -test_script: - - composer info phpunit/phpunit - - vendor/bin/phpunit --colors=always --configuration tests --testsuite functional - - vendor/bin/phpunit --colors=always --configuration tests --testsuite integration - - vendor/bin/phpunit --colors=always --configuration tests --testsuite unit - -on_finish: - # Uncomment this and above line to enable RDP into build machine https://www.appveyor.com/docs/how-to/rdp-to-build-worker/ - # - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) - -# environment variables -environment: - global: -# php_ver_target: 7.3 - UNISH_DB_URL: "mysql://root:Password12!@localhost/unish_dev?module=mysql" - APPVEYOR_RDP_PASSWORD: un1sh@Windows diff --git a/composer.json b/composer.json index 5a6ba31330..32b5c1425d 100644 --- a/composer.json +++ b/composer.json @@ -31,20 +31,20 @@ } }, "require": { - "php": ">=8.2", + "php": ">=8.3", "ext-dom": "*", "composer-runtime-api": "^2.2", "chi-teck/drupal-code-generator": "^3.6 || ^4@alpha", "composer/semver": "^1.4 || ^3", - "consolidation/annotated-command": "^4.9.2", - "consolidation/config": "^2.1.2 || ^3", + "consolidation/annotated-command": "^4.10.2", + "consolidation/config": "^2.1.2 || ^3.1.1", "consolidation/filter-via-dot-access-data": "^2.0.2", - "consolidation/output-formatters": "^4.3.2", - "consolidation/robo": "^4.0.6 || ^5", - "consolidation/site-alias": "^4", - "consolidation/site-process": "^5.2.0", + "consolidation/output-formatters": "^4.6.1", + "consolidation/robo": "^4.0.6 || ^5.1.0", + "consolidation/site-alias": "^4.1.1", + "consolidation/site-process": "^5.4.2", "dflydev/dot-access-data": "^3.0.2", - "grasmash/yaml-cli": "^3.1", + "grasmash/yaml-cli": "^3.2", "guzzlehttp/guzzle": "^7.0", "laravel/prompts": "^0.3.5", "league/container": "^4.2", @@ -58,16 +58,16 @@ "require-dev": { "composer/installers": "^2", "cweagans/composer-patches": "~1.7.3", - "drupal/core-recommended": "^10.3.0 || 11.x-dev", + "drupal/core-recommended": "^10.4.0 || 11.x-dev", "drupal/semver_example": "2.3.0", "jetbrains/phpstorm-attributes": "^1.0", - "mglaman/phpstan-drupal": "^1.2", + "mglaman/phpstan-drupal": "^2", "phpunit/phpunit": "^9 || ^10 || ^11", - "rector/rector": "^1", + "rector/rector": "^2", "squizlabs/php_codesniffer": "^3.7" }, "conflict": { - "drupal/core": "< 10.2", + "drupal/core": "< 10.4", "drupal/migrate_run": "*", "drupal/migrate_tools": "<= 5" }, @@ -97,7 +97,7 @@ "sort-packages": true, "process-timeout": 9600, "platform": { - "php": "8.2" + "php": "8.3" } }, "scripts": { @@ -134,6 +134,7 @@ "sut/profiles/contrib/{$name}": ["type:drupal-profile"], "sut/themes/contrib/{$name}": ["type:drupal-theme"], "sut/drush/contrib/{$name}": ["type:drupal-drush"] - } + }, + "patches-file": "composer.patches.json" } } diff --git a/composer.patches.json b/composer.patches.json new file mode 100644 index 0000000000..b7805831b3 --- /dev/null +++ b/composer.patches.json @@ -0,0 +1,4 @@ +{ + "patches": { + } +} diff --git a/docs/commands.md b/docs/commands.md index 4ad62fc748..dfe2ffd5c2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,8 +2,8 @@ !!! tip - 1. Drush 13 expects commandfiles to use the [AutowireTrait](https://github.com/drush-ops/drush/blob/13.x/src/Commands/AutowireTrait.php) to inject Drupal and Drush dependencies. Prior versions used a [drush.services.yml file](https://www.drush.org/11.x/dependency-injection/#services-files) which is now deprecated and will be removed in Drush 14. - 1. Drush 12 expects all commandfiles in the `/src/Drush/` directory. The `Drush` subdirectory is a new requirement. + 1. Drush 13+ expects commandfiles to use the [AutowireTrait](https://github.com/drush-ops/drush/blob/13.x/src/Commands/AutowireTrait.php) to inject Drupal and Drush dependencies. Prior versions used a [drush.services.yml file](https://www.drush.org/11.x/dependency-injection/#services-files) which is now deprecated. + 1. Drush 12+ expects all commandfiles in the `/src/Drush/` directory. The `Drush` subdirectory is a new requirement. Creating a new Drush command is easy. Follow the steps below. @@ -14,18 +14,80 @@ Creating a new Drush command is easy. Follow the steps below. 5. You may [inject dependencies](dependency-injection.md) into a command instance. 6. Write PHPUnit tests based on [Drush Test Traits](https://github.com/drush-ops/drush/blob/13.x/docs/contribute/unish.md#drush-test-traits). -## Attributes or Annotations -The following are both valid ways to declare a command: +## Four ways to declare a command +The following are supported ways to declare a command. + +=== "Console, _Recommended_" + + ```php + namespace Drupal\[module-name]\Drush\Commands; + + use Consolidation\OutputFormatters\FormatterManager; + use Consolidation\OutputFormatters\StructuredData\RowsOfFields; + use Drupal\Core\Template\TwigEnvironment; + use Drush\Attributes as CLI; + use Drush\Commands\AutowireTrait; + use Drush\Formatters\FormatterTrait; + use Psr\Log\LoggerInterface; + 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; + + #[AsCommand( + name: self::NAME, + description: 'Find potentially unused Twig templates.', + aliases: ['twu'], + )] + #[CLI\FieldLabels(labels: ['template' => 'Template', 'compiled' => 'Compiled'])] + #[CLI\DefaultTableFields(fields: ['template', 'compiled'])] + #[CLI\FilterDefaultField(field: 'template')] + #[CLI\Formatter(returnType: RowsOfFields::class, defaultFormatter: 'table')] + final class TwigUnusedCommand extends Command + { + use AutowireTrait; + use FormatterTrait; + + public const NAME = 'twig:unused'; + + public function __construct( + protected readonly FormatterManager $formatterManager, + protected readonly TwigEnvironment $twig, + private readonly LoggerInterface $logger + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setHelp('Immediately before running this command, web crawl your entire web site.') + ->addArgument('searchpaths', InputArgument::REQUIRED, 'A comma delimited list of paths to recursively search.') + ->addUsage('twig:unused /var/www/mass.local/docroot/modules/custom'); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $data = $this->doExecute($input, $output, $input->getArgument('searchpaths')); + $this->writeFormattedOutput($input, $output, $data); + return Command::SUCCESS; + } + + public function doExecute(InputInterface $input, OutputInterface $output, string $searchpaths): RowsOfFields + { + $this->logger->notice('Found {count} unused', ['count' => count($rows)]); + return new RowsOfFields($unused); + } + ``` + +=== "Annotated (Attributes), _Deprecated_" -=== "PHP8 Attributes" - ```php use Drush\Attributes as CLI; /** - * Retrieve and display xkcd cartoons (attribute variant). + * Retrieve and display xkcd cartoons */ - #[CLI\Command(name: 'xkcd:fetch-attributes', aliases: ['xkcd-attributes'])] + #[CLI\Command(name: 'xkcd:fetch', aliases: ['xkcd'])] #[CLI\Argument(name: 'search', description: 'Optional argument to retrieve the cartoons matching an index, keyword, or "random".')] #[CLI\Option(name: 'image-viewer', description: 'Command to use to view images (e.g. xv, firefox).', suggestedValues: ['open', 'xv', 'firefox'])] #[CLI\Option(name: 'google-custom-search-api-key', description: 'Google Custom Search API Key')] @@ -36,8 +98,8 @@ The following are both valid ways to declare a command: } ``` -=== "Annotations" - +=== "Annotated Command, _Deprecated_" + ```php /** * @command xkcd:fetch @@ -55,26 +117,83 @@ The following are both valid ways to declare a command: } ``` -- A commandfile that will only be used on PHP8+ should [use PHP Attributes](https://github.com/drush-ops/drush/pull/4821) instead of Annotations. -- [See Attributes provided by Drush core](https://www.drush.org/api/Drush/Attributes.html). Custom code can supply additional Attribute classes, which may then be added to any command. For example see [InteractConfigName](https://github.com/drush-ops/drush/blob/13.x/src/Attributes/InteractConfigName.php) which is used by [ConfigCommands](https://github.com/drush-ops/drush/blob/8b77c9abe6639de42a198c7e69565f09dcf5f22d/src/Commands/config/ConfigCommands.php#L98). +=== "Console (Invokable), Symfony 7.4+" -## Altering Command Info -Drush command info (annotations/attributes) can be altered from other modules. This is done by creating and registering _command info alterers_. Alterers are classes that are able to intercept and manipulate an existing command annotation. + ```php + declare(strict_types=1); + + namespace Drupal\woot\Drush\Commands; + + 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\Output\OutputInterface; + + #[AsCommand( + name: self::NAME, + description: 'This command will concatenate two parameters.', + aliases: ['my-cat'], + help: 'If the --flip flag is provided, then the result is the concatenation of two and one.', + usages: ['bet alpha --flip'], + )] + final class MyCatCommand { + + const NAME = 'my:cat'; + + public function __invoke( + OutputInterface $output, + #[Argument('The first parameter.')] string $one, + #[Argument('The second parameter.')] string $two, + #[Option('Whether or not the second parameter should come first in the result')] bool $flip = FALSE, + ): int + { + if ($flip) { + $output->writeln("{$two}{$one}"); + } + else { + $output->writeln("{$one}{$two}"); + } + return Command::SUCCESS; + } + } + ``` -In the module that wants to alter a command info, add a class that: +Drush 13.7 deprecates Annotated Commands in favor of pure [Symfony Console commands](https://symfony.com/doc/current/console.html). This implies: + +- Each command lives in its own class file +- The command class extends `Symfony\Component\Console\Command\Command` directly. The base class `DrushCommands` is deprecated. +- The command class should use Console's `#[AsCommand]` Attribute to declare its name, aliases, and hidden status. The `#[Command]` Attribute is deprecated. +- Options and Arguments moved from Attributes to a `configure()` method on the command class +- User interaction now happens in an `interact()` method on the command class. +- Drush and Drupal services may be autowired. See [Dependency Injection](dependency-injection.md). +- The main logic of the command moves to an execute() method on the command class. +- Commands that wish to offer multiple _output formats_ (yes please!): + - See [TwigUnusedCommand](https://www.drush.org/latest/commands/twig_unused/)] or [SqlDumpCommand](https://www.drush.org/latest/commands/sql_dump/) as examples. + - Implement the [Formatter Attribute](https://github.com/drush-ops/drush/blob/13.x/src/Attributes/Formatter.php). + - Command class should `use \Drush\Formatters\FormatterTrait` + - `execute()` is largely boilerplate. See examples above. By convention, do your work in a `doExecute()` method instead. +- Add the following snippet to your project's composer.json. +```json +"conflict": { + "drush/drush": "<13.7" +}, +``` +- [Numerous Optionset and Validate Attributes are provided by Drush core](https://github.com/drush-ops/drush/blob/13.x/src/Attributes). Custom code can supply additional Attributes+Listeners, which any command may choose to use. + +## Altering Command Info -1. The class namespace, relative to base namespace, should be `Drupal\\Drush\CommandInfoAlterers` and the class file should be located under the `src/Drush/CommandInfoAlterers` directory. -1. The filename must have a name like FooCommandInfoAlterer.php. The prefix `Foo` can be whatever string you want. The file must end in `CommandInfoAlterer.php`. -1. The class must implement the `\Consolidation\AnnotatedCommand\CommandInfoAltererInterface`. -1. Implement the alteration logic in the `alterCommandInfo()` method. -1. Along with the alter code, it's strongly recommended to log a debug message explaining what exactly was altered. This makes things easier on others who may need to debug the interaction of the alter code with other modules. Also it's a good practice to inject the the logger in the class constructor. +Drush command info can be altered from other modules. This is done by creating and registering a command definition listener. Listeners are dispatched once after non-bootstrap commands are instantiated and once again after bootstrap commands are instantiated. -For an example, see [WootCommandInfoAlterer](https://github.com/drush-ops/drush/blob/13.x/sut/modules/unish/woot/src/Drush/CommandInfoAlterers/WootCommandInfoAlterer.php) provided by the testing 'woot' module. +In the module that wants to alter command info, add a class that: -## Symfony Console Commands +1. The class namespace, relative to base namespace, should be `Drupal\\Drush\Listeners` and the class file should be located under the `src/Drush/Listeners` directory. +1. The filename must have a name like FooListener.php. The prefix `Foo` can be whatever string you want. The file must end in `Listener.php`. +1. The class should implement the `#[AsListener]` PHP Attribute. +1. Implement the alteration logic via a `__invoke(ConsoleDefinitionsEvent $event)` method. +1. Along with the alter code, it's recommended to log a debug message explaining what exactly was altered. This makes things easier on others who may need to debug the interaction of the alter code with other modules. Also, it's a good practice to inject the logger in the class constructor. -Drush lists and runs Symfony Console commands, in addition to more typical annotated commands. -See [GreetCommands](https://github.com/drush-ops/drush/blob/13.x/sut/modules/unish/woot/src/Drush/Commands/GreetCommands.php) as an example. Note that these commands must conform to the usual class name and class namespace requirements. You might need to extend the Console class if you can't rename and move it. +For an example, see [WootDefinitionListener](https://github.com/drush-ops/drush/blob/13.x/sut/modules/unish/woot/src/Drush/Listeners/WootDefinitionListener.php) provided by the testing 'woot' module. ## Auto-discovered commands (PSR4) diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index 4222124214..ecbaf13428 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -5,14 +5,22 @@ Drush command files obtain references to the resources they need through a techn !!! tip - Drush 11 and prior required [dependency injection via a drush.services.yml file](https://www.drush.org/11.x/dependency-injection/#services-files). This approach is deprecated in Drush 12+ and removed in Drush 14. + Drush 11 and prior required [dependency injection via a drush.services.yml file](https://www.drush.org/11.x/dependency-injection/#services-files). This approach is deprecated in Drush 12+. Autowire ------------------ :octicons-tag-24: 12.5+ -Command files may inject Drush and Drupal services by adding the [AutowireTrait](https://github.com/drush-ops/drush/blob/13.x/src/Commands/AutowireTrait.php) to the class (example: [PmCommands](https://github.com/drush-ops/drush/blob/13.x/src/Commands/pm/PmCommands.php)). This enables your [Constructor parameter type hints to determine the injected service](https://www.drupal.org/node/3396179). When a type hint is insufficient, an [#[Autowire] Attribute](https://www.drupal.org/node/3396179) on the constructor property (with -_service:_ named argument) directs AutoWireTrait to the right service (example: [FieldDefinitionCommands](https://github.com/drush-ops/drush/blob/13.x/src/Commands/field/FieldDefinitionCommands.php)). +Command files may inject Drush and Drupal services by adding the [AutowireTrait](https://github.com/drush-ops/drush/blob/14.x/src/Commands/AutowireTrait.php) to the class (example: [PmCommands](https://github.com/drush-ops/drush/blob/14.x/src/Commands/sql/SqlDumpCommand.php)). This enables your [Constructor parameter type hints to determine the injected service](https://www.drupal.org/node/3396179). When a type hint is insufficient, an [#[Autowire] Attribute](https://www.drupal.org/node/3396179) on the constructor property (with _service:_ named argument) directs AutoWireTrait to the right service (example: [FieldDefinitionCommands](https://github.com/drush-ops/drush/blob/14.x/src/Commands/field/FieldDefinitionCommands.php)). Some autowire examples: + + ```php + protected readonly Consolidation\OutputFormatters\FormatterManager $formatterManager + protected readonly Psr\Log\LoggerInterface $logger + protected readonly Drush\SiteAlias\ProcessManager $processManager + protected readonly Consolidation\SiteAlias\SiteAliasManagerInterface $siteAliasManager + protected readonly \Drush\Config\DrushConfig $drushConfig + protected readonly Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + ``` If your command is not found by Drush, add the `-vvv` option for debug info about any service instantiation errors. If Autowire is still insufficient, a commandfile may implement its own `create()` method (see below). @@ -22,7 +30,7 @@ create() method Command files not using Autowire may inject services by adding a create() method to the commandfile. The passed in Container is a [League container](https://container.thephpleague.com/) with a delegate to the Drupal container. Note that the type hint should be to `Psr\Container\ContainerInterface` not `Symfony\Component\DependencyInjection\ContainerInterface`. A create() method and constructor will look something like this: ```php -class WootStaticFactoryCommands extends DrushCommands +class WootStaticFactoryCommand extends Command { protected $configFactory; @@ -38,21 +46,9 @@ class WootStaticFactoryCommands extends DrushCommands ``` See the [Drupal Documentation](https://www.drupal.org/docs/drupal-apis/services-and-dependency-injection/services-and-dependency-injection-in-drupal-8#s-injecting-dependencies-into-controllers-forms-and-blocks) for details on how to inject Drupal services into your command file. This approach mimics Drupal's blocks, forms, and controllers. -createEarly() method ------------------- -:octicons-tag-24: 12.0+ - -Drush 12 supported a `createEarly()` method. This is deprecated and instead put a `#[CLI\Bootstrap(DrupalBootLevels::NONE)]` Attribute on the command class and inject dependencies via the usual `__construct()` and [AutowireTrait](https://github.com/drush-ops/drush/blob/13.x/src/Commands/AutowireTrait.php). - -Note also that Drush commands packaged with Drupal modules are not discovered -until after Drupal bootstraps, and therefore cannot use `createEarly()`. This -mechanism is only usable by PSR-4 discovered commands packaged with Composer -projects that are *not* Drupal modules. - - -Inflection +Inflection (_deprecated_) ----------------- -A command class may implement the following interfaces. When doing so, implement the corresponding trait to satisfy the interface. +Command classes used to implement the following interfaces. The replacement approach is listed below. -- [CustomEventAwareInterface](https://github.com/consolidation/annotated-command/blob/4.x/src/Events/CustomEventAwareInterface.php): Allows command files to [define and fire custom events](hooks.md) that other command files can hook. Example: [CacheCommands](https://github.com/drush-ops/drush/blob/13.x/src/Commands/core/CacheCommands.php) -- [StdinAwareInterface](https://github.com/consolidation/annotated-command/blob/4.x/src/Input/StdinAwareInterface.php): Read from standard input. This class contains facilities to redirect stdin to instead read from a file, e.g. in response to an option or argument value. Example: [CacheCommands](https://github.com/drush-ops/drush/blob/13.x/src/Commands/core/CacheCommands.php) +- [CustomEventAwareInterface](https://github.com/consolidation/annotated-command/blob/4.x/src/Events/CustomEventAwareInterface.php): Commands should fire their own events. Example: [CacheCommands](https://github.com/drush-ops/drush/blob/14.x/src/Commands/core/CacheClearCommand.php) +- [StdinAwareInterface](https://github.com/consolidation/annotated-command/blob/4.x/src/Input/StdinAwareInterface.php): Read from stdin using the approach from [ConfigSetCommand](https://github.com/drush-ops/drush/blob/14.x/src/Commands/config/ConfigSetCommand.php). This makes it possible to its test set stdin and then assert proper behavior. diff --git a/docs/examples/XkcdCommands.php.md b/docs/examples/XkcdFetchCommands.php.md similarity index 68% rename from docs/examples/XkcdCommands.php.md rename to docs/examples/XkcdFetchCommands.php.md index a91854f458..02730b221a 100644 --- a/docs/examples/XkcdCommands.php.md +++ b/docs/examples/XkcdFetchCommands.php.md @@ -2,5 +2,5 @@ edit_url: https://github.com/drush-ops/drush/blob/13.x/examples/Commands/XkcdCommands.php --- ```php ---8<-- "examples/Commands/XkcdCommands.php" +--8<-- "examples/Commands/XkcdFetchCommands.php" ``` diff --git a/docs/hooks.md b/docs/hooks.md index a1793979c8..4b3033056f 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -1,3 +1,10 @@ +!!! Warning + + Hooks are deprecated in Drush 13.7. A few replacements: + + 1. To alter command information (options, arguments, etc.), [see Altering Command Info](commands.md##altering-command-info). + 2. To validate arguments/options, [provide](commands.md) a PHP8 Attribute class which commands will use to opt into your validation. Add a Listener class with the validation logic. + 3. To provide an Optionsets, you also [provide an Attribute Class and a Listener Class](commands.md). ## Core Hooks All commandfiles may implement methods that are called by Drush at various times in the request cycle. To implement one, add a `#[CLI\Hook(type: HookManager::ARGUMENT_VALIDATOR, target: 'pm:install')]` (for example) to the top of your method. The class constants for hooks are located in [HookManager](https://github.com/consolidation/annotated-command/blob/e01152f698eff4cb5df3ebfe5e097ef335dbd3c9/src/Hooks/HookManager.php#L30-L57). diff --git a/docs/install.md b/docs/install.md index df86592a49..7ecdfda838 100644 --- a/docs/install.md +++ b/docs/install.md @@ -27,6 +27,12 @@ Drupal Compatibility 7 8 9 10 11 + + Drush 14 + 8.3+ + TBD + ✓ 10.2+ ✅11.2+ + Drush 13 8.3+ diff --git a/drush.yml b/drush.yml index cd166da4cc..252ad3c1fb 100644 --- a/drush.yml +++ b/drush.yml @@ -1,4 +1,4 @@ #This is a Drush config file. Sites may override this config to change minimum PHP. drush: php: - minimum-version: 8.2.5 + minimum-version: 8.3.0 diff --git a/includes/batch.inc b/includes/batch.inc index 83e0df186e..19a502d58e 100644 --- a/includes/batch.inc +++ b/includes/batch.inc @@ -41,7 +41,7 @@ use Drush\Drush; * * @param string $command * (optional) The command to call for the back end process. By default this will be - * the 'batch-process' command, but some commands will + * the 'batch:process' command, but some commands will * have special initialization requirements, and will need to define and * use their own command. * @param array $args @@ -49,7 +49,7 @@ use Drush\Drush; * @param array $options * (optional) */ -function drush_backend_batch_process($command = 'batch-process', $args = [], $options = []) { +function drush_backend_batch_process($command = 'batch:process', $args = [], $options = []) { // Command line options to pass to the command. $options['u'] = \Drupal::currentUser()->id(); return _drush_backend_batch_process($command, $args, $options); @@ -88,7 +88,7 @@ function drush_batch_command($id) { * A return array. The callers only care about the finished marker and an #abort on an operation. * */ -function _drush_backend_batch_process($command = 'batch-process', $args = [], $options = []) { +function _drush_backend_batch_process($command = 'batch:process', $args = [], $options = []) { $result = NULL; $batch = &batch_get(); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 43eed83226..09d1482076 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,6 +5,7 @@ parameters: - phpstan-bootstrap.php paths: - src + - sut/drush universalObjectCratesClasses: # Useful until we have https://www.drupal.org/project/drupal/issues/2024043 - Drupal\Core\Extension\Extension @@ -17,6 +18,8 @@ parameters: - src/Commands/core/LanguageCommands.php # Remove once we only support Drupal 11.2 - src/Commands/core/CacheWarmCommands.php + # Not used by Drush itself + - src/TestTraits ignoreErrors: # XHprof - '#tideways_xhprof_enable#' diff --git a/src-symfony-compatibility/v6/Style/DrushStyle.php b/src-symfony-compatibility/v6/Style/DrushStyle.php index 2893796eb0..a301c8b268 100644 --- a/src-symfony-compatibility/v6/Style/DrushStyle.php +++ b/src-symfony-compatibility/v6/Style/DrushStyle.php @@ -22,6 +22,11 @@ class DrushStyle extends SymfonyStyle { + public function success(array|string $message): void { + // Force output to stderr so as to not interfere with formatted output. + $this->getErrorStyle()->success($message); + } + public function confirm(string $question, bool $default = true, string $yes = 'Yes', string $no = 'No', bool|string $required = false, ?\Closure $validate = null, string $hint = ''): bool { // Automatically accept confirmations if the --yes argument was supplied. @@ -36,8 +41,7 @@ public function confirm(string $question, bool $default = true, string $yes = 'Y return confirm($question, $default, $yes, $no, $required, $validate, $hint); } - #[Deprecated('Use select() or multiselect() instead.')] - public function choice(string $question, array $choices, mixed $default = null, bool $multiSelect = false, int $scroll = 10, ?\Closure $validate = null, string $hint = '', bool|string $required = true): mixed + public function choice(string $question, array $choices, mixed $default = null, bool $multiSelect = false, int $scroll = 15, ?\Closure $validate = null, string $hint = '', bool|string $required = true): mixed { if ($multiSelect) { // For backward compat. Deprecated. diff --git a/src/Application.php b/src/Application.php index cf9170ab36..c2f8d1195c 100644 --- a/src/Application.php +++ b/src/Application.php @@ -7,10 +7,12 @@ use Composer\Autoload\ClassLoader; use Consolidation\AnnotatedCommand\AnnotatedCommand; use Consolidation\SiteAlias\SiteAliasManager; +use Drush\Attributes\HandleRemoteCommands; use Drush\Boot\BootstrapManager; use Drush\Boot\DrupalBootLevels; use Drush\Command\RemoteCommandProxy; use Drush\Config\ConfigAwareTrait; +use Drush\Event\ConsoleDefinitionsEvent; use Drush\Runtime\RedispatchHook; use Drush\Runtime\ServiceManager; use Drush\Runtime\TildeExpansionHook; @@ -192,6 +194,13 @@ public function find($name): Command return $command; } + protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output): int + { + $this->redispatchIfRemote($command, $input); + // If we get here, redispatch did not happen. + return parent::doRunCommand($command, $input, $output); + } + /** * Look up a command. Bootstrap further if necessary. */ @@ -309,6 +318,8 @@ public function configureAndRegisterCommands(InputInterface $input, OutputInterf // any of the configuration steps we do here. $this->configureIO($input, $output); + $this->addListeners($commandfileSearchpath); + // Directly add the yaml-cli commands. $this->addCommands($this->serviceManager->instantiateYamlCliCommands()); @@ -324,9 +335,14 @@ public function configureAndRegisterCommands(InputInterface $input, OutputInterf $commandInstances = $this->serviceManager->instantiateServices($commandClasses, Drush::getContainer()); // Register our commands with Robo, our application framework. - // Note that Robo::register can accept either Annotated Command - // command handlers or Symfony Console Command objects. + // Note that Robo::register can accept Annotated Command + // handlers or Symfony Console Command objects, but not yet invokables. + $commandInstances = $this->serviceManager->commandFromInvokable($commandInstances); + array_walk($commandInstances, fn($instance) => $this->logger->debug('Add command {class}', ['class' => $instance::class])); Robo::register($this, $commandInstances); + + // Dispatch our custom event. It also fires later in \Drush\Boot\DrupalBoot8::bootstrapDrupalFull. + Drush::getContainer()->get('eventDispatcher')->dispatch(new ConsoleDefinitionsEvent($this), ConsoleDefinitionsEvent::class); } /** @@ -338,4 +354,23 @@ public function renderThrowable(\Throwable $e, OutputInterface $output): void $this->doRenderThrowable($e, $output); } + + // Discover event listeners and add those that do not require bootstrap. + protected function addListeners($commandfileSearchpath): void + { + $listenerClasses = $this->serviceManager->discoverListeners($commandfileSearchpath, '\Drush'); + $listenerClasses = $this->serviceManager->filterListeners($listenerClasses); + $this->serviceManager->addListeners($listenerClasses, Drush::getContainer()); + } + + protected function redispatchIfRemote(Command $command, InputInterface $input): void + { + // Redispatch if the command is remote and is eligible. + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(HandleRemoteCommands::class); + if (empty($attributes) && !$command instanceof AnnotatedCommand && !$command instanceof RemoteCommandProxy) { + $this->redispatchHook->redispatchIfRemote($input); + } + } } diff --git a/src/Attributes/Argument.php b/src/Attributes/Argument.php index 5a310f4b33..4255bbf926 100644 --- a/src/Attributes/Argument.php +++ b/src/Attributes/Argument.php @@ -5,7 +5,9 @@ namespace Drush\Attributes; use Attribute; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated('Use #[AsCommand] instead. See https://www.drush.org/latest/commands/')] #[Attribute(Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Argument extends \Consolidation\AnnotatedCommand\Attributes\Argument { diff --git a/src/Attributes/Bootstrap.php b/src/Attributes/Bootstrap.php index a1487f1e6b..370730e1f3 100644 --- a/src/Attributes/Bootstrap.php +++ b/src/Attributes/Bootstrap.php @@ -8,6 +8,7 @@ use Consolidation\AnnotatedCommand\Parser\CommandInfo; use Drush\Boot\DrupalBootLevels; use JetBrains\PhpStorm\ExpectedValues; +use RuntimeException; #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class Bootstrap @@ -22,6 +23,9 @@ public function __construct( #[ExpectedValues(valuesFromClass: DrupalBootLevels::class)] public int $level, public ?int $max_level = null, ) { + if ($this->max_level && $this->level !== DrupalBootLevels::MAX) { + throw new RuntimeException('The max_level argument can only be used with the MAX bootstrap level.'); + } } public static function handle(\ReflectionAttribute $attribute, CommandInfo $commandInfo) diff --git a/src/Attributes/Command.php b/src/Attributes/Command.php index de06060be7..f239d6e2da 100644 --- a/src/Attributes/Command.php +++ b/src/Attributes/Command.php @@ -5,7 +5,9 @@ namespace Drush\Attributes; use Attribute; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated('Use #[AsCommand] instead. See https://www.drush.org/latest/commands/')] #[Attribute(Attribute::TARGET_METHOD)] class Command extends \Consolidation\AnnotatedCommand\Attributes\Command { diff --git a/src/Attributes/Complete.php b/src/Attributes/Complete.php index b5cf5c19b7..8f7ec8f017 100644 --- a/src/Attributes/Complete.php +++ b/src/Attributes/Complete.php @@ -5,7 +5,9 @@ namespace Drush\Attributes; use Attribute; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated('Use complete() method instead on a Console Command. See https://www.drush.org/latest/commands/')] #[Attribute(Attribute::TARGET_METHOD)] class Complete extends \Consolidation\AnnotatedCommand\Attributes\Complete { diff --git a/src/Attributes/DefaultFields.php b/src/Attributes/DefaultFields.php index 58b0e02a9f..182d0704bf 100644 --- a/src/Attributes/DefaultFields.php +++ b/src/Attributes/DefaultFields.php @@ -5,8 +5,16 @@ namespace Drush\Attributes; use Attribute; +use Drush\Formatters\FormatterConfigurationItemProviderInterface; -#[Attribute(Attribute::TARGET_METHOD)] -class DefaultFields extends \Consolidation\AnnotatedCommand\Attributes\DefaultFields +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +class DefaultFields extends \Consolidation\AnnotatedCommand\Attributes\DefaultFields implements FormatterConfigurationItemProviderInterface { + const KEY = 'default-fields'; + + public function getConfigurationItem(\ReflectionAttribute $attribute): array + { + $args = $attribute->getArguments(); + return [self::KEY => $args['fields']]; + } } diff --git a/src/Attributes/DefaultTableFields.php b/src/Attributes/DefaultTableFields.php index 7e340474bf..f49402b12e 100644 --- a/src/Attributes/DefaultTableFields.php +++ b/src/Attributes/DefaultTableFields.php @@ -5,8 +5,16 @@ namespace Drush\Attributes; use Attribute; +use Drush\Formatters\FormatterConfigurationItemProviderInterface; -#[Attribute(Attribute::TARGET_METHOD)] -class DefaultTableFields extends \Consolidation\AnnotatedCommand\Attributes\DefaultTableFields +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +class DefaultTableFields extends \Consolidation\AnnotatedCommand\Attributes\DefaultTableFields implements FormatterConfigurationItemProviderInterface { + const KEY = 'default-table-fields'; + + public function getConfigurationItem(\ReflectionAttribute $attribute): array + { + $args = $attribute->getArguments(); + return [self::KEY => $args['fields']]; + } } diff --git a/src/Attributes/FieldLabels.php b/src/Attributes/FieldLabels.php index 6e7a26a1b2..4753e46cd3 100644 --- a/src/Attributes/FieldLabels.php +++ b/src/Attributes/FieldLabels.php @@ -5,8 +5,17 @@ namespace Drush\Attributes; use Attribute; +use Consolidation\OutputFormatters\Options\FormatterOptions; +use Drush\Formatters\FormatterConfigurationItemProviderInterface; -#[Attribute(Attribute::TARGET_METHOD)] -class FieldLabels extends \Consolidation\AnnotatedCommand\Attributes\FieldLabels +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +class FieldLabels extends \Consolidation\AnnotatedCommand\Attributes\FieldLabels implements FormatterConfigurationItemProviderInterface { + const KEY = FormatterOptions::FIELD_LABELS; + + public function getConfigurationItem(\ReflectionAttribute $attribute): array + { + $args = $attribute->getArguments(); + return [self::KEY => $args['labels']]; + } } diff --git a/src/Attributes/FilterDefaultField.php b/src/Attributes/FilterDefaultField.php index 70d2749cc0..52096a21ff 100644 --- a/src/Attributes/FilterDefaultField.php +++ b/src/Attributes/FilterDefaultField.php @@ -6,7 +6,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] class FilterDefaultField extends \Consolidation\AnnotatedCommand\Attributes\FilterDefaultField { } diff --git a/src/Attributes/Format.php b/src/Attributes/Format.php index 0c121d59c1..b8e7a8b0a7 100644 --- a/src/Attributes/Format.php +++ b/src/Attributes/Format.php @@ -7,9 +7,10 @@ use Attribute; use Consolidation\AnnotatedCommand\Parser\CommandInfo; use Consolidation\OutputFormatters\Options\FormatterOptions; -use Drush\Boot\Kernels; +use JetBrains\PhpStorm\Deprecated; use JetBrains\PhpStorm\ExpectedValues; +#[Deprecated('Use #[TableFormat] instead.')] #[Attribute(Attribute::TARGET_METHOD)] class Format { diff --git a/src/Attributes/Formatter.php b/src/Attributes/Formatter.php new file mode 100644 index 0000000000..51acd69911 --- /dev/null +++ b/src/Attributes/Formatter.php @@ -0,0 +1,26 @@ +addOption('tables-key', 'A key in the $tables array.', [], DrushCommands::REQ); $commandInfo->addOption('skip-tables-list', 'A comma-separated list of tables to exclude completely.', [], DrushCommands::REQ); $commandInfo->addOption('structure-tables-list', 'A comma-separated list of tables to include for structure, but not data.', [], DrushCommands::REQ); - $commandInfo->addOption('skip-tables-list', 'A comma-separated list of tables to exclude completely.', [], DrushCommands::REQ); $commandInfo->addOption('tables-list', 'A comma-separated list of tables to transfer.', [], DrushCommands::REQ); } } diff --git a/src/Attributes/TableFormat.php b/src/Attributes/TableFormat.php new file mode 100644 index 0000000000..e423fb2f01 --- /dev/null +++ b/src/Attributes/TableFormat.php @@ -0,0 +1,35 @@ +getArguments(); + return [FormatterOptions::TABLE_STYLE => $args['tableStyle'], FormatterOptions::LIST_DELIMITER => $args['listDelimiter']]; + } +} diff --git a/src/Attributes/Topics.php b/src/Attributes/Topics.php index 9f974ba950..eb10fc1034 100644 --- a/src/Attributes/Topics.php +++ b/src/Attributes/Topics.php @@ -5,8 +5,10 @@ namespace Drush\Attributes; use Attribute; +use JetBrains\PhpStorm\Deprecated; -#[Attribute(Attribute::TARGET_METHOD)] +#[Deprecated('Use Help Links when converting to a Console command. See https://www.drush.org/latest/commands/')] +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] class Topics extends \Consolidation\AnnotatedCommand\Attributes\Topics { } diff --git a/src/Attributes/Usage.php b/src/Attributes/Usage.php index 4b7cc395a1..c7e6fe411f 100644 --- a/src/Attributes/Usage.php +++ b/src/Attributes/Usage.php @@ -5,7 +5,9 @@ namespace Drush\Attributes; use Attribute; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated('Use configure() method instead. See https://www.drush.org/latest/commands/')] #[Attribute(Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Usage extends \Consolidation\AnnotatedCommand\Attributes\Usage { diff --git a/src/Attributes/ValidateConfigName.php b/src/Attributes/ValidateConfigName.php index dd3213d501..b35a92fa9d 100644 --- a/src/Attributes/ValidateConfigName.php +++ b/src/Attributes/ValidateConfigName.php @@ -9,7 +9,7 @@ use Consolidation\AnnotatedCommand\CommandError; use Drush\Utils\StringUtils; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class ValidateConfigName extends ValidatorBase implements ValidatorInterface { /** diff --git a/src/Attributes/ValidateEntityLoad.php b/src/Attributes/ValidateEntityLoad.php index a8edc12aee..3aaf07fd83 100644 --- a/src/Attributes/ValidateEntityLoad.php +++ b/src/Attributes/ValidateEntityLoad.php @@ -9,7 +9,7 @@ use Consolidation\AnnotatedCommand\CommandError; use Drush\Utils\StringUtils; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class ValidateEntityLoad extends ValidatorBase implements ValidatorInterface { /** diff --git a/src/Attributes/ValidateFileExists.php b/src/Attributes/ValidateFileExists.php index 2e2cc613c2..aef73fdbd9 100644 --- a/src/Attributes/ValidateFileExists.php +++ b/src/Attributes/ValidateFileExists.php @@ -8,7 +8,7 @@ use Consolidation\AnnotatedCommand\CommandData; use Consolidation\AnnotatedCommand\CommandError; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class ValidateFileExists extends ValidatorBase implements ValidatorInterface { /** diff --git a/src/Attributes/ValidateModulesEnabled.php b/src/Attributes/ValidateModulesEnabled.php index d3cd6b1267..b53da3cca5 100644 --- a/src/Attributes/ValidateModulesEnabled.php +++ b/src/Attributes/ValidateModulesEnabled.php @@ -8,7 +8,7 @@ use Consolidation\AnnotatedCommand\CommandData; use Consolidation\AnnotatedCommand\CommandError; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class ValidateModulesEnabled extends ValidatorBase implements ValidatorInterface { /** diff --git a/src/Attributes/ValidatePermissions.php b/src/Attributes/ValidatePermissions.php index db6a4c2e8b..56f6e36ac3 100644 --- a/src/Attributes/ValidatePermissions.php +++ b/src/Attributes/ValidatePermissions.php @@ -9,7 +9,7 @@ use Consolidation\AnnotatedCommand\CommandError; use Drush\Utils\StringUtils; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class ValidatePermissions extends ValidatorBase implements ValidatorInterface { /** diff --git a/src/Attributes/ValidateQueueName.php b/src/Attributes/ValidateQueueName.php index 0381ce27dc..fe8737c520 100644 --- a/src/Attributes/ValidateQueueName.php +++ b/src/Attributes/ValidateQueueName.php @@ -8,7 +8,7 @@ use Consolidation\AnnotatedCommand\CommandData; use Consolidation\AnnotatedCommand\CommandError; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class ValidateQueueName extends ValidatorBase implements ValidatorInterface { /** diff --git a/src/Attributes/Version.php b/src/Attributes/Version.php index 8314ab6d7e..78b73dfcb9 100644 --- a/src/Attributes/Version.php +++ b/src/Attributes/Version.php @@ -7,7 +7,7 @@ use Attribute; use Consolidation\AnnotatedCommand\Parser\CommandInfo; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class Version { /** diff --git a/src/Boot/BootstrapManager.php b/src/Boot/BootstrapManager.php index a0da295b08..b14fa3bd42 100644 --- a/src/Boot/BootstrapManager.php +++ b/src/Boot/BootstrapManager.php @@ -344,11 +344,11 @@ public function bootstrapToPhaseIndex(int $max_phase_index, ?AnnotationData $ann break; } - $this->logger->info('Try to validate bootstrap phase {phase}', ['phase' => $max_phase_index]); + // $this->logger->info('Try to validate bootstrap phase {phase}', ['phase' => $max_phase_index]); if ($this->bootstrapValidate($phase_index)) { if ($phase_index > $this->getPhase()) { - $this->logger->info('Try to bootstrap at phase {phase}', ['phase' => $max_phase_index]); + // $this->logger->info('Try to bootstrap at phase {phase}', ['phase' => $max_phase_index]); $result = $this->doBootstrap($phase_index, $max_phase_index, $annotationData); } } else { diff --git a/src/Boot/DrupalBoot8.php b/src/Boot/DrupalBoot8.php index eb087ba8e9..43e496dc4b 100644 --- a/src/Boot/DrupalBoot8.php +++ b/src/Boot/DrupalBoot8.php @@ -14,6 +14,7 @@ use Drush\Drupal\DrushLoggerServiceProvider; use Drush\Drupal\Migrate\MigrateRunnerServiceProvider; use Drush\Drush; +use Drush\Event\ConsoleDefinitionsEvent; use Drush\Runtime\LegacyServiceFinder; use Drush\Runtime\LegacyServiceInstantiator; use Drush\Runtime\ServiceManager; @@ -86,7 +87,7 @@ public function confPath(bool $require_settings = true, bool $reset = false): ?s public function bootstrapDrupalSiteValidate(BootstrapManager $manager): bool { - parent::bootstrapDrupalSiteValidate($manager); + // parent::bootstrapDrupalSiteValidate($manager); // Normalize URI. $uri = rtrim($this->uri, '/') . '/'; @@ -104,14 +105,7 @@ public function bootstrapDrupalSiteValidate(BootstrapManager $manager): bool 'SCRIPT_FILENAME' => getcwd() . '/index.php', 'SCRIPT_NAME' => isset($parsed_url['path']) ? $parsed_url['path'] . 'index.php' : '/index.php', ] + $_SERVER; - // To do: split into Drupal 9 and Drupal 10 bootstrap - if (method_exists(Request::class, 'create')) { - // Drupal 9 - $request = Request::create($uri, 'GET', [], [], [], $server); - } else { - // Drupal 10 - $request = Request::createFromGlobals(); - } + $request = Request::create($uri, 'GET', [], [], [], $server); $request->overrideGlobals(); $this->setRequest($request); return true; @@ -202,7 +196,7 @@ public function bootstrapDrupalConfiguration(BootstrapManager $manager, ?Annotat // Disable automated cron if the module is enabled. $GLOBALS['config']['automated_cron.settings']['interval'] = 0; - parent::bootstrapDrupalConfiguration($manager); + // parent::bootstrapDrupalConfiguration($manager); } public function bootstrapDrupalFull(BootstrapManager $manager): void @@ -217,12 +211,17 @@ public function bootstrapDrupalFull(BootstrapManager $manager): void // Directly add the Drupal core bootstrapped commands. Drush::getApplication()->addCommands($this->serviceManager->instantiateDrupalCoreBootstrappedCommands()); + $this->addBootstrapListeners(); + $this->addDrupalModuleDrushCommands($manager); // Set a default account to make sure the correct timezone is set $this->kernel->getContainer()->get('current_user')->setAccount(new AnonymousUserSession()); } + /** + * Adds module supplied commands, as well as Symfony Console commands that require bootstrap. + */ public function addDrupalModuleDrushCommands(BootstrapManager $manager): void { $application = Drush::getApplication(); @@ -266,6 +265,8 @@ public function addDrupalModuleDrushCommands(BootstrapManager $manager): void // Robo::register to add any commands, as that is the point where the // alteration will happen. foreach ($commandInfoAlterers as $altererHandler) { + // @todo This is in Drush14 but is giving an error here. + // $altererHandler = $this->serviceManager->commandFromInvokable($altererHandler); $commandFactory->addCommandInfoAlterer($altererHandler); $this->logger->debug(dt('Commands are potentially altered in !class.', ['!class' => get_class($altererHandler)])); } @@ -292,9 +293,11 @@ public function addDrupalModuleDrushCommands(BootstrapManager $manager): void $commandHandlers = $this->serviceManager->instantiateServices($bootstrapCommandClasses, $drushContainer, $container); // Inflect and register all command handlers - foreach ($commandHandlers as $commandHandler) { - Robo::register($application, $commandHandler); - } + $commandHandlers = $this->serviceManager->commandFromInvokable($commandHandlers); + Robo::register($application, $commandHandlers); + + // Dispatch our custom event. It also fires earlier in \Drush\Application::configureAndRegisterCommands. + Drush::getContainer()->get('eventDispatcher')->dispatch(new ConsoleDefinitionsEvent(Drush::getApplication()), ConsoleDefinitionsEvent::class); } /** @@ -325,4 +328,17 @@ public function bootstrapDrupalSite(BootstrapManager $manager) { $this->bootstrapDoDrupalSite($manager); } + + // Add the Listeners that require bootstrap. + public function addBootstrapListeners(): void + { + $listenersInThisModule = []; + $moduleHandler = \Drupal::moduleHandler(); + foreach ($moduleHandler->getModuleList() as $moduleId => $extension) { + $path = DRUPAL_ROOT . '/' . $extension->getPath() . '/src/Drush'; + $listenersInThisModule = array_merge($listenersInThisModule, $this->serviceManager->discoverListeners([$path], "\Drupal\\$moduleId\Drush")); + } + $classes = $this->serviceManager->bootstrapListenerClasses(); + $this->serviceManager->addListeners(array_merge($listenersInThisModule, $classes), Drush::getContainer(), \Drupal::getContainer()); + } } diff --git a/src/Command/ConsoleLink.php b/src/Command/ConsoleLink.php new file mode 100644 index 0000000000..7e529b2b48 --- /dev/null +++ b/src/Command/ConsoleLink.php @@ -0,0 +1,15 @@ + new ConsoleLink('site-aliases', 'Creating site aliases for running Drush on remote sites'), + self::Deploy => new ConsoleLink('deploy', 'Deploy command for Drupal.'), + self::DrushConfiguration => new ConsoleLink('using-drush-configuration', 'Drush configuration'), + self::Policy => new ConsoleLink('examples/PolicyCommands.php', 'Example policy file'), + self::ConfigExporting => new ConsoleLink('config-exporting', 'Example policy file'), + self::Repl => new ConsoleLink('repl', 'Drush\'s PHP Shell'), + self::Cron => new ConsoleLink('cron', 'Crontab instructions for running your Drupal cron tasks via `drush cron`.'), + self::Migrate => new ConsoleLink('migrate', 'Defining and running migrations.'), + self::Script => new ConsoleLink('examples/helloworld.script', 'An example Drush script'), + self::SyncViaHttp => new ConsoleLink('examples/Commands/SyncViaHttpCommands.php', 'Extend sql-sync to allow transfer of the sql dump file via http.'), + self::Readme => new ConsoleLink('README.md', 'README.md'), + self::Generators => new ConsoleLink('generators', 'Instructions on creating your own Drush Generators.'), + }; + } + + /** + * A base URL for help links. + */ + public static function getDocsUrlBase($branch = 'latest'): string + { + return "https://www.drush.org/$branch"; + } + + /** + * Build Console hyperlink to a Drush docs page. + */ + public function consoleLink(): string + { + $link = $this->getConsoleLink(); + return sprintf('* %s', self::getDocsUrlBase(), $link->path, $link->text); + } +} diff --git a/src/Commands/AutowireTrait.php b/src/Commands/AutowireTrait.php index d4d77ef095..ad5f8639c9 100644 --- a/src/Commands/AutowireTrait.php +++ b/src/Commands/AutowireTrait.php @@ -21,27 +21,23 @@ trait AutowireTrait * * @param ContainerInterface $container * The service container this instance should use. - * - * @return static */ public static function create(ContainerInterface $container) { $args = []; - if (method_exists(static::class, '__construct')) { - $constructor = new \ReflectionMethod(static::class, '__construct'); - foreach ($constructor->getParameters() as $parameter) { - $service = ltrim((string) $parameter->getType(), '?'); - foreach ($parameter->getAttributes(Autowire::class) as $attribute) { - $service = (string) $attribute->newInstance()->value; - } - - if (!$container->has($service)) { - throw new AutowiringFailedException($service, sprintf('Cannot autowire service "%s": argument "$%s" of method "%s::_construct()", you should configure its value explicitly.', $service, $parameter->getName(), static::class)); - } + $constructor = new \ReflectionMethod(static::class, '__construct'); + foreach ($constructor->getParameters() as $parameter) { + $service = ltrim((string) $parameter->getType(), '?'); + foreach ($parameter->getAttributes(Autowire::class) as $attribute) { + $service = (string) $attribute->newInstance()->value; + } - $args[] = $container->get($service); + if (!$container->has($service)) { + throw new AutowiringFailedException($service, sprintf('Cannot autowire service "%s": argument "$%s" of method "%s::_construct()", you should configure its value explicitly.', $service, $parameter->getName(), static::class)); } + + $args[] = $container->get($service); } return new self(...$args); diff --git a/src/Commands/config/ConfigCommands.php b/src/Commands/config/ConfigCommands.php index 0c899356cf..2d7af19f97 100644 --- a/src/Commands/config/ConfigCommands.php +++ b/src/Commands/config/ConfigCommands.php @@ -69,7 +69,7 @@ public function __construct( public function hasImportTransformer(): bool { - return isset($this->importStorageTransformer); + return true; } public function getImportTransformer(): ImportStorageTransformer diff --git a/src/Commands/core/CliCommands.php b/src/Commands/core/CliCommands.php index 6f9c801cb3..ab68edccd3 100644 --- a/src/Commands/core/CliCommands.php +++ b/src/Commands/core/CliCommands.php @@ -86,19 +86,6 @@ public function cli(array $options = ['version-history' => false, 'cwd' => self: $shell = new Shell($configuration); - - // Register the assertion handler so exceptions are thrown instead of - // errors being triggered. This plays nicer with PsySH. Since we're - // using exceptions, turn error warnings off. - if (version_compare(PHP_VERSION, '8.3', '<')) { - // assert_options() and assert.* INI configuration directives are - // all deprecated in PHP 8.3. - // @see https://www.php.net/manual/en/function.assert-options.php - // @see https://www.php.net/manual/en/info.configuration.php - assert_options(ASSERT_EXCEPTION, true); - assert_options(ASSERT_WARNING, false); - } - $shell->setScopeVariables(['container' => \Drupal::getContainer()]); // Add our casters to the shell configuration. @@ -182,9 +169,6 @@ protected function getDrushCommands(): array * These are Symfony VarDumper casters. * See http://symfony.com/doc/current/components/var_dumper/advanced.html#casters * for more information. - * - * @return callable[]. - * An array of caster callbacks keyed by class or interface. */ protected function getCasters(): array { @@ -203,9 +187,6 @@ protected function getCasters(): array * Returns the file path for the CLI history. * * This can either be site specific (default) or Drupal version specific. - * - * - * @return string. */ protected function historyPath(array $options): string { diff --git a/src/Commands/core/DeployCommands.php b/src/Commands/core/DeployCommands.php index 1790a37efa..86c6a3f29e 100644 --- a/src/Commands/core/DeployCommands.php +++ b/src/Commands/core/DeployCommands.php @@ -7,7 +7,6 @@ use Consolidation\SiteAlias\SiteAlias; use Consolidation\SiteAlias\SiteAliasManagerInterface; use Drush\Attributes as CLI; -use Drush\Boot\DrupalBoot; use Drush\Boot\DrupalBootLevels; use Drush\Commands\AutowireTrait; use Drush\Commands\config\ConfigImportCommands; @@ -58,7 +57,7 @@ public function deploy(): void // Since this command is Bootstrap=None, we don't have access to the Drupal container. $boot_manager = Drush::bootstrapManager(); $boot_object = Drush::bootstrap(); - if (($drupal_root = $boot_manager->getRoot()) && ($boot_object instanceof DrupalBoot && version_compare($boot_object->getVersion($drupal_root), '11.2-dev', '>='))) { + if (version_compare($boot_object->getVersion(null), '11.2-dev', '>=')) { $this->logger()->success("Cache prewarm start."); $process = $manager->drush($self, CacheWarmCommands::WARM, [], $redispatchOptions); $process->mustRun($process->showRealtime()); diff --git a/src/Commands/core/EntityCommands.php b/src/Commands/core/EntityCommands.php index 4680f87dc2..8d6ba2d48c 100644 --- a/src/Commands/core/EntityCommands.php +++ b/src/Commands/core/EntityCommands.php @@ -241,7 +241,7 @@ public function doSave(string $entity_type, array $ids, ?string $action, ?string protected function getQuery(string $entity_type, ?string $ids, array $options): QueryInterface { $storage = $this->entityTypeManager->getStorage($entity_type); - $query = $storage->getQuery()->accessCheck(false); + $query = $storage->getQuery(); if ($ids = StringUtils::csvToArray((string) $ids)) { $idKey = $this->entityTypeManager->getDefinition($entity_type)->getKey('id'); $query = $query->condition($idKey, $ids, 'IN'); @@ -258,6 +258,7 @@ protected function getQuery(string $entity_type, ?string $ids, array $options): $query->range(0, $limit); } } - return $query; + // @phpstan-ignore-next-line + return $query->accessCheck(false); } } diff --git a/src/Commands/core/MigrateRunnerCommands.php b/src/Commands/core/MigrateRunnerCommands.php index 253c4bf2ce..f3a09a93a7 100644 --- a/src/Commands/core/MigrateRunnerCommands.php +++ b/src/Commands/core/MigrateRunnerCommands.php @@ -181,9 +181,10 @@ protected function getMigrationSourceRowsCount(MigrationInterface $migration): ? try { $sourceRowsCount = $migration->getSourcePlugin()->count(); // -1 indicates uncountable sources. - if ($sourceRowsCount === -1) { - return null; - } + // Can't happen? +// if ($sourceRowsCount == -1) { +// return null; +// } return $sourceRowsCount; } catch (\Exception $exception) { $arguments = [ diff --git a/src/Commands/core/StatusCommands.php b/src/Commands/core/StatusCommands.php index 83830f7802..bd80adf047 100644 --- a/src/Commands/core/StatusCommands.php +++ b/src/Commands/core/StatusCommands.php @@ -106,7 +106,7 @@ public function getPropertyList($options): array { $boot_manager = Drush::bootstrapManager(); $boot_object = Drush::bootstrap(); - if (($drupal_root = $boot_manager->getRoot()) && ($boot_object instanceof DrupalBoot)) { + if (($drupal_root = $boot_manager->getRoot())) { $status_table['drupal-version'] = $boot_object->getVersion($drupal_root); $conf_dir = $boot_object->confPath(); $settings_file = Path::join($conf_dir, 'settings.php'); @@ -128,9 +128,7 @@ public function getPropertyList($options): array $status_table['db-port'] = isset($db_spec['port']) ? $db_spec['port'] : null; } if ($boot_manager->hasBootstrapped(DrupalBootLevels::CONFIGURATION)) { - if (method_exists('Drupal', 'installProfile')) { - $status_table['install-profile'] = \Drupal::installProfile(); - } + $status_table['install-profile'] = \Drupal::installProfile(); if ($boot_manager->hasBootstrapped(DrupalBootLevels::DATABASE)) { $status_table['db-status'] = dt('Connected'); if ($boot_manager->hasBootstrapped(DrupalBootLevels::FULL)) { diff --git a/src/Commands/core/UpdateDBCommands.php b/src/Commands/core/UpdateDBCommands.php index ec4659cda1..88e345c47c 100644 --- a/src/Commands/core/UpdateDBCommands.php +++ b/src/Commands/core/UpdateDBCommands.php @@ -117,7 +117,7 @@ public function updatedbStatus($options = ['format' => 'table']): ?RowsOfFields { require_once DRUSH_DRUPAL_CORE . '/includes/install.inc'; drupal_load_updates(); - list($pending, $start, $warnings) = $this->getUpdatedbStatus($options); + [$pending, $start, $warnings] = $this->getUpdatedbStatus($options); // Output any warnings. $return = null; @@ -190,10 +190,11 @@ public static function updateDoOne(string $module, int $number, array $dependenc $ret = []; $update_hook_registry = \Drupal::service('update.update_hook_registry'); $equivalent_update = null; + // @phpstan-ignore-next-line if (method_exists($update_hook_registry, 'getEquivalentUpdate')) { $equivalent_update = \Drupal::service('update.update_hook_registry')->getEquivalentUpdate($module, $number); } - if ($equivalent_update && $equivalent_update instanceof EquivalentUpdate) { + if ($equivalent_update instanceof EquivalentUpdate) { $ret['results']['query'] = $equivalent_update->toSkipMessage(); $ret['results']['success'] = true; $context['sandbox']['#finished'] = true; @@ -280,7 +281,7 @@ public static function updateDoOnePostUpdate(string $function, array $context): return; } - list($extension, $name) = explode('_post_update_', $function, 2); + [$extension, $name] = explode('_post_update_', $function, 2); \Drupal::service('update.post_update_registry')->getUpdateFunctions($extension); if (function_exists($function)) { diff --git a/src/Commands/field/FieldBaseOverrideCreateCommands.php b/src/Commands/field/FieldBaseOverrideCreateCommands.php index 3106d1198e..975bbbfe22 100644 --- a/src/Commands/field/FieldBaseOverrideCreateCommands.php +++ b/src/Commands/field/FieldBaseOverrideCreateCommands.php @@ -135,7 +135,7 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti } } - protected function askFieldName(string $entityType): ?string + protected function askFieldName(string $entityType): string { /** @var BaseFieldDefinition[] $definitions */ $definitions = $this->entityFieldManager->getBaseFieldDefinitions($entityType); diff --git a/src/Commands/field/FieldEntityReferenceHooks.php b/src/Commands/field/FieldEntityReferenceHooks.php index 83c811804e..811ef0bd42 100644 --- a/src/Commands/field/FieldEntityReferenceHooks.php +++ b/src/Commands/field/FieldEntityReferenceHooks.php @@ -100,7 +100,7 @@ protected function askReferencedEntityType(): string return $this->io()->select('Referenced entity type', $choices); } - protected function askReferencedBundles(string $targetType): ?array + protected function askReferencedBundles(string $targetType): array { $choices = []; $bundleInfo = $this->entityTypeBundleInfo->getBundleInfo($targetType); diff --git a/src/Commands/help/HelpCLIFormatter.php b/src/Commands/help/HelpCLIFormatter.php index 28e0930a92..1996ef84c7 100644 --- a/src/Commands/help/HelpCLIFormatter.php +++ b/src/Commands/help/HelpCLIFormatter.php @@ -42,9 +42,9 @@ public function write(OutputInterface $output, $data, FormatterOptions $options) } } elseif (array_key_exists('usages', $data)) { // Usages come from Console commands. - // Don't show the last two Usages which come from synopsis and alias. See \Symfony\Component\Console\Descriptor\XmlDescriptor::getCommandDocument. - array_pop($data['usages']); - array_pop($data['usages']); + // Don't show the first two Usages which come from synopsis and alias. See \Symfony\Component\Console\Descriptor\XmlDescriptor::getCommandDocument. + array_shift($data['usages']); + array_shift($data['usages']); if ($data['usages']) { $output->writeln(''); $output->writeln('Examples:'); diff --git a/src/Commands/sql/SqlCommands.php b/src/Commands/sql/SqlCommands.php index 01a9ec3cbf..6dc6ce39d2 100644 --- a/src/Commands/sql/SqlCommands.php +++ b/src/Commands/sql/SqlCommands.php @@ -39,7 +39,7 @@ final class SqlCommands extends DrushCommands implements StdinAwareInterface #[CLI\Option(name: 'all', description: 'Show all database connections, instead of just one.')] #[CLI\Bootstrap(level: DrupalBootLevels::MAX, max_level: DrupalBootLevels::CONFIGURATION)] #[CLI\OptionsetSql] - public function conf($options = ['format' => 'yaml', 'all' => false, 'show-passwords' => false]): ?array + public function conf($options = ['format' => 'yaml', 'all' => false, 'show-passwords' => false]): array { if ($options['all']) { $return = Database::getAllConnectionInfo(); diff --git a/src/Commands/sql/sanitize/SanitizeCommands.php b/src/Commands/sql/sanitize/SanitizeCommands.php index 79633a40e3..953c561525 100644 --- a/src/Commands/sql/sanitize/SanitizeCommands.php +++ b/src/Commands/sql/sanitize/SanitizeCommands.php @@ -8,22 +8,39 @@ use Consolidation\AnnotatedCommand\Events\CustomEventAwareTrait; use Drush\Attributes as CLI; use Drush\Boot\DrupalBootLevels; +use Drush\Commands\AutowireTrait; use Drush\Commands\core\DocsCommands; use Drush\Commands\DrushCommands; +use Drush\Event\SanitizeConfirmsEvent; use Drush\Exceptions\UserAbortException; +use JetBrains\PhpStorm\Deprecated; +use Psr\EventDispatcher\EventDispatcherInterface; #[CLI\Bootstrap(level: DrupalBootLevels::FULL)] final class SanitizeCommands extends DrushCommands implements CustomEventAwareInterface { + use AutowireTrait; use CustomEventAwareTrait; + public function __construct(protected EventDispatcherInterface $eventDispatcher) + { + parent::__construct(); + } + + #[Deprecated('This constant will soon move to a new class. Use the string value instead.')] const SANITIZE = 'sql:sanitize'; + #[Deprecated('This constant will soon move to a new class. Use the string value instead.')] const CONFIRMS = 'sql-sanitize-confirms'; /** * Sanitize the database by removing or obfuscating user data. * - * Commandfiles may add custom operations by implementing: + * Commandfiles may add custom operations by implementing a Listener that subscribes to two events: + * + * - `\Drush\Events\SanitizeConfirmsEvent`. Display a summary to the user before confirmation. + * - `\Symfony\Component\Console\Event\ConsoleTerminateEvent`. Run queries or call APIs to perform sanitizing + * + * [Deprecated] Commandfiles may add custom operations by implementing: * * - `#[CLI\Hook(type: HookManager::ON_EVENT, target: SanitizeCommands::CONFIRMS)]`. Display summary to user before confirmation. * - `#[CLI\Hook(type: HookManager::POST_COMMAND_HOOK, target: SanitizeCommands::SANITIZE)]`. Run queries or call APIs to perform sanitizing @@ -36,18 +53,20 @@ final class SanitizeCommands extends DrushCommands implements CustomEventAwareIn #[CLI\Topics(topics: [DocsCommands::HOOKS])] public function sanitize(): void { - /** - * In order to present only one prompt, collect all confirmations from - * commandfiles up front. sql:sanitize plugins are commandfiles that implement - * \Drush\Commands\sql\SanitizePluginInterface - */ - $messages = []; - $input = $this->input(); + // To present only one prompt, collect all confirmations first. + // These are the "new" event listeners. + $event = new SanitizeConfirmsEvent($this->input()); + $this->eventDispatcher->dispatch($event); + $messages = $event->getMessages(); + + // These are the "old" custom events. $handlers = $this->getCustomEventHandlers(self::CONFIRMS); foreach ($handlers as $handler) { - $handler($messages, $input); + $handler($messages, $this->input()); + $stringCallable = (is_string($handler[0]) ? $handler[0] : get_class($handler[0])) . '::' . $handler[1]; + $this->logger()->notice('The {handler} sanitize plugin is using a deprecated API. See {url}', ['handler' => $stringCallable, 'url' => 'https://www.drush.org/14.x/events/']); } - // @phpstan-ignore if.alwaysFalse + if ($messages) { $this->output()->writeln(dt('The following operations will be performed:')); $this->io()->listing($messages); diff --git a/src/Drupal/Migrate/ValidateMigrationId.php b/src/Drupal/Migrate/ValidateMigrationId.php index 84e9384116..5600a93ab8 100644 --- a/src/Drupal/Migrate/ValidateMigrationId.php +++ b/src/Drupal/Migrate/ValidateMigrationId.php @@ -7,7 +7,7 @@ use Attribute; use Drush\Attributes\NoArgumentsBase; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class ValidateMigrationId extends NoArgumentsBase { protected const NAME = 'validate_migration_id'; diff --git a/src/Event/CacheClearEvent.php b/src/Event/CacheClearEvent.php new file mode 100644 index 0000000000..ab93d76b15 --- /dev/null +++ b/src/Event/CacheClearEvent.php @@ -0,0 +1,28 @@ +types; + } + + public function addType(string $name, callable $callback): self + { + $this->types[$name] = $callback; + return $this; + } +} diff --git a/src/Event/ConsoleDefinitionsEvent.php b/src/Event/ConsoleDefinitionsEvent.php new file mode 100644 index 0000000000..63811117cc --- /dev/null +++ b/src/Event/ConsoleDefinitionsEvent.php @@ -0,0 +1,28 @@ +application = $application; + } + + public function getApplication(): Application + { + return $this->application; + } +} diff --git a/src/Event/SanitizeConfirmsEvent.php b/src/Event/SanitizeConfirmsEvent.php new file mode 100644 index 0000000000..3c34542062 --- /dev/null +++ b/src/Event/SanitizeConfirmsEvent.php @@ -0,0 +1,48 @@ +input = $input; + } + + public function getInput(): InputInterface + { + return $this->input; + } + + public function addMessage(string $message): self + { + $this->messages[] = $message; + return $this; + } + + public function getMessages(): array + { + return $this->messages; + } + + public function setMessages(array $messages): self + { + $this->messages = $messages; + return $this; + } +} diff --git a/src/Formatters/FormatterConfigurationItemProviderInterface.php b/src/Formatters/FormatterConfigurationItemProviderInterface.php new file mode 100644 index 0000000000..78314cad85 --- /dev/null +++ b/src/Formatters/FormatterConfigurationItemProviderInterface.php @@ -0,0 +1,10 @@ +formatterManager)) { + throw new \Exception('\Consolidation\OutputFormatters\FormatterManager must be injected into the command during __construct().'); + } + + if (is_object($data) || is_array($data) || is_string($data)) { + $data = $this->alterResult($data, $input); + $format = $input->getOption('format'); + if ($input->hasOption('field') && $input->getOption('field')) { + $format = 'string'; + } + $this->formatterManager->write($output, $format, $data, $this->getFormatterOptions()->setInput($input)->setOptions($input->getOptions())); + } + } + + protected function alterResult($result, InputInterface $input): mixed + { + if (!$input->hasOption('filter') || !$input->getOption('filter')) { + return $result; + } + $expression = $input->getOption('filter'); + $reflection = new \ReflectionObject($this); + $attributes = $reflection->getAttributes(FilterDefaultField::class); + $instance = $attributes[0]->newInstance(); + $factory = LogicalOpFactory::get(); + $op = $factory->evaluate($expression, $instance->field); + $filter = new FilterOutputData(); + return $this->wrapFilteredResult($filter->filter($result, $op), $result); + } + + /** + * If the source data was wrapped in a marker class such + * as RowsOfFields, then re-apply the wrapper. + */ + protected function wrapFilteredResult($data, $source) + { + if (!$source instanceof \ArrayObject) { + return $data; + } + $sourceClass = get_class($source); + + return new $sourceClass($data); + } + + protected function getFormatterOptions(): FormatterOptions + { + return $this->formatterOptions; + } + + /** + * Public because is used by FormatterListener. + */ + public function setFormatterOptions(FormatterOptions $formatterOptions): void + { + $this->formatterOptions = $formatterOptions; + } + + protected function getPrivatePropValue(mixed $object, $name): mixed + { + $rc = new \ReflectionClass($object); + $prop = $rc->getProperty($name); + return $prop->getValue($object); + } +} diff --git a/src/Listeners/BootstrapListener.php b/src/Listeners/BootstrapListener.php new file mode 100644 index 0000000000..9d3568948f --- /dev/null +++ b/src/Listeners/BootstrapListener.php @@ -0,0 +1,58 @@ +getCommand(); + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(Bootstrap::class); + if (empty($attributes)) { + return; + } + /** @var \Drush\Attributes\Bootstrap $instance */ + $instance = $attributes[0]->newInstance(); + if ($instance->max_level) { + $success = $this->bootstrapManager->bootstrapMax($instance->max_level); + } else { + $success = $this->bootstrapManager->bootstrapToPhaseIndex($instance->level); + } + if (!$success) { + $message = 'Bootstrap failed'; + if (!Drush::verbose()) { + $message .= ' Run your command with -vvv for more information.'; + } + $this->logger->error($message); + $event->disableCommand(); + } + } +} diff --git a/src/Listeners/DrupliconListener.php b/src/Listeners/DrupliconListener.php new file mode 100644 index 0000000000..e3ab49cf1f --- /dev/null +++ b/src/Listeners/DrupliconListener.php @@ -0,0 +1,58 @@ +getApplication()->all() as $id => $command) { + $command->addOption(name: 'druplicon', mode: InputOption::VALUE_NONE, description: 'Shows the druplicon as glorious ASCII art.'); + } + } + + public function onConsoleTerminateEvent(ConsoleTerminateEvent $event): void + { + // If one command does a Drush::drush() to another command, + // then this Listener will be called multiple times. Only print + // once. + if ($this->printed) { + return; + } + $this->printed = true; + if ($event->getInput()->hasOption('druplicon') && $event->getInput()->getOption('druplicon')) { + $misc_dir = $this->drushConfig->get('drush.base-dir') . '/misc'; + if ($event->getInput()->getOption('no-ansi')) { + $content = file_get_contents($misc_dir . '/druplicon-no_color.txt'); + } else { + $content = file_get_contents($misc_dir . '/druplicon-color.txt'); + } + $event->getOutput()->writeln($content); + } + } +} diff --git a/src/Listeners/FilterDefaultFieldListener.php b/src/Listeners/FilterDefaultFieldListener.php new file mode 100644 index 0000000000..a9469aef32 --- /dev/null +++ b/src/Listeners/FilterDefaultFieldListener.php @@ -0,0 +1,31 @@ +getApplication()->all() as $id => $command) { + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflectionObject = new \ReflectionObject($code); + // Add the --filter option if the command has a FilterDefaultField attribute. + $attributes = $reflectionObject->getAttributes(CLI\FilterDefaultField::class); + if (!empty($attributes)) { + $instance = $attributes[0]->newInstance(); + $command->addOption('filter', null, InputOption::VALUE_REQUIRED, 'Filter output based on provided expression. Default field: ' . $instance->field); + } + } + } +} diff --git a/src/Listeners/FormatterListener.php b/src/Listeners/FormatterListener.php new file mode 100644 index 0000000000..631054bef4 --- /dev/null +++ b/src/Listeners/FormatterListener.php @@ -0,0 +1,87 @@ +getApplication()->all() as $id => $command) { + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflectionObject = new \ReflectionObject($code); + if (!$attributes = $reflectionObject->getAttributes(CLI\Formatter::class)) { + continue; + } + + /** @var \Drush\Attributes\Formatter $attribute */ + $attribute = $attributes[0]->newInstance(); + $configurationData = $this->getConfigurationData($reflectionObject); + $formatterOptions = new FormatterOptions($configurationData, []); + assert(method_exists($command, 'setFormatterOptions')); + $command->setFormatterOptions($formatterOptions); + + $inputOptions = $this->formatterManager->automaticOptions($formatterOptions, $attribute->returnType); + foreach ($inputOptions as $inputOption) { + if ($command->getDefinition()->hasOption($inputOption->getName())) { + // This Listener also fires during full bootstrap, so skip if already present. + continue; + } + $mode = $this->getPrivatePropValue($inputOption, 'mode'); + $suggestedValues = $this->getPrivatePropValue($inputOption, 'suggestedValues'); + $command->addOption($inputOption->getName(), $inputOption->getShortcut(), $mode, $inputOption->getDescription(), $inputOption->getDefault(), $suggestedValues); + } + // The command must have a --format option, even if the above didn't add it. + if (!$command->getDefinition()->hasOption('format')) { + $command->addOption(name:'format', mode: InputOption::VALUE_REQUIRED, description: 'A format for printing the returned data'); + } + // Use the command's fallback for --format. The automatic option above doesn't always get it right. + $command->getDefinition()->getOption('format')->setDefault($attribute->defaultFormatter); + } + } + + /** + * Build the formatter configuration from the command's attributes + */ + protected function getConfigurationData(ReflectionObject $reflection): array + { + $configurationData = []; + $attributes = $reflection->getAttributes(); + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + if ($instance instanceof FormatterConfigurationItemProviderInterface) { + $configurationData = array_merge($configurationData, $instance->getConfigurationItem($attribute)); + } + } + return $configurationData; + } + + protected function getPrivatePropValue(mixed $object, $name): mixed + { + $rc = new \ReflectionClass($object); + $prop = $rc->getProperty($name); + return $prop->getValue($object); + } +} diff --git a/src/Listeners/HelpLinksListener.php b/src/Listeners/HelpLinksListener.php new file mode 100644 index 0000000000..818ca5bdf8 --- /dev/null +++ b/src/Listeners/HelpLinksListener.php @@ -0,0 +1,42 @@ +getApplication()->all() as $id => $command) { + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflectionObject = new \ReflectionObject($code); + $attributes = $reflectionObject->getAttributes(CLI\HelpLinks::class); + if (!empty($attributes)) { + // Bail if this Listener has already run on this class. + if (str_contains($command->getHelp(), 'Help topics:')) { + continue; + } + /** @var \Drush\Attributes\HelpLinks $instance */ + $instance = $attributes[0]->newInstance(); + $bullets = array_map(fn($case) => $case->consoleLink(), $instance->links); + $help = $command->getHelp(); + $help .= "\n\n" . self::bullets($bullets); + $command->setHelp($help); + } + } + } + + public static function bullets(array $links) + { + return "Help topics:\n" . implode("\n", $links); + } +} diff --git a/src/Listeners/MessengerListener.php b/src/Listeners/MessengerListener.php new file mode 100644 index 0000000000..98e5a1836b --- /dev/null +++ b/src/Listeners/MessengerListener.php @@ -0,0 +1,54 @@ +messenger->messagesByType(MessengerInterface::TYPE_ERROR) as $message) { + $this->logger->error($prefix . DrupalUtil::drushRender($message)); + } + foreach ($this->messenger->messagesByType(MessengerInterface::TYPE_WARNING) as $message) { + $this->logger->warning($prefix . DrupalUtil::drushRender($message)); + } + foreach ($this->messenger->messagesByType(MessengerInterface::TYPE_STATUS) as $message) { + $this->logger->notice($prefix . DrupalUtil::drushRender($message)); + } + $this->messenger->deleteAll(); + } +} diff --git a/src/Listeners/OptionsetGetEditorListener.php b/src/Listeners/OptionsetGetEditorListener.php new file mode 100644 index 0000000000..d1b8bb2497 --- /dev/null +++ b/src/Listeners/OptionsetGetEditorListener.php @@ -0,0 +1,31 @@ +getApplication()->all() as $id => $command) { + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(CLI\OptionsetGetEditor::class); + if (empty($attributes)) { + continue; + } + $command->addOption(name: 'editor', mode: InputOption::VALUE_REQUIRED, description: 'A string of bash which launches user\'s preferred text editor. Defaults to ${VISUAL-${EDITOR-vi}}.'); + $command->addOption(name: 'bg', mode: InputOption::VALUE_NONE, description: 'Launch editor in background process.'); + } + } +} diff --git a/src/Listeners/OptionsetProcBuildListener.php b/src/Listeners/OptionsetProcBuildListener.php new file mode 100644 index 0000000000..31a493de7c --- /dev/null +++ b/src/Listeners/OptionsetProcBuildListener.php @@ -0,0 +1,31 @@ +getApplication()->all() as $id => $command) { + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(CLI\OptionsetProcBuild::class); + if (empty($attributes)) { + continue; + } + $command->addOption(name: 'ssh-options', mode: InputOption::VALUE_REQUIRED, description: 'A string appended to ssh command during rsync, sql:sync, etc.'); + $command->addOption('tty', 'Create a tty (e.g. to run an interactive program).'); + } + } +} diff --git a/src/Listeners/OptionsetSqlListener.php b/src/Listeners/OptionsetSqlListener.php new file mode 100644 index 0000000000..c8c84b6471 --- /dev/null +++ b/src/Listeners/OptionsetSqlListener.php @@ -0,0 +1,33 @@ +getApplication()->all() as $id => $command) { + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(CLI\OptionsetSql::class); + if (empty($attributes)) { + continue; + } + $command->addOption('database', '', InputOption::VALUE_REQUIRED, 'The DB connection key if using multiple connections in settings.php.', 'default'); + $command->addOption('db-url', '', InputOption::VALUE_REQUIRED, 'A Drupal 6 style database URL. For example mysql://root:pass@localhost:port/dbname'); + $command->addOption('target', '', InputOption::VALUE_REQUIRED, 'The name of a target within the specified database connection.', 'default'); + $command->addOption('show-passwords', '', InputOption::VALUE_NONE, 'Show password on the CLI. Useful for debugging.'); + } + } +} diff --git a/src/Listeners/OptionsetSshListener.php b/src/Listeners/OptionsetSshListener.php new file mode 100644 index 0000000000..95a2aa3cb7 --- /dev/null +++ b/src/Listeners/OptionsetSshListener.php @@ -0,0 +1,30 @@ +getApplication()->all() as $id => $command) { + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(CLI\OptionsetSsh::class); + if (empty($attributes)) { + continue; + } + $command->addOption(name: 'ssh-options', mode: InputOption::VALUE_REQUIRED, description: 'A string appended to ssh command during rsync, sql:sync, etc.'); + } + } +} diff --git a/src/Listeners/OptionsetTableSelectionListener.php b/src/Listeners/OptionsetTableSelectionListener.php new file mode 100644 index 0000000000..a87bda83e7 --- /dev/null +++ b/src/Listeners/OptionsetTableSelectionListener.php @@ -0,0 +1,36 @@ +getApplication()->all() as $id => $command) { + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(OptionsetTableSelection::class); + if (empty($attributes)) { + continue; + } + $command->addOption('skip-tables-key', '', InputOption::VALUE_REQUIRED, 'A key in the $skip_tables array. @see [Site aliases](../site-aliases.md)'); + $command->addOption('structure-tables-key', '', InputOption::VALUE_REQUIRED, 'A key in the $structure_tables array. @see [Site aliases](../site-aliases.md)'); + $command->addOption('tables-key', '', InputOption::VALUE_REQUIRED, 'A key in the $tables array.'); + $command->addOption('skip-tables-list', '', InputOption::VALUE_REQUIRED, 'A comma-separated list of tables to exclude completely.'); + $command->addOption('structure-tables-list', '', InputOption::VALUE_REQUIRED, 'A comma-separated list of tables to include for structure, but not data.'); + $command->addOption('tables-list', '', InputOption::VALUE_REQUIRED, 'A comma-separated list of tables to transfer.', []); + } + } +} diff --git a/src/Listeners/ValidateConfigNameListener.php b/src/Listeners/ValidateConfigNameListener.php new file mode 100644 index 0000000000..b3dcdae9ed --- /dev/null +++ b/src/Listeners/ValidateConfigNameListener.php @@ -0,0 +1,54 @@ +getCommand(); + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(ValidateConfigName::class); + if (empty($attributes)) { + return; + } + /** @var ValidateConfigName $instance */ + $instance = $attributes[0]->newInstance(); + if (!$command->getDefinition()->hasArgument($instance->argumentName)) { + return; + } + $names = StringUtils::csvToArray($event->getInput()->getArgument($instance->argumentName)); + foreach ($names as $name) { + $config = $this->configFactory->get($name); + if ($config->isNew()) { + $this->logger->error('Config {name} does not exist', ['name' => $name]); + $event->disableCommand(); + } + } + } +} diff --git a/src/Listeners/ValidateEntityLoadListener.php b/src/Listeners/ValidateEntityLoadListener.php new file mode 100644 index 0000000000..58c6c1faff --- /dev/null +++ b/src/Listeners/ValidateEntityLoadListener.php @@ -0,0 +1,49 @@ +getCommand(); + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(ValidateEntityLoad::class); + if (empty($attributes)) { + return; + } + $instance = $attributes[0]->newInstance(); + $names = StringUtils::csvToArray($event->getInput()->getArgument($instance->argumentName)); + $loaded = $this->entityTypeManager->getStorage($instance->entityType)->loadMultiple($names); + if ($missing = array_diff($names, array_keys($loaded))) { + $context = ['type' => $instance->entityType, 'str' => implode(', ', $missing)]; + $this->logger->error('Unable to load the {type}: {str}', $context); + $event->disableCommand(); + } + } +} diff --git a/src/Listeners/ValidateFileExistsListener.php b/src/Listeners/ValidateFileExistsListener.php new file mode 100644 index 0000000000..dc9bbade0d --- /dev/null +++ b/src/Listeners/ValidateFileExistsListener.php @@ -0,0 +1,57 @@ +getCommand(); + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(ValidateFileExists::class); + if (empty($attributes)) { + return; + } + /** @var ValidateFileExists $instance */ + $instance = $attributes[0]->newInstance(); + $missing = []; + // @todo handle option as well. + if (!$command->getDefinition()->hasArgument($instance->argName)) { + return; + } + $paths = StringUtils::csvToArray($event->getInput()->getArgument($instance->argName)); + foreach ($paths as $path) { + if (!empty($path) && !file_exists($path)) { + $missing[] = $path; + } + } + + if ($missing) { + $this->logger->error('File(s) not found: {paths}', ['paths' => implode(', ', $missing)]); + $event->disableCommand(); + } + } +} diff --git a/src/Listeners/ValidateMigrationIdListener.php b/src/Listeners/ValidateMigrationIdListener.php new file mode 100644 index 0000000000..ad49ebaa47 --- /dev/null +++ b/src/Listeners/ValidateMigrationIdListener.php @@ -0,0 +1,54 @@ +has('plugin.manager.migration')) { + return new self($container->get('plugin.manager.migration'), $container->get('logger')); + } + // Do nothing. Command never gets added to the Application. + } + + /** + * This subscriber affects commands which put #[ValidateMigrationId] on the *class*. + * Method usages are enforced by Annotated Command still. + */ + public function __invoke(ConsoleCommandEvent $event): void + { + $command = $event->getCommand(); + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(ValidateMigrationId::class); + if (empty($attributes)) { + return; + } + /** @var ValidateMigrationId $instance */ + $instance = $attributes[0]->newInstance(); + // $argName = $commandData->annotationData()->get('validate-migration-id') ?: 'migrationId'; + $migrationId = $event->getInput()->getArgument('migrationId'); + if (!$this->migrationPluginManager->hasDefinition($migrationId)) { + $this->logger->error('Migration "{id}" does not exist', ['id' => $migrationId]); + $event->disableCommand(); + } + } +} diff --git a/src/Listeners/ValidateModulesEnabledListener.php b/src/Listeners/ValidateModulesEnabledListener.php new file mode 100644 index 0000000000..e0e0d2e18b --- /dev/null +++ b/src/Listeners/ValidateModulesEnabledListener.php @@ -0,0 +1,46 @@ +getCommand(); + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(ValidateModulesEnabled::class); + if (empty($attributes)) { + return; + } + $instance = $attributes[0]->newInstance(); + $missing = array_filter($instance->modules, fn($module) => !$this->moduleHandler->moduleExists($module)); + if ($missing) { + $this->logger->error('The following modules are required: {modules}', ['modules' => implode(', ', $missing)]); + $event->disableCommand(); + } + } +} diff --git a/src/Listeners/ValidatePermissionsListener.php b/src/Listeners/ValidatePermissionsListener.php new file mode 100644 index 0000000000..43441555f5 --- /dev/null +++ b/src/Listeners/ValidatePermissionsListener.php @@ -0,0 +1,50 @@ +getCommand(); + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(ValidatePermissions::class); + if (empty($attributes)) { + return; + } + /** @var ValidatePermissions $instance */ + $instance = $attributes[0]->newInstance(); + $permissions = StringUtils::csvToArray($event->getInput()->getArgument($instance->argName)); + $all_permissions = array_keys($this->permissionHandler->getPermissions()); + $missing = array_diff($permissions, $all_permissions); + if ($missing) { + $this->logger->error('Permission(s) not found: {perms}', ['perms' => implode(', ', $missing)]); + $event->disableCommand(); + } + } +} diff --git a/src/Listeners/ValidateQueueNameListener.php b/src/Listeners/ValidateQueueNameListener.php new file mode 100644 index 0000000000..7bd71b13da --- /dev/null +++ b/src/Listeners/ValidateQueueNameListener.php @@ -0,0 +1,59 @@ +getCommand(); + // Support invokable commands (Symfony Console 7.4+). + $code = method_exists($command, 'getCode') && $command->getCode() ? $command->getCode() : $command; + $reflection = new \ReflectionObject($code); + $attributes = $reflection->getAttributes(ValidateQueueName::class); + if (empty($attributes)) { + return; + } + + /** @var ValidateQueueName $instance */ + $instance = $attributes[0]->newInstance(); + if (!$command->getDefinition()->hasArgument($instance->argumentName)) { + return; + } + $names = StringUtils::csvToArray($event->getInput()->getArgument($instance->argumentName)); + $missing = array_diff($names, array_keys($this->getQueues())); + if ($missing) { + $this->logger->error('Queue name(s) not found: {names}', ['names' => implode(', ', $missing)]); + $event->disableCommand(); + } + } + + // In 14.x this is in QueueTrait + public static function getQueues(): array + { + return array_keys(\Drupal::service('plugin.manager.queue_worker')->getDefinitions()); + } +} diff --git a/src/Listeners/sanitize/SanitizeUserFieldsListener.php b/src/Listeners/sanitize/SanitizeUserFieldsListener.php new file mode 100644 index 0000000000..cc44c0016b --- /dev/null +++ b/src/Listeners/sanitize/SanitizeUserFieldsListener.php @@ -0,0 +1,108 @@ +addMessage(dt('Sanitize text fields associated with users.')); + } + + public function onConsoleTerminate(ConsoleTerminateEvent $event): void + { + if ($event->getCommand()->getName() !== 'sql:sanitize') { + return; + } + + $options = $event->getInput()->getOptions(); + $field_definitions = $this->entityFieldManager->getFieldDefinitions('user', 'user'); + $field_storage = $this->entityFieldManager->getFieldStorageDefinitions('user'); + foreach (StringUtils::csvToArray($options['allowlist-fields']) as $key) { + unset($field_definitions[$key], $field_storage[$key]); + } + + $sanitized = false; + foreach ($field_definitions as $key => $def) { + if (!isset($field_storage[$key]) || $field_storage[$key]->isBaseField()) { + continue; + } + + $table = 'user__' . $key; + $query = $this->database->update($table); + $name = $def->getName(); + $field_type_class = $this->fieldTypePluginManager->getPluginClass($def->getType()); + $supported_field_types = ['email', 'string', 'string_long', 'telephone', 'text', 'text_long', 'text_with_summary']; + if (in_array($def->getType(), $supported_field_types)) { + $value_array = $field_type_class::generateSampleValue($def); + $value = $value_array['value']; + } else { + continue; + } + switch ($def->getType()) { + case 'string': + case 'string_long': + case 'text': + case 'text_long': + case 'email': + $query->fields([$name . '_value' => $value]); + $execute = true; + break; + + case 'telephone': + $query->fields([$name . '_value' => '15555555555']); + $execute = true; + break; + + case 'text_with_summary': + $query->fields([ + $name . '_value' => $value, + $name . '_summary' => $value_array['summary'], + ]); + $execute = true; + break; + default: + $execute = false; + } + if ($execute) { + $sanitized = true; + $query->execute(); + $this->entityTypeManager->getStorage('user')->resetCache(); + $this->logger->notice(dt('!table table sanitized.', ['!table' => $table])); + } + } + if (!$sanitized) { + $this->logger->notice(dt('No text fields for users need sanitizing.')); + } + } +} diff --git a/src/Listeners/sanitize/SanitizeUserTableListener.php b/src/Listeners/sanitize/SanitizeUserTableListener.php new file mode 100644 index 0000000000..6b4c1364c3 --- /dev/null +++ b/src/Listeners/sanitize/SanitizeUserTableListener.php @@ -0,0 +1,130 @@ +getInput()->getOptions(); + if ($this->isEnabled($options['sanitize-password'])) { + $event->addMessage(dt('Sanitize user passwords.')); + } + if ($this->isEnabled($options['sanitize-email'])) { + $event->addMessage(dt('Sanitize user emails.')); + } + if (!empty($options['ignored-roles'])) { + $event->addMessage(dt('Preserve user emails and passwords for the specified roles.')); + } + } + + public function onConsoleTerminate(ConsoleTerminateEvent $event): void + { + if ($event->getCommand()->getName() !== 'sql:sanitize') { + return; + } + + $options = $event->getInput()->getOptions(); + $query = $this->database->update('users_field_data')->condition('uid', 0, '>'); + $messages = []; + + // Sanitize passwords. + if ($this->isEnabled($options['sanitize-password'])) { + $password = $options['sanitize-password']; + if (is_null($password)) { + $password = StringUtils::generatePassword(); + } + + // Mimic Drupal's /scripts/password-hash.sh + $hash = $this->passwordHasher->hash($password); + $query->fields(['pass' => $hash]); + $messages[] = dt('User passwords sanitized.'); + } + + // Sanitize email addresses. + if ($this->isEnabled($options['sanitize-email'])) { + if (str_contains($options['sanitize-email'], '%')) { + // We need a different sanitization query for MSSQL, Postgres and Mysql. + $sql = SqlBase::create($event->getInput()->getOptions()); + $db_driver = $sql->scheme(); + if ($db_driver === 'pgsql') { + $email_map = ['%uid' => "' || uid || '", '%mail' => "' || replace(mail, '@', '_') || '", '%name' => "' || replace(name, ' ', '_') || '"]; + $new_mail = "'" . str_replace(array_keys($email_map), array_values($email_map), $options['sanitize-email']) . "'"; + } elseif ($db_driver === 'mssql') { + $email_map = ['%uid' => "' + uid + '", '%mail' => "' + replace(mail, '@', '_') + '", '%name' => "' + replace(name, ' ', '_') + '"]; + $new_mail = "'" . str_replace(array_keys($email_map), array_values($email_map), $options['sanitize-email']) . "'"; + } else { + $email_map = ['%uid' => "', uid, '", '%mail' => "', replace(mail, '@', '_'), '", '%name' => "', replace(name, ' ', '_'), '"]; + $new_mail = "concat('" . str_replace(array_keys($email_map), array_values($email_map), $options['sanitize-email']) . "')"; + } + $query->expression('mail', $new_mail); + $query->expression('init', $new_mail); + } else { + $query->fields(['mail' => $options['sanitize-email']]); + } + $messages[] = dt('User emails sanitized.'); + } + + if (!empty($options['ignored-roles'])) { + $roles = explode(',', $options['ignored-roles']); + /** @var SelectInterface $roles_query */ + $roles_query = $this->database->select('user__roles', 'ur'); + $roles_query + ->condition('roles_target_id', $roles, 'IN') + ->fields('ur', ['entity_id']); + $roles_query_results = $roles_query->execute(); + $ignored_users = $roles_query_results->fetchCol(); + + if (!empty($ignored_users)) { + $query->condition('uid', $ignored_users, 'NOT IN'); + $messages[] = dt('User emails and passwords for the specified roles preserved.'); + } + } + + if ($messages) { + $query->execute(); + $this->entityTypeManager->getStorage('user')->resetCache(); + foreach ($messages as $message) { + $this->logger->notice($message); + } + } + } + + /** + * Test an option value to see if it is disabled. + */ + protected function isEnabled(?string $value): bool + { + return $value != 'no' && $value != '0'; + } +} diff --git a/src/Log/DrushLoggerManager.php b/src/Log/DrushLoggerManager.php index 5d9dc40cfd..dd1ec39a60 100644 --- a/src/Log/DrushLoggerManager.php +++ b/src/Log/DrushLoggerManager.php @@ -5,11 +5,16 @@ namespace Drush\Log; use Consolidation\Log\LoggerManager; +use JetBrains\PhpStorm\Deprecated; class DrushLoggerManager extends LoggerManager implements SuccessInterface { + const DEPRECATED_MESSAGE = 'Use \Drush\Style\DrushStyle::success() instead. See https://www.drush.org/latest/commands/.'; + + #[Deprecated(self::DEPRECATED_MESSAGE)] public function success(string $message, array $context = array()) { + trigger_deprecation('drush/drush', '14.0.0', self::DEPRECATED_MESSAGE); $this->log(self::SUCCESS, $message, $context); } } diff --git a/src/Preflight/ArgsPreprocessor.php b/src/Preflight/ArgsPreprocessor.php index 128efa7c45..ac9ae28ef0 100644 --- a/src/Preflight/ArgsPreprocessor.php +++ b/src/Preflight/ArgsPreprocessor.php @@ -76,7 +76,7 @@ public function parse(array $argv, PreflightArgsInterface $storage) $sawArg = true; } - list($methodName, $value, $acceptsValueFromNextArg) = $this->findMethodForOptionWithValues($optionsTable, $opt); + [$methodName, $value, $acceptsValueFromNextArg] = $this->findMethodForOptionWithValues($optionsTable, $opt); if ($methodName) { if (!isset($value) && $acceptsValueFromNextArg && static::nextCouldBeValue($argv)) { $value = array_shift($argv); @@ -198,7 +198,8 @@ protected function checkMatchingOption(string $opt, string $keyParam, string $me // If $opt is a double-dash option, and it contains an '=', then // the option value is everything after the '='. - if ((strlen($key) < strlen($opt)) && ($opt[1] === '-') && ($opt[strlen($key)] === '=')) { + // @phpstan-ignore-next-line + if ((strlen($key) < strlen($opt)) && ($opt[1] == '-') && ($opt[strlen($key)] == '=')) { $value = substr($opt, strlen($key) + 1); return [$methodName, $value, false]; } diff --git a/src/Runtime/ConfiguresPrompts.php b/src/Runtime/ConfiguresPrompts.php new file mode 100644 index 0000000000..a9dbcf6850 --- /dev/null +++ b/src/Runtime/ConfiguresPrompts.php @@ -0,0 +1,21 @@ +output); + + Prompt::cancelUsing(function (): never { + Runtime::setCompleted(); + exit(1); + }); + + Prompt::interactive(($input->isInteractive() && defined('STDIN') && stream_isatty(STDIN)) || $this->runningUnitTests()); + + Prompt::fallbackWhen(!$input->isInteractive() || strtoupper(substr(PHP_OS, 0, 3)) === "WIN" || $this->runningUnitTests()); + + TextPrompt::fallbackUsing(fn (TextPrompt $prompt) => $this->promptUntilValid( + fn () => (new SymfonyStyle($this->input, $this->output))->ask($prompt->label, $prompt->default ?: null) ?? '', + $prompt->required, + $prompt->validate + )); + + TextareaPrompt::fallbackUsing(fn(TextareaPrompt $prompt) => $this->promptUntilValid( + fn() => (new SymfonyStyle($this->input, $this->output))->ask($prompt->label, $prompt->default ?: null) ?? '', // multiline: true + $prompt->required, + $prompt->validate + )); + + PasswordPrompt::fallbackUsing(fn (PasswordPrompt $prompt) => $this->promptUntilValid( + fn() => (new SymfonyStyle($this->input, $this->output))->askHidden($prompt->label) ?? '', + $prompt->required, + $prompt->validate + )); + + ConfirmPrompt::fallbackUsing(fn (ConfirmPrompt $prompt) => $this->promptUntilValid( + fn () => (new SymfonyStyle($this->input, $this->output))->confirm($prompt->label, $prompt->default), + $prompt->required, + $prompt->validate + )); + + SelectPrompt::fallbackUsing(fn (SelectPrompt $prompt) => $this->promptUntilValid( + // Cast null to empty string to satisfy return type of select(). + fn () => (new SymfonyStyle($this->input, $this->output))->choice($prompt->label, $prompt->options, $prompt->default) ?? '', + false, + $prompt->validate + )); + + MultiSelectPrompt::fallbackUsing(function (MultiSelectPrompt $prompt) { + $style = new SymfonyStyle($this->input, $this->output); + if ($prompt->default !== []) { + return $this->promptUntilValid( + fn () => $style->choice($prompt->label, $prompt->options, implode(',', $prompt->default), true), + $prompt->required, + $prompt->validate + ); + } + + return $this->promptUntilValid( + // MW: Had to change to 'none' as key to fix test failure. Deviates from Laravel. + fn () => array_filter( + $style->choice($prompt->label, ['none' => 'None', ...$prompt->options], 'none', true), + fn ($option, $key) => $key !== 'none', + ARRAY_FILTER_USE_BOTH, + ), + $prompt->required, + $prompt->validate + ); + }); + + SuggestPrompt::fallbackUsing(fn (SuggestPrompt $prompt) => $this->promptUntilValid( + fn() => (new SymfonyStyle($this->input, $this->output))->choice($prompt->label, $prompt->options, $prompt->default ?: null) ?? '', + $prompt->required, + $prompt->validate + )); + + SearchPrompt::fallbackUsing(fn (SearchPrompt $prompt) => $this->promptUntilValid( + function () use ($prompt) { + $query = (new SymfonyStyle($this->input, $this->output))->ask($prompt->label); + + $options = ($prompt->options)($query); + + return (new SymfonyStyle($this->input, $this->output))->choice($prompt->label, $options); + }, + false, + $prompt->validate + )); + + MultiSearchPrompt::fallbackUsing(fn (MultiSearchPrompt $prompt) => $this->promptUntilValid( + function () use ($prompt) { + $style = new SymfonyStyle($this->input, $this->output); + $query = $style->ask($prompt->label); + + $options = ($prompt->options)($query); + + if ($prompt->required === false) { + if (array_is_list($options)) { + return array_filter( + $style->choice($prompt->label, ['None', ...$options], 'None', true), + fn ($option) => $option !== 'None', + ); + } + + return array_filter( + $style->choice($prompt->label, ['' => 'None', ...$options], '', true), + fn ($option, $key) => $key !== '', + ARRAY_FILTER_USE_BOTH, + ); + } + + return $style->choice($prompt->label, $options, true); + }, + $prompt->required, + $prompt->validate + )); + } + + /** + * Prompt the user until the given validation callback passes. + * + * @param \Closure $prompt + * @param bool|string $required + * @param \Closure|null $validate + * @return mixed + */ + protected function promptUntilValid($prompt, $required, $validate) + { + while (true) { + $result = $prompt(); + $style = new SymfonyStyle($this->input, $this->output); + + if ($required && ($result === '' || $result === [] || $result === false)) { + $style->error(is_string($required) ? $required : 'Required.'); + + continue; + } + + if ($validate) { + $error = $validate($result); + + if (is_string($error) && strlen($error) > 0) { + $style->error($error); + + continue; + } + } + + return $result; + } + } + + /** + * Restore the prompts output. + * + * @return void + */ + protected function restorePrompts() + { + Prompt::setOutput($this->output); + } + + protected function runningUnitTests(): bool + { + // is not PHPUnit run + return !(! defined('PHPUNIT_COMPOSER_INSTALL') && ! defined('__PHPUNIT_PHAR__')); + } +} diff --git a/src/Runtime/DependencyInjection.php b/src/Runtime/DependencyInjection.php index 82fca40036..74eafecea7 100644 --- a/src/Runtime/DependencyInjection.php +++ b/src/Runtime/DependencyInjection.php @@ -8,6 +8,7 @@ use Consolidation\AnnotatedCommand\CommandFileDiscovery; use Consolidation\Config\ConfigInterface; use Consolidation\Config\Util\ConfigOverlay; +use Consolidation\OutputFormatters\FormatterManager; use Consolidation\SiteAlias\SiteAliasManager; use Consolidation\SiteAlias\SiteAliasManagerAwareInterface; use Consolidation\SiteAlias\SiteAliasManagerInterface; @@ -24,11 +25,14 @@ use Drush\Drush; use Drush\Formatters\DrushFormatterManager; use Drush\Formatters\EntityToArraySimplifier; +use Drush\Log\DrushLoggerManager; use Drush\Log\Logger; use Drush\SiteAlias\ProcessManager; use Drush\Symfony\DrushStyleInjector; use League\Container\Container; use League\Container\ContainerInterface; +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; use Robo\Robo; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -39,8 +43,10 @@ */ class DependencyInjection { + const FORMATTER_MANAGER = 'formatterManager'; const SITE_ALIAS_MANAGER = 'site.alias.manager'; const BOOTSTRAP_MANAGER = 'bootstrap.manager'; + const PROCESS_MANAGER = 'process.manager'; const LOADER = 'loader'; protected array $handlers = []; @@ -66,17 +72,18 @@ public function initContainer( $container = new Container(); // With league/container 3.x, first call wins, so add Drush services first. - $this->addDrushServices($container, $loader, $drupalFinder, $aliasManager, $config, $output); + $this->addDrushServices($container, $loader, $drupalFinder, $aliasManager, $config, $output, $input); // Robo has the same signature for configureContainer in 1.x, 2.x and 3.x. Robo::configureContainer($container, $application, $config, $input, $output); $container->add('container', $container); + $container->add(\Psr\Container\ContainerInterface::class, 'container'); // For autowiring // Store the container in the \Drush object Drush::setContainer($container); // Change service definitions as needed for our application. - $this->alterServicesForDrush($container, $application); + $this->alterServicesForDrush($container, $application, $input, $output); // Inject needed services into our application object. $this->injectApplicationServices($container, $application); @@ -96,12 +103,13 @@ public function installHandlers($container): void } // Add Drush Services to league/container 3.x - protected function addDrushServices($container, ClassLoader $loader, DrushDrupalFinder $drupalFinder, SiteAliasManager $aliasManager, DrushConfig $config, OutputInterface $output): void + protected function addDrushServices(Container $container, ClassLoader $loader, DrushDrupalFinder $drupalFinder, SiteAliasManager $aliasManager, DrushConfig $config, OutputInterface $output, InputInterface $input): void { // Override Robo's logger with a LoggerManager that delegates to the Drush logger. - Robo::addShared($container, 'logger', '\Drush\Log\DrushLoggerManager') - ->addMethodCall('setLogOutputStyler', ['logStyler']) - ->addMethodCall('add', ['drush', new Logger($output)]); + Robo::addShared($container, 'logger', DrushLoggerManager::class) + ->addMethodCall('setLogOutputStyler', ['logStyler']) + ->addMethodCall('add', ['drush', new Logger($output)]); + Robo::addShared($container, LoggerInterface::class, 'logger'); // For autowiring Robo::addShared($container, self::LOADER, $loader); Robo::addShared($container, ClassLoader::class, self::LOADER); // For autowiring @@ -114,10 +122,11 @@ protected function addDrushServices($container, ClassLoader $loader, DrushDrupal // Override Robo's formatter manager with our own // @todo not sure that we'll use this. Maybe remove it. - Robo::addShared($container, 'formatterManager', DrushFormatterManager::class) + Robo::addShared($container, self::FORMATTER_MANAGER, DrushFormatterManager::class) ->addMethodCall('addDefaultFormatters', []) ->addMethodCall('addDefaultSimplifiers', []) ->addMethodCall('addSimplifier', [new EntityToArraySimplifier()]); + Robo::addShared($container, FormatterManager::class, self::FORMATTER_MANAGER); // For autowiring // Add some of our own objects to the container Robo::addShared($container, 'service.manager', ServiceManager::class) @@ -134,12 +143,14 @@ protected function addDrushServices($container, ClassLoader $loader, DrushDrupal Robo::addShared($container, 'bootstrap.hook', BootstrapHook::class) ->addArgument(self::BOOTSTRAP_MANAGER); Robo::addShared($container, 'tildeExpansion.hook', TildeExpansionHook::class); - Robo::addShared($container, 'process.manager', ProcessManager::class) + Robo::addShared($container, self::PROCESS_MANAGER, ProcessManager::class) ->addMethodCall('setConfig', ['config']) ->addMethodCall('setConfigRuntime', ['config.runtime']) ->addMethodCall('setDrupalFinder', [$drupalFinder]); + Robo::addShared($container, ProcessManager::class, self::PROCESS_MANAGER); // For autowiring Robo::addShared($container, 'redispatch.hook', RedispatchHook::class) - ->addArgument('process.manager'); + ->addArgument(self::PROCESS_MANAGER); + ; // Robo does not manage the command discovery object in the container, // but we will register and configure one for our use. @@ -157,14 +168,20 @@ protected function addDrushServices($container, ClassLoader $loader, DrushDrupal $container->inflector(SiteAliasManagerAwareInterface::class) ->invokeMethod('setSiteAliasManager', [self::SITE_ALIAS_MANAGER]); $container->inflector(ProcessManagerAwareInterface::class) - ->invokeMethod('setProcessManager', ['process.manager']); + ->invokeMethod('setProcessManager', [self::PROCESS_MANAGER]); } - protected function alterServicesForDrush($container, Application $application): void + protected function alterServicesForDrush($container, Application $application, InputInterface $input, OutputInterface $output): void { $paramInjection = $container->get('parameterInjection'); $paramInjection->register(SymfonyStyle::class, new DrushStyleInjector()); + // Alias the dispatcher service that is defined in \Robo\Robo::configureContainer. + Robo::addShared($container, EventDispatcherInterface::class, 'eventDispatcher'); // For autowiring + + // Alias the config service that is defined in \Robo\Robo::configureContainer. + Robo::addShared($container, DrushConfig::class, 'config'); // For autowiring + // Add our own callback to the hook manager $hookManager = $container->get('hookManager'); $hookManager->addCommandEvent(new GlobalOptionsEventListener()); @@ -180,7 +197,7 @@ protected function alterServicesForDrush($container, Application $application): $commandProcessor = $container->get('commandProcessor'); $commandProcessor->setPassExceptions(true); - ProcessManager::addTransports($container->get('process.manager')); + ProcessManager::addTransports($container->get(self::PROCESS_MANAGER)); } protected function injectApplicationServices($container, Application $application): void diff --git a/src/Runtime/LegacyServiceFinder.php b/src/Runtime/LegacyServiceFinder.php index 11e64e408b..3a8be055af 100644 --- a/src/Runtime/LegacyServiceFinder.php +++ b/src/Runtime/LegacyServiceFinder.php @@ -159,11 +159,11 @@ protected function findAppropriateServicesFile($module, $services, $dir) * Add a services.yml file if it exists. * * @param string $serviceProviderName Arbitrary name for temporary use only - * @param string $serviceYmlPath Path to drush.services.yml file + * @param ?string $serviceYmlPath Path to drush.services.yml file */ protected function addDrushServiceProvider($serviceProviderName, $serviceYmlPath = '') { - if (($serviceYmlPath !== null) && file_exists($serviceYmlPath)) { + if ((!is_null($serviceYmlPath)) && file_exists($serviceYmlPath)) { // Keep our own list of service files $this->drushServiceYamls[$serviceProviderName] = $serviceYmlPath; // This is how we used to add our drush.services.yml file diff --git a/src/Runtime/Runtime.php b/src/Runtime/Runtime.php index 288a9cff04..111a60b1d3 100644 --- a/src/Runtime/Runtime.php +++ b/src/Runtime/Runtime.php @@ -4,11 +4,11 @@ namespace Drush\Runtime; -use Symfony\Component\Console\Output\ConsoleOutput; use Drush\Application; use Drush\Commands\DrushCommands; use Drush\Drush; use Drush\Preflight\Preflight; +use Symfony\Component\Console\Output\ConsoleOutput; /** * Control the Drush runtime environment @@ -104,6 +104,9 @@ protected function doRun($argv, $output): int // and output objects are ready & we can start using the logger, etc. $application->configureAndRegisterCommands($input, $output, $commandfileSearchpath, $this->preflight->environment()->loader()); + // Configure Laravel prompts. + (new ConfiguresPrompts($input, $output))->configurePrompts($input); + // Run the Symfony Application // Predispatch: call a remote Drush command if applicable (via a 'pre-init' hook) // Bootstrap: bootstrap site to the level requested by the command (via a 'post-init' hook) diff --git a/src/Runtime/ServiceManager.php b/src/Runtime/ServiceManager.php index 9b443d9783..73aa7c3c7d 100644 --- a/src/Runtime/ServiceManager.php +++ b/src/Runtime/ServiceManager.php @@ -5,6 +5,7 @@ namespace Drush\Runtime; use Composer\Autoload\ClassLoader; +use Composer\InstalledVersions; use Consolidation\AnnotatedCommand\CommandFileDiscovery; use Consolidation\AnnotatedCommand\Events\CustomEventAwareInterface; use Consolidation\AnnotatedCommand\Input\StdinAwareInterface; @@ -19,19 +20,25 @@ use Drush\Boot\DrupalBootLevels; use Drush\Commands\DrushCommands; use Drush\Config\DrushConfig; +use Drush\Drush; use Grasmash\YamlCli\Command\GetValueCommand; use Grasmash\YamlCli\Command\LintCommand; use Grasmash\YamlCli\Command\UnsetKeyCommand; use Grasmash\YamlCli\Command\UpdateKeyCommand; use Grasmash\YamlCli\Command\UpdateValueCommand; use League\Container\Container as DrushContainer; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; use Robo\ClassDiscovery\RelativeNamespaceDiscovery; use Robo\Contract\ConfigAwareInterface; use Robo\Contract\OutputAwareInterface; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\ConsoleEvents; +use Symfony\Component\Console\Event\ConsoleCommandEvent; +use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Console\Input\InputAwareInterface; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; /** * Manage Drush services. @@ -55,6 +62,9 @@ class ServiceManager /** @var string[] */ protected array $bootstrapCommandClasses = []; + /** @var string[] */ + protected array $bootstrapListenerClasses = []; + public function __construct( protected ClassLoader $autoloader, protected DrushConfig $config, @@ -88,6 +98,17 @@ public function bootstrapCommandClasses(): array return $this->bootstrapCommandClasses; } + /** + * Return cached of deferred scubscriber objects. + * + * @return string[] + * List of class names to instantiate at bootstrap time. + */ + public function bootstrapListenerClasses(): array + { + return $this->bootstrapListenerClasses; + } + /** * Discover all of the different kinds of command handler objects * in the places where Drush can find them. Called during preflight; @@ -96,7 +117,7 @@ public function bootstrapCommandClasses(): array * * @param string[] $commandfileSearchpath List of directories to search * @param string $baseNamespace The namespace to use at the base of each - * search diretory. Namespace components mirror directory structure. + * search directory. Namespace components mirror directory structure. * * @return string[] * List of command classes @@ -262,6 +283,32 @@ public function discoverModuleCommandInfoAlterers(array $directoryList, string $ return array_values($commandClasses); } + /** + * Discovers Listener classes from a provided search path. + * + * @param string[] $directoryList List of directories to search + * @param string $baseNamespace The namespace to use at the base of each + * search directory. Namespace components mirror directory structure. + * + * @return string[] + * List Listeners. + */ + public function discoverListeners(array $directoryList, string $baseNamespace): array + { + $discovery = new CommandFileDiscovery(); + $discovery + ->setIncludeFilesAtBase(true) + ->setSearchDepth(3) + ->ignoreNamespacePart('contrib', 'Listeners') + ->ignoreNamespacePart('custom', 'Listeners') + ->ignoreNamespacePart('src') + ->setSearchLocations(['Listeners']) + ->setSearchPattern('#.*(Listener)s?.php$#'); + $baseNamespace = ltrim($baseNamespace, '\\'); + $listenerClasses = $discovery->discover($directoryList, $baseNamespace); + return array_values($listenerClasses); + } + /** * Instantiate commands from Grasmash\YamlCli that we want to expose * as Drush commands. @@ -280,7 +327,6 @@ public function instantiateYamlCliCommands(): array ]; foreach ($classes_yaml as $class_yaml) { - /** @var Command $instance */ $instance = new $class_yaml(); // Namespace the commands. $name = $instance->getName(); @@ -324,12 +370,12 @@ public function instantiateDrupalCoreBootstrappedCommands(): array * Drupal and Drush DI containers. If there is no static factory, then * instantiate it via 'new $class' * - * @param string[] $bootstrapCommandClasses Classes to instantiate. + * @param string[] $serviceClasses Classes to instantiate. * * @return object[] * List of instantiated service objects */ - public function instantiateServices(array $bootstrapCommandClasses, DrushContainer $drushContainer, ?DrupalContainer $container = null): array + public function instantiateServices(array $serviceClasses, DrushContainer $drushContainer, ?DrupalContainer $container = null): array { $commandHandlers = []; @@ -337,7 +383,7 @@ public function instantiateServices(array $bootstrapCommandClasses, DrushContain // particularly DrushCommands (our abstract base class). // n.b. we cannot simply use 'isInstantiable' here because // the constructor is typically protected when using a static create method - $bootstrapCommandClasses = array_filter($bootstrapCommandClasses, function ($class) { + $serviceClasses = array_filter($serviceClasses, function ($class) { try { $reflection = new \ReflectionClass($class); } catch (\Throwable $e) { @@ -351,7 +397,7 @@ public function instantiateServices(array $bootstrapCommandClasses, DrushContain // Combine the two containers. $drushContainer->delegate($container); } - foreach ($bootstrapCommandClasses as $class) { + foreach ($serviceClasses as $class) { $commandHandler = null; try { @@ -375,7 +421,58 @@ public function instantiateServices(array $bootstrapCommandClasses, DrushContain } } - return $commandHandlers; + // Omit any null commandhandlers that chose not to instantiate.. + return array_filter($commandHandlers); + } + + /** + * Robo does not support invokable commands, so build a Command as needed. + */ + public function commandFromInvokable(array &$callables): array + { + $return = []; + + foreach ($callables as $key => $callable) { + $return[$key] = $callable; + if (is_callable($callable) && version_compare(InstalledVersions::getVersion('symfony/console'), '7.4.0', '>=')) { + // @phpstan-ignore arguments.count + $return[$key] = new Command(null, $callable); + } + } + + return $return; + } + + /** + * Add listeners to Drush's event dispatcher. + */ + public function addListeners(iterable $classes, ContainerInterface $drushContainer, ?ContainerInterface $drupalContainer = null): void + { + $instances = $this->instantiateServices($classes, $drushContainer, $drupalContainer); + foreach ($instances as $instance) { + $reflectionObject = new \ReflectionObject($instance); + $attributes = $reflectionObject->getAttributes(AsEventListener::class); + foreach ($attributes as $attribute) { + $attributeInstance = $attribute->newInstance(); + $method = $attributeInstance->method ?? '__invoke'; + $priority = $attributeInstance->priority ?? 0; + $reflectionMethod = $reflectionObject->getMethod($method); + $reflectionParameters = $reflectionMethod->getParameters(); + $paramType = $reflectionParameters[0]->getType(); + if ($paramType instanceof \ReflectionNamedType) { + $eventName = $paramType->getName(); + } else { + throw new \Exception('Event listener method must have a single parameter with a type hint.'); + } + $eventName = match ($eventName) { + ConsoleCommandEvent::class => ConsoleEvents::COMMAND, + ConsoleTerminateEvent::class => ConsoleEvents::TERMINATE, + default => $eventName, + }; + $this->logger->debug('Add listener {class}::{method}', ['class' => $instance::class, 'method' => $method]); + Drush::getContainer()->get('eventDispatcher')->addListener($eventName, $instance->$method(...), $priority); + } + } } /** @@ -412,6 +509,16 @@ protected function bootStrapAttributeValue(string $class): ?int return null; } + // If a command class has a Bootstrap Attribute or static `create` method, we + // postpone instantiating it until after we bootstrap Drupal. + public function filterListeners($listenClasses): array + { + $this->bootstrapListenerClasses = array_filter($listenClasses, [$this, 'requiresBootstrap']); + + // Remove the listener classes that we put into the bootstrap listener classes. + return array_diff($listenClasses, $this->bootstrapListenerClasses); + } + /** * Check whether a command class requires Drupal bootstrap. */ diff --git a/src/Sql/SqlBase.php b/src/Sql/SqlBase.php index b8d66d607b..d1206786bd 100644 --- a/src/Sql/SqlBase.php +++ b/src/Sql/SqlBase.php @@ -595,7 +595,7 @@ public function getOption($name, $default = null) public static function dbSpecFromDbUrl($db_url): array { $db_url_default = is_array($db_url) ? $db_url['default'] : $db_url; - return Database::convertDbUrlToConnectionInfo($db_url_default, DRUSH_DRUPAL_CORE); + return Database::convertDbUrlToConnectionInfo($db_url_default, DRUPAL_ROOT); } /** diff --git a/src/Utils/FsUtils.php b/src/Utils/FsUtils.php index b767fec141..75a8542920 100644 --- a/src/Utils/FsUtils.php +++ b/src/Utils/FsUtils.php @@ -228,7 +228,7 @@ public static function isTarball(string $path) * gz, zip and bzip2 types can be detected. * * - * @return string|bool|null + * @return string|bool * The MIME content type of the file. */ public static function getMimeContentType(string $path) diff --git a/sut/drush/Commands/FixtureCommands.php b/sut/drush/Commands/FixtureCommands.php index 9e47b404b7..23fdeb3895 100644 --- a/sut/drush/Commands/FixtureCommands.php +++ b/sut/drush/Commands/FixtureCommands.php @@ -24,17 +24,6 @@ public function drushUnitEval($code) return eval($code . ';'); } - /** - * Return options as function result. - * @command unit-return-options - * @hidden - */ - public function drushUnitReturnOptions($arg = '', $options = ['x' => 'y', 'data' => [], 'format' => 'yaml']) - { - unset($options['format']); - return $options; - } - /** * Return original argv as function result. * @command unit-return-argv diff --git a/sut/drush/Commands/UnitReturnOptionsCommand.php b/sut/drush/Commands/UnitReturnOptionsCommand.php new file mode 100644 index 0000000000..45a796ff6b --- /dev/null +++ b/sut/drush/Commands/UnitReturnOptionsCommand.php @@ -0,0 +1,61 @@ +addArgument(name: 'code', mode: InputArgument::REQUIRED, description: 'Code you wish to run'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $data = $this->doExecute($input, $output); + $this->writeFormattedOutput($input, $output, $data); + return self::SUCCESS; + } + + public function doExecute(InputInterface $input, OutputInterface $output): mixed + { + $this->bootstrapManager->bootstrapMax(DrupalBootLevels::FULL); + + return eval($input->getArgument('code') . ';'); + } +} diff --git a/sut/modules/unish/woot/src/Commands/WootCommands.php b/sut/modules/unish/woot/src/Commands/WootCommands.php index e71144bf70..e395c3c174 100644 --- a/sut/modules/unish/woot/src/Commands/WootCommands.php +++ b/sut/modules/unish/woot/src/Commands/WootCommands.php @@ -29,28 +29,6 @@ public function appRoot(): string return "The app root is {$this->appRoot}"; } - /** - * This is the my-cat command - * - * This command will concatenate two parameters. If the --flip flag - * is provided, then the result is the concatination of two and one. - * - * @command my-cat - * @param string $one The first parameter. - * @param string $two The other parameter. - * @option boolean $flip Whether or not the second parameter should come first in the result. - * @aliases c - * @usage bet alpha --flip - * Concatinate "alpha" and "bet". - */ - public function myCat($one, $two = '', $options = ['flip' => false]): string - { - if ($options['flip']) { - return "{$two}{$one}"; - } - return "{$one}{$two}"; - } - /** * Demonstrate formatters. Default format is 'table'. * diff --git a/sut/modules/unish/woot/src/Drush/Commands/MyCatCommand.php b/sut/modules/unish/woot/src/Drush/Commands/MyCatCommand.php new file mode 100644 index 0000000000..9d85c94769 --- /dev/null +++ b/sut/modules/unish/woot/src/Drush/Commands/MyCatCommand.php @@ -0,0 +1,43 @@ +writeln("{$two}{$one}"); + } + else { + $output->writeln("{$one}{$two}"); + } + return Command::SUCCESS; + } +} diff --git a/sut/modules/unish/woot/src/Drush/Listeners/WootDefinitionListener.php b/sut/modules/unish/woot/src/Drush/Listeners/WootDefinitionListener.php new file mode 100644 index 0000000000..1b6090eae9 --- /dev/null +++ b/sut/modules/unish/woot/src/Drush/Listeners/WootDefinitionListener.php @@ -0,0 +1,31 @@ +getApplication()->all() as $id => $command) { + if ($command->getName() === 'woot:altered') { + $command->setAliases(['woot-new-alias']); + $this->logger->debug(dt("Module 'woot' changed the alias of 'woot:altered' command into 'woot-new-alias' in " . __METHOD__ . '().')); + } + } + } +} diff --git a/tests/functional/CommandDefinitionAlterTest.php b/tests/functional/CommandDefinitionAlterTest.php new file mode 100644 index 0000000000..a64cad85c1 --- /dev/null +++ b/tests/functional/CommandDefinitionAlterTest.php @@ -0,0 +1,34 @@ +setUpDrupal(1, true); + $this->drush(PmCommands::INSTALL, ['woot']); + $this->drush('woot:altered', [], ['help' => true, 'debug' => true]); + $this->assertStringNotContainsString('woot-initial-alias', $this->getOutput()); + $this->assertStringContainsString('woot-new-alias', $this->getOutput()); + + // Check the debug messages. + $this->assertStringContainsString("[debug] Module 'woot' changed the alias of 'woot:altered' command into 'woot-new-alias' in Drupal\woot\Drush\Listeners\WootDefinitionListener::__invoke().", $this->getErrorOutput()); + + // Run the command with the altered alias. + $this->drush('woot-new-alias'); + } +} diff --git a/tests/integration/ImageTest.php b/tests/integration/ImageTest.php index 0c318ebb87..81a9fa3b38 100644 --- a/tests/integration/ImageTest.php +++ b/tests/integration/ImageTest.php @@ -35,7 +35,6 @@ public function testImage() // Test that "drush image-derive" works. $style_name = 'thumbnail'; $this->drush(ImageCommands::DERIVE, [$style_name, $logo]); - $this->log($this->getOutput()); $this->assertFileExists($thumbnail); // @todo investigate why this is failing.