diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..96c12d6
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,27 @@
+# This file is copied from config/drupal-module/.editorconfig in https://github.com/itk-dev/devops_itkdev-docker.
+# Feel free to edit the file, but consider making a pull request if you find a general issue with the file.
+
+# EditorConfig is awesome: https://editorconfig.org
+
+# top-most EditorConfig file
+root = true
+
+[*]
+charset = utf-8
+end_of_line = LF
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{js,css,scss}]
+indent_size = 2
+
+[*.{yml,yaml}]
+indent_size = 2
+
+[config/sync/**/*.{yml,yaml}]
+indent_size = 2
+
+[*.{php,install,module,theme}]
+indent_size = 2
diff --git a/.env b/.env
new file mode 100644
index 0000000..928dbf6
--- /dev/null
+++ b/.env
@@ -0,0 +1,3 @@
+COMPOSE_PROJECT_NAME=drupal_config_helper
+COMPOSE_DOMAIN=drupal_config_helper.local.itkdev.dk
+ITKDEV_TEMPLATE=drupal-module
diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml
new file mode 100644
index 0000000..483da6e
--- /dev/null
+++ b/.github/workflows/changelog.yaml
@@ -0,0 +1,29 @@
+# Do not edit this file! Make a pull request on changing
+# github/workflows/changelog.yaml in
+# https://github.com/itk-dev/devops_itkdev-docker if need be.
+
+### ### Changelog
+###
+### Checks that changelog has been updated
+
+name: Changelog
+
+on:
+ pull_request:
+
+jobs:
+ changelog:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+
+ - name: Git fetch
+ run: git fetch
+
+ - name: Check that changelog has been updated.
+ run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0
diff --git a/.github/workflows/composer.yaml b/.github/workflows/composer.yaml
new file mode 100644
index 0000000..6c3a30c
--- /dev/null
+++ b/.github/workflows/composer.yaml
@@ -0,0 +1,80 @@
+# Do not edit this file! Make a pull request on changing
+# github/workflows/composer.yaml in
+# https://github.com/itk-dev/devops_itkdev-docker if need be.
+
+### ### Composer
+###
+### Validates composer.json and checks that it's normalized.
+###
+### #### Assumptions
+###
+### 1. A docker compose service named `phpfpm` can be run and `composer` can be
+### run inside the `phpfpm` service.
+### 2. [ergebnis/composer-normalize](https://github.com/ergebnis/composer-normalize)
+### is a dev requirement in `composer.json`:
+###
+### ``` shell
+### docker compose run --rm phpfpm composer require --dev ergebnis/composer-normalize
+### ```
+###
+### Normalize `composer.json` by running
+###
+### ``` shell
+### docker compose run --rm phpfpm composer normalize
+### ```
+
+name: Composer
+
+env:
+ COMPOSE_USER: root
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ composer-validate:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm phpfpm composer validate --strict
+
+ composer-normalized:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm phpfpm composer install
+ docker compose run --rm phpfpm composer normalize --dry-run
+
+ composer-audit:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm phpfpm composer audit
diff --git a/.github/workflows/markdown.yaml b/.github/workflows/markdown.yaml
new file mode 100644
index 0000000..f8bcf09
--- /dev/null
+++ b/.github/workflows/markdown.yaml
@@ -0,0 +1,44 @@
+# Do not edit this file! Make a pull request on changing
+# github/workflows/markdown.yaml in
+# https://github.com/itk-dev/devops_itkdev-docker if need be.
+
+### ### Markdown
+###
+### Lints Markdown files (`**/*.md`) in the project.
+###
+### [markdownlint-cli configuration
+### files](https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration),
+### `.markdownlint.jsonc` and `.markdownlintignore`, control what is actually
+### linted and how.
+###
+### #### Assumptions
+###
+### 1. A docker compose service named `markdownlint` for running `markdownlint`
+### (from
+### [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli))
+### exists.
+
+name: Markdown
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ markdown-lint:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm markdownlint markdownlint '**/*.md'
diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml
new file mode 100644
index 0000000..95c22ed
--- /dev/null
+++ b/.github/workflows/php.yaml
@@ -0,0 +1,59 @@
+# Do not edit this file! Make a pull request on changing
+# github/workflows/drupal-module/php.yaml in
+# https://github.com/itk-dev/devops_itkdev-docker if need be.
+
+### ### Drupal module PHP
+###
+### Checks that PHP code adheres to the [Drupal coding
+### standards](https://www.drupal.org/docs/develop/standards).
+###
+### #### Assumptions
+###
+### 1. A docker compose service named `phpfpm` can be run and `composer` can be
+### run inside the `phpfpm` service.
+### 2. [drupal/coder](https://www.drupal.org/project/coder) is a dev requirement
+### in `composer.json`:
+###
+### ``` shell
+### docker compose run --rm phpfpm composer require --dev drupal/coder
+### ```
+###
+### Clean up and check code by running
+###
+### ``` shell
+### docker compose run --rm phpfpm vendor/bin/phpcbf
+### docker compose run --rm phpfpm vendor/bin/phpcs
+### ```
+###
+### > [!NOTE]
+### > The template adds `.phpcs.xml.dist` as [a configuration file for
+### > PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer/wiki/Advanced-Usage#using-a-default-configuration-file)
+### > and this makes it possible to override the actual configuration used in a
+### > project by adding a more important configuration file, e.g. `.phpcs.xml`.
+
+name: PHP
+
+env:
+ COMPOSE_USER: root
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ coding-standards:
+ name: PHP - Check Coding Standards
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm phpfpm composer install
+ docker compose run --rm phpfpm vendor/bin/phpcs
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
new file mode 100644
index 0000000..fa306ac
--- /dev/null
+++ b/.github/workflows/pr.yml
@@ -0,0 +1,31 @@
+on: pull_request
+name: Review
+jobs:
+ php-code-analysis:
+ name: PHP code analysis
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-versions: ["8.3"]
+ steps:
+ - uses: actions/checkout@master
+ - name: Setup PHP, with composer and extensions
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: json
+ coverage: none
+ tools: composer:v2
+ # https://github.com/shivammathur/setup-php#cache-composer-dependencies
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+ - name: Cache dependencies
+ uses: actions/cache@v2
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+ - name: Code analysis
+ run: |
+ ./scripts/code-analysis
diff --git a/.github/workflows/yaml.yaml b/.github/workflows/yaml.yaml
new file mode 100644
index 0000000..8c60963
--- /dev/null
+++ b/.github/workflows/yaml.yaml
@@ -0,0 +1,41 @@
+# Do not edit this file! Make a pull request on changing
+# github/workflows/yaml.yaml in
+# https://github.com/itk-dev/devops_itkdev-docker if need be.
+
+### ### YAML
+###
+### Validates YAML files.
+###
+### #### Assumptions
+###
+### 1. A docker compose service named `prettier` for running
+### [Prettier](https://prettier.io/) exists.
+###
+### #### Symfony YAML
+###
+### Symfony's YAML config files use 4 spaces for indentation and single quotes.
+### Therefore we use a [Prettier configuration
+### file](https://prettier.io/docs/configuration), `.prettierrc.yaml`, to make
+### Prettier format YAML files in the `config/` folder like Symfony expects.
+
+name: YAML
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ yaml-lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm prettier '**/*.{yml,yaml}' --check
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..987e2a2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+composer.lock
+vendor
diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc
new file mode 100644
index 0000000..0253096
--- /dev/null
+++ b/.markdownlint.jsonc
@@ -0,0 +1,22 @@
+// This file is copied from config/markdown/.markdownlint.jsonc in https://github.com/itk-dev/devops_itkdev-docker.
+// Feel free to edit the file, but consider making a pull request if you find a general issue with the file.
+
+// markdownlint-cli configuration file (cf. https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration)
+{
+ "default": true,
+ // https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md
+ "line-length": {
+ "line_length": 120,
+ "code_blocks": false,
+ "tables": false
+ },
+ // https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md
+ "no-duplicate-heading": {
+ "siblings_only": true
+ },
+ // https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections#creating-a-collapsed-section
+ // https://github.com/DavidAnson/markdownlint/blob/main/doc/md033.md
+ "no-inline-html": {
+ "allowed_elements": ["details", "summary"]
+ }
+}
diff --git a/.markdownlintignore b/.markdownlintignore
new file mode 100644
index 0000000..d143ace
--- /dev/null
+++ b/.markdownlintignore
@@ -0,0 +1,12 @@
+# This file is copied from config/markdown/.markdownlintignore in https://github.com/itk-dev/devops_itkdev-docker.
+# Feel free to edit the file, but consider making a pull request if you find a general issue with the file.
+
+# https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#ignoring-files
+vendor/
+node_modules/
+LICENSE.md
+# Drupal
+web/*.md
+web/core/
+web/libraries/
+web/*/contrib/
diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist
new file mode 100644
index 0000000..a97dd83
--- /dev/null
+++ b/.phpcs.xml.dist
@@ -0,0 +1,31 @@
+
+
+
+
+
+ The coding standard.
+
+ .
+
+
+ node_modules
+ vendor
+ *.css
+ *.js
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f866789
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,10 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+[Unreleased]: https://github.com/rimi-itk/drupal_config_helper
diff --git a/README.md b/README.md
index af6f922..ecbcf17 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,48 @@
# Config helper
+
+Adds some useful commands for managing config:
+
+```shell
+config:rename Rename config.
+config:add-module-dependencies Enforce module dependencies in config.
+config:move-module-config Move config info config/install folder in a module.
+```
+
+**Note**: The `config:move-module-config` command is somewhat is similar to the
+[`config:export:content:type`](https://drupalconsole.com/docs/en/commands/config-export-content-type) command the
+[Drupal Console](https://drupalconsole.com/), but that command does not add the dependencies needed for our
+requirements.
+
+## Coding standards
+
+Our coding are checked by GitHub Actions (cf.
+[.github/workflows/pr.yml](.github/workflows/pr.yml)). Use the commands below to
+run the checks locally.
+
+### PHP
+
+```sh
+docker run --rm --volume ${PWD}:/app --workdir /app itkdev/php8.3-fpm composer install
+# Fix (some) coding standards issues
+docker run --rm --volume ${PWD}:/app --workdir /app itkdev/php8.3-fpm composer coding-standards-apply
+# Check that code adheres to the coding standards
+docker run --rm --volume ${PWD}:/app --workdir /app itkdev/php8.3-fpm composer coding-standards-check
+```
+
+### Markdown
+
+```sh
+docker run --rm --volume $PWD:/md peterdavehello/markdownlint markdownlint --ignore vendor --ignore LICENSE.md '**/*.md' --fix
+docker run --rm --volume $PWD:/md peterdavehello/markdownlint markdownlint --ignore vendor --ignore LICENSE.md '**/*.md'
+```
+
+## Code analysis
+
+We use [PHPStan](https://phpstan.org/) for static code analysis.
+
+Running statis code analysis on a standalone Drupal module is a bit tricky, so we use a helper script to run the
+analysis:
+
+```sh
+docker run --rm --volume ${PWD}:/app --workdir /app itkdev/php8.3-fpm ./scripts/code-analysis
+```
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..b30d95d
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,57 @@
+{
+ "name": "itk-dev/drupal_config_helper",
+ "description": "Config helper",
+ "license": "GPL-2.0-or-later",
+ "type": "drupal-module",
+ "keywords": [
+ "Drupal"
+ ],
+ "require": {
+ "php": "^8.3",
+ "drush/drush": "^12.5"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+ "drupal/coder": "^8.3",
+ "drupal/maillog": "^1.1",
+ "ergebnis/composer-normalize": "^2.47",
+ "mglaman/phpstan-drupal": "^1.2",
+ "phpstan/extension-installer": "^1.3",
+ "phpstan/phpstan-deprecation-rules": "^1.1"
+ },
+ "repositories": {
+ "drupal": {
+ "type": "composer",
+ "url": "https://packages.drupal.org/8"
+ }
+ },
+ "minimum-stability": "stable",
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true,
+ "ergebnis/composer-normalize": true,
+ "phpstan/extension-installer": true
+ },
+ "sort-packages": true
+ },
+ "scripts": {
+ "code-analysis": [
+ "@code-analysis/phpstan"
+ ],
+ "code-analysis/phpstan": [
+ "phpstan analyse"
+ ],
+ "coding-standards-apply": [
+ "@coding-standards-apply/phpcs"
+ ],
+ "coding-standards-apply/phpcs": [
+ "phpcbf --standard=phpcs.xml.dist"
+ ],
+ "coding-standards-check": [
+ "@coding-standards-check/phpcs"
+ ],
+ "coding-standards-check/phpcs": [
+ "phpcs --standard=phpcs.xml.dist"
+ ]
+ }
+}
diff --git a/config_helper.info.yml b/config_helper.info.yml
new file mode 100644
index 0000000..ac3b17c
--- /dev/null
+++ b/config_helper.info.yml
@@ -0,0 +1,8 @@
+name: "config_helper"
+type: module
+description: "Config helper"
+# Used only for development and testing.
+hidden: true
+core_version_requirement: ^10
+package: "ITK"
+# https://www.drupal.org/node/2087879
diff --git a/config_helper.services.yml b/config_helper.services.yml
new file mode 100644
index 0000000..08aaf35
--- /dev/null
+++ b/config_helper.services.yml
@@ -0,0 +1,6 @@
+services:
+ Drupal\config_helper\ConfigHelper:
+ arguments:
+ - "@module_handler"
+ - "@config.factory"
+ - "@file_system"
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f5d788a
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,27 @@
+# itk-version: 3.2.4
+
+services:
+ phpfpm:
+ image: itkdev/php8.3-fpm:latest
+ user: ${COMPOSE_USER:-deploy}
+ volumes:
+ - .:/app
+
+ # Code checks tools
+ markdownlint:
+ image: itkdev/markdownlint
+ profiles:
+ - dev
+ volumes:
+ - ./:/md
+
+ prettier:
+ # Prettier does not (yet, fcf.
+ # https://github.com/prettier/prettier/issues/15206) have an official
+ # docker image.
+ # https://hub.docker.com/r/jauderho/prettier is good candidate (cf. https://hub.docker.com/search?q=prettier&sort=updated_at&order=desc)
+ image: jauderho/prettier
+ profiles:
+ - dev
+ volumes:
+ - ./:/work
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..de59b15
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,24 @@
+
+
+ PHP Code Sniffer configuration
+
+ .
+ vendor/
+ node_modules/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..38f71c1
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,13 @@
+parameters:
+ level: 6
+ paths:
+ - src
+
+ ignoreErrors:
+ - "#Method [a-zA-Z0-9\\_\\\\:\\(\\)]+ has parameter \\$[a-zA-Z0-9_]+ with no value type specified in iterable type array#"
+ - "#Method [a-zA-Z0-9\\_\\\\:\\(\\)]+ return type has no value type specified in iterable type array#"
+ - "#Calls to function fnmatch should not exist.#"
+
+# Local Variables:
+# mode: yaml
+# End:
diff --git a/scripts/code-analysis b/scripts/code-analysis
new file mode 100755
index 0000000..abe20a3
--- /dev/null
+++ b/scripts/code-analysis
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+script_dir=$(pwd)
+module_name=$(basename "$script_dir")
+drupal_dir=vendor/drupal-module-code-analysis
+# Relative to $drupal_dir
+module_path=web/modules/contrib/$module_name
+
+cd "$script_dir" || exit
+
+drupal_composer() {
+ composer --working-dir="$drupal_dir" --no-interaction "$@"
+}
+
+# Create new Drupal 10 project
+if [ ! -f "$drupal_dir/composer.json" ]; then
+ composer --no-interaction create-project drupal/recommended-project:^10 "$drupal_dir"
+fi
+# Copy our code into the modules folder
+
+# Clean up
+rm -fr "$drupal_dir/$module_path"
+
+# https://stackoverflow.com/a/15373763
+# rsync --archive --compress . --filter=':- .gitignore' --exclude "$drupal_dir" --exclude .git "$drupal_dir/$module_path"
+
+# The rsync command in not available in itkdev/php8.3-fpm
+
+git config --global --add safe.directory /app
+# Create directories
+git ls-files -z | xargs --null -I {} bash -c 'mkdir -p '"$drupal_dir/$module_path/"'$(dirname {})'
+# Copy files
+git ls-files -z | xargs --null -I {} cp {} "$drupal_dir/$module_path/{}"
+
+drupal_composer config minimum-stability dev
+
+# Allow ALL plugins
+# https://getcomposer.org/doc/06-config.md#allow-plugins
+drupal_composer config --no-plugins allow-plugins true
+
+drupal_composer require wikimedia/composer-merge-plugin
+drupal_composer config extra.merge-plugin.include "$module_path/composer.json"
+# https://www.drupal.org/project/drupal/issues/3220043#comment-14845434
+drupal_composer require --dev symfony/phpunit-bridge
+
+# Run PHPStan
+(cd "$drupal_dir" && vendor/bin/phpstan --configuration="$module_path/phpstan.neon")
diff --git a/src/ConfigHelper.php b/src/ConfigHelper.php
new file mode 100644
index 0000000..8002a24
--- /dev/null
+++ b/src/ConfigHelper.php
@@ -0,0 +1,240 @@
+setLogger(new NullLogger());
+ }
+
+ /**
+ * Add enforced module dependency.
+ */
+ public function addEnforcedDependency(
+ string $module,
+ array $configNames,
+ ): void {
+ foreach ($configNames as $name) {
+ $this->logger->info(sprintf('Config name: %s', $name));
+
+ $config = $this->configFactory->getEditable($name);
+
+ $data = $config->getRawData();
+ $data['dependencies']['enforced']['module'][] = $module;
+ $data['dependencies']['enforced']['module'] = array_unique($data['dependencies']['enforced']['module']);
+ sort($data['dependencies']['enforced']['module']);
+ $config->setData($data);
+
+ $config->save();
+ }
+ }
+
+ /**
+ * Remove enforced module dependency.
+ */
+ public function removeEnforcedDependency(
+ string $module,
+ array $configNames,
+ ): void {
+ foreach ($configNames as $name) {
+ $this->logger->info(sprintf('Config name: %s', $name));
+
+ $config = $this->configFactory->getEditable($name);
+
+ $data = $config->getRawData();
+ if (isset($data['dependencies']['enforced']['module'])) {
+ $data['dependencies']['enforced']['module'] = array_diff($data['dependencies']['enforced']['module'], [$module]);
+ sort($data['dependencies']['enforced']['module']);
+ if (empty($data['dependencies']['enforced']['module'])) {
+ unset($data['dependencies']['enforced']['module']);
+ }
+ if (empty($data['dependencies']['enforced'])) {
+ unset($data['dependencies']['enforced']);
+ }
+ if (empty($data['dependencies'])) {
+ unset($data['dependencies']);
+ }
+ $config->setData($data);
+ $config->save();
+ }
+ }
+ }
+
+ /**
+ * Write module config.
+ */
+ public function writeModuleConfig(
+ string $module,
+ mixed $optional,
+ array $configNames,
+ ): void {
+ $moduleConfigPath = $this->getModuleConfigPath($module, $optional);
+ if (!is_dir($moduleConfigPath)) {
+ $this->fileSystem->mkdir($moduleConfigPath, 0755, TRUE);
+ }
+
+ foreach ($configNames as $name) {
+ $this->logger->info($name);
+
+ $filename = $name . '.yml';
+ $destination = $moduleConfigPath . '/' . $filename;
+ $config = $this->configFactory->get($name)->getRawData();
+ // @see https://www.drupal.org/node/2087879#s-exporting-configuration
+ unset($config['uuid'], $config['_core']);
+
+ file_put_contents($destination, Yaml::encode($config));
+ }
+ }
+
+ /**
+ * Rename config.
+ */
+ public function renameConfig(
+ string $from,
+ string $to,
+ array $configNames,
+ bool $regex = FALSE,
+ ): void {
+ $replacer = $regex
+ ? static fn (string $subject) => preg_replace($from, $to, $subject)
+ : static fn (string $subject) => str_replace($from, $to, $subject);
+
+ foreach ($configNames as $name) {
+ $config = $this->configFactory->getEditable($name);
+ $data = $config->getRawData();
+ $newData = $this->replaceKeysAndValues($replacer, $data);
+ if ($newData !== $data) {
+ $config->setData($newData);
+ $this->logger->info(sprintf('Saving updated config %s', $name));
+ $config->save();
+ }
+
+ $newName = $replacer($name);
+ if ($newName !== $name) {
+ $this->logger->info(sprintf('Renaming config %s to %s', $name, $newName));
+ $this->configFactory->rename($name, $newName);
+ }
+ }
+ }
+
+ /**
+ * Get config names matching patterns.
+ */
+ public function getConfigNames(array $patterns): array {
+ $names = $this->configFactory->listAll();
+ if (empty($patterns)) {
+ return $names;
+ }
+
+ $configNames = [];
+ foreach ($patterns as $pattern) {
+ $chunk = array_filter(
+ $names,
+ // phpcs:disable Drupal.Functions.DiscouragedFunctions.Discouraged -- https://www.php.net/manual/en/function.fnmatch.php#refsect1-function.fnmatch-notes
+ static fn ($name) => fnmatch($pattern, $name),
+ );
+ if (empty($chunk)) {
+ throw new RuntimeException(sprintf('No config matches %s', var_export($pattern, TRUE)));
+ }
+ $configNames[] = $chunk;
+ }
+
+ $configNames = array_unique(array_merge(...$configNames));
+ sort($configNames);
+
+ return $configNames;
+ }
+
+ /**
+ * Get config names for a module.
+ */
+ public function getModuleConfigNames(string $module): array {
+ return $this->getConfigNames(['*.' . $module . '.*']);
+ }
+
+ /**
+ * Get names of config that has an enforced dependency on a module.
+ */
+ public function getEnforcedModuleConfigNames(string $module): array {
+ $configNames = array_values(
+ array_filter(
+ $this->configFactory->listAll(),
+ function ($name) use ($module) {
+ $config = $this->configFactory->get($name)->getRawData();
+ $list = $config['dependencies']['enforced']['module'] ?? NULL;
+
+ return is_array($list) && in_array($module, $list, TRUE);
+ }
+ )
+ );
+
+ if (empty($configNames)) {
+ throw new RuntimeException(sprintf('No config has an enforced dependency on "%s".', $module));
+ }
+
+ return $configNames;
+ }
+
+ /**
+ * Check if a module exists.
+ */
+ public function moduleExists(string $module): bool {
+ return $this->moduleHandler->moduleExists($module);
+ }
+
+ /**
+ * Get module config path.
+ */
+ public function getModuleConfigPath(string $module, bool $optional): string {
+ $modulePath = $this->moduleHandler->getModule($module)->getPath();
+
+ return $modulePath . '/config/' . ($optional ? 'optional' : 'install');
+ }
+
+ /**
+ * Replace in keys and values.
+ *
+ * @see https://stackoverflow.com/a/29619470
+ */
+ private function replaceKeysAndValues(
+ callable $replacer,
+ array $input,
+ ): array {
+ $return = [];
+ foreach ($input as $key => $value) {
+ $key = $replacer($key);
+
+ if (is_array($value)) {
+ $value = $this->replaceKeysAndValues($replacer, $value);
+ }
+ elseif (is_string($value)) {
+ $value = $replacer($value);
+ }
+
+ $return[$key] = $value;
+ }
+
+ return $return;
+ }
+
+}
diff --git a/src/Drush/Commands/ConfigHelperCommands.php b/src/Drush/Commands/ConfigHelperCommands.php
new file mode 100644
index 0000000..0224dea
--- /dev/null
+++ b/src/Drush/Commands/ConfigHelperCommands.php
@@ -0,0 +1,152 @@
+initialize();
+
+ $names = $this->helper->getConfigNames($patterns);
+
+ foreach ($names as $name) {
+ $this->io()->writeln($name);
+ }
+ }
+
+ /**
+ * Enforce module dependency in config.
+ *
+ * @see https://www.drupal.org/node/2087879#s-example:~:text=The%20dependencies%20and%20enforced%20keys%20ensure,removed%20when%20the%20module%20is%20uninstalled
+ */
+ #[CLI\Command(name: 'config_helper:enforce-module-dependency')]
+ #[CLI\Argument(name: 'module', description: 'The module name.')]
+ #[CLI\Argument(name: 'configNames', description: 'The config names.')]
+ #[CLI\Usage(name: 'drush config_helper:enforce-module-dependency my_module "*.my_module.*"', description: 'Enforce dependency on my_module')]
+ #[CLI\Usage(name: 'drush config_helper:enforce-module-dependency my_module', description: 'Shorthand for `drush config_helper:enforce-module-dependency my_module "*.my_module.*"`')]
+ public function enforceModuleDependencies(
+ string $module,
+ array $configNames,
+ ): void {
+ $this->initialize();
+
+ if (!$this->helper->moduleExists($module)) {
+ throw new RuntimeException(sprintf('Invalid module: %s', $module));
+ }
+
+ $configNames = empty($configNames)
+ ? $this->helper->getModuleConfigNames($module)
+ : $this->helper->getConfigNames($configNames);
+
+ $question = sprintf("Enforce dependency on module %s in\n * %s\n ?", $module, implode("\n * ", $configNames));
+ if ($this->io()->confirm($question)) {
+ $this->helper->addEnforcedDependency($module, $configNames);
+ }
+ }
+
+ /**
+ * Write config info config folder in module.
+ */
+ #[CLI\Command(name: 'config_helper:write-module-config')]
+ #[CLI\Argument(name: 'module', description: 'The module name.')]
+ #[CLI\Argument(name: 'configNames', description: 'The config names.')]
+ #[CLI\Option(name: 'optional', description: 'Create as optional config.')]
+ #[CLI\Option(name: 'enforced', description: 'Move only config with enforced dependency on the module.')]
+ #[CLI\Usage(name: 'drush config_helper:write-module-config my_module', description: '')]
+ #[CLI\Usage(name: 'drush config_helper:write-module-config --source=sites/all/config my_module', description: '')]
+ public function writeModuleConfig(
+ string $module,
+ array $configNames,
+ array $options = [
+ 'optional' => FALSE,
+ 'enforced' => FALSE,
+ ],
+ ): void {
+ $this->initialize();
+
+ if (!$this->helper->moduleExists($module)) {
+ throw new RuntimeException(sprintf('Invalid module: %s', $module));
+ }
+
+ if ($options['enforced']) {
+ $configNames = $this->helper->getEnforcedModuleConfigNames($module);
+ }
+ else {
+ $configNames = empty($configNames)
+ ? $this->helper->getModuleConfigNames($module)
+ : $this->helper->getConfigNames($configNames);
+ }
+
+ $configPath = $this->helper->getModuleConfigPath($module, $options['optional']);
+ $question = sprintf("Write config\n * %s\n into %s?", implode("\n * ", $configNames), $configPath);
+ if ($this->io()->confirm($question)) {
+ $this->helper->writeModuleConfig($module, $options['optional'], $configNames);
+ }
+ }
+
+ /**
+ * Rename config.
+ */
+ #[CLI\Command(name: 'config_helper:rename')]
+ #[CLI\Argument(name: 'from', description: 'The value to search for.')]
+ #[CLI\Argument(name: 'to', description: 'The replacement value.')]
+ #[CLI\Argument(name: 'configNames', description: 'The config names.')]
+ #[CLI\Option(name: 'regex', description: 'Use regex search and replace.')]
+ #[CLI\Usage(name: 'drush config_helper:rename porject project', description: 'Fix typo in config.')]
+ #[CLI\Usage(name: "drush config_helper:rename '/field_(.+)/' '\1' --regex", description: 'Remove superfluous prefix from field machine names.')]
+ public function rename(
+ string $from,
+ string $to,
+ array $configNames,
+ array $options = [
+ 'regex' => FALSE,
+ ],
+ ): void {
+ $this->initialize();
+
+ $configNames = $this->helper->getConfigNames($configNames);
+ $question = sprintf("Rename %s to %s %sin\n * %s\n?", $from, $to, $options['regex'] ? '(regex)' : '', implode("\n * ", $configNames));
+ if ($this->io()->confirm($question)) {
+ $this->helper->renameConfig($from, $to, $configNames, (bool) $options['regex']);
+ }
+ }
+
+ /**
+ * Initialize.
+ *
+ * @todo Can we automatically call this before each command?
+ */
+ private function initialize(): void {
+ $this->helper->setLogger($this->logger());
+ }
+
+}