Verified Commit 3b94128e authored by Lee Rowlands's avatar Lee Rowlands
Browse files

feat: #3594335 Add a module and theme uninstall command

By: mradcliffe
By: nicxvan
By: moshe weitzman
By: kristiantosney
By: vasyl vasylkiv
By: larowlan
parent 4f9558d8
Loading
Loading
Loading
Loading
Loading
+1 −1
Changes for core/lib/Drupal/Core/Extension/Command/ExtensionInstallCommand.php: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -102,7 +102,7 @@ public function __invoke(
      return Command::FAILURE;
    }

    // Installing modules and themes together would require interleaving two
    // Installing modules and themes together would require combining two
    // different dependency-resolution and install processes (a theme may even
    // depend on a module being installed first). Keep each invocation to a
    // single extension type rather than guessing at the correct ordering.
+231 −0
Changes for core/lib/Drupal/Core/Extension/Command/ExtensionUninstallCommand.php: 231 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\Extension\Command;

use Drupal\Core\Command\Exception\UserAbortException;
use Drupal\Core\Extension\Exception\UnknownExtensionException;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ModuleInstallerInterface;
use Drupal\Core\Extension\ThemeExtensionList;
use Drupal\Core\Extension\ThemeInstallerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
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\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

/**
 * Uninstalls modules or themes, resolving and confirming their dependencies.
 *
 * @internal
 */
#[AsCommand(
  name: 'ex:uninstall',
  description: 'Uninstalls one or more modules or themes.',
  aliases: ['exun', 'pm:uninstall', 'pmu'],
  usages: ['views,views_ui', 'claro']
)]
class ExtensionUninstallCommand {

  use StringTranslationTrait;

  public function __construct(
    protected readonly ModuleInstallerInterface $moduleInstaller,
    protected readonly ThemeInstallerInterface $themeInstaller,
    protected readonly ModuleExtensionList $moduleExtensionList,
    protected readonly ThemeExtensionList $themeExtensionList,
    protected readonly MessengerInterface $messenger,
  ) {}

  /**
   * Uninstalls one or more modules or themes.
   */
  public function __invoke(
    InputInterface $input,
    OutputInterface $output,
    #[Argument(description: 'A comma-separated list of module or theme machine names to uninstall.')]
    string $extensions,
    #[Option(description: 'Proceed without prompting for confirmation when additional dependencies will be uninstalled.', shortcut: 'y')]
    bool $yes = FALSE,
    #[Option(description: 'Show the operations that would be performed without uninstalling anything.')]
    bool $dryRun = FALSE,
  ): int {
    $io = new SymfonyStyle($input, $output);

    // Parse the comma-delimited list of extension machine names, dropping any
    // empty entries left by stray or trailing commas.
    $requested = array_values(array_filter(explode(',', $extensions)));
    if (empty($requested)) {
      $io->error((string) $this->t('No modules or themes were specified.'));
      return Command::INVALID;
    }

    // Classify each requested extension as a module or a theme. The extension
    // lists are reset so that any extensions added directly to the filesystem
    // are picked up.
    $module_data = $this->moduleExtensionList->reset()->getList();
    $theme_data = $this->themeExtensionList->reset()->getList();

    $modules = [];
    $themes = [];
    $unknown = [];
    foreach ($requested as $name) {
      if (isset($module_data[$name])) {
        $modules[] = $name;
        continue;
      }
      if (isset($theme_data[$name])) {
        $themes[] = $name;
        continue;
      }
      $unknown[] = $name;
    }

    if (!empty($unknown)) {
      $io->error((string) $this->formatPlural(
        count($unknown),
        'The following could not be found as a module or theme: @names.',
        'The following could not be found as modules or themes: @names.',
        ['@names' => implode(', ', $unknown)],
      ));
      return Command::FAILURE;
    }

    // Keep the uninstall to a single extension type because it would require
    // combining two different dependency-resolution and uninstall processes
    // e.g. a theme may depend on a module being uninstalled first.
    if (!empty($modules) && !empty($themes)) {
      $io->error((string) $this->t('Cannot uninstall modules (@modules) and themes (@themes) in the same command. Run "ex:uninstall" separately for each.', [
        '@modules' => implode(', ', $modules),
        '@themes' => implode(', ', $themes),
      ]));
      return Command::FAILURE;
    }

    $is_theme = !empty($themes);
    $type = $is_theme ? 'theme' : 'module';
    $names = $is_theme ? $themes : $modules;
    $data = $is_theme ? $theme_data : $module_data;

    // Evaluate the full set of extensions that will be uninstalled, including
    // dependencies that are pulled in automatically. This validates the
    // request and throws on missing/incompatible/obsolete extensions.
    try {
      $resolved = $is_theme
        ? $this->themeInstaller->getThemesToUninstall($names)
        : $this->moduleInstaller->getModulesToUninstall($names);
    }
    catch (UnknownExtensionException | \InvalidArgumentException $e) {
      $io->error($e->getMessage());
      return Command::FAILURE;
    }

    if (empty($resolved)) {
      $io->success((string) $this->formatPlural(
        count($names),
        'Nothing to do: the requested @type is already uninstalled.',
        'Nothing to do: the requested @types are already uninstalled.',
        ['@type' => $type],
      ));
      return Command::SUCCESS;
    }

    // Split the resolved set into the extensions that were explicitly
    // requested and the additional dependencies that will be uninstalled too.
    $additional = array_diff($resolved, $names);
    $verb = $dryRun ? $this->t('would be uninstalled') : $this->t('will be uninstalled');

    $requested_labels = [];
    $additional_labels = [];
    foreach ($resolved as $machine_name) {
      $label = sprintf('%s (%s)', $data[$machine_name]->info['name'], $machine_name);
      if (in_array($machine_name, $names, TRUE)) {
        $requested_labels[] = $label;
      }
      else {
        $additional_labels[] = $label;
      }
    }

    $io->writeln((string) $this->formatPlural(
      count($requested_labels),
      'The following @type @verb:',
      'The following @types @verb:',
      ['@type' => $type, '@verb' => $verb],
    ));
    $io->listing($requested_labels);

    if ($additional_labels) {
      $io->writeln((string) $this->formatPlural(
        count($additional_labels),
        'The following additional @type @verb as a dependency:',
        'The following additional @types @verb as dependencies:',
        ['@type' => $type, '@verb' => $verb],
      ));
      $io->listing($additional_labels);
    }

    // Perform the same checks as the modules uninstall form, so a module that
    // an uninstall validator blocks is reported before anything happens.
    if (!$is_theme && $reasons = $this->moduleInstaller->validateUninstall($resolved)) {
      foreach ($reasons as $machine_name => $module_reasons) {
        $io->error((string) $this->t('@module cannot be uninstalled: @reasons', [
          '@module' => sprintf('%s (%s)', $data[$machine_name]->info['name'], $machine_name),
          '@reasons' => implode('; ', $module_reasons),
        ]));
      }
      return Command::FAILURE;
    }

    if ($dryRun) {
      $io->note((string) $this->t('Dry run: no changes were made.'));
      return Command::SUCCESS;
    }

    // Prompt before pulling in dependencies the user did not request. In
    // non-interactive mode there is nobody to answer the prompt, so require
    // --yes to be explicit rather than silently uninstalling the dependencies.
    if (!empty($additional) && !$yes) {
      if (!$input->isInteractive()) {
        throw new UserAbortException(sprintf('Uninstalling the requested %s(s) requires additional dependencies. Re-run with --yes to uninstall them.', $type));
      }
      $question = (string) $this->formatPlural(
        count($additional),
        'Uninstall @count additional @type as a dependency?',
        'Uninstall @count additional @types as dependencies?',
        ['@type' => $type],
      );
      if (!$io->confirm($question, TRUE)) {
        throw new UserAbortException();
      }
    }

    try {
      if ($is_theme) {
        $this->themeInstaller->uninstall($resolved);
      }
      else {
        $this->moduleInstaller->uninstall($resolved);
      }
    }
    catch (\Exception $e) {
      $io->error($e->getMessage());
      return Command::FAILURE;
    }

    $io->success((string) $this->formatPlural(
      count($resolved),
      'Successfully uninstalled @count @type.',
      'Successfully uninstalled @count @types.',
      ['@type' => $type],
    ));
    return Command::SUCCESS;
  }

}
+41 −27
Changes for core/lib/Drupal/Core/Extension/ModuleInstaller.php: 41 added lines, 27 removed lines.
Original line number Diff line number Diff line
@@ -13,6 +13,7 @@
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Extension\Exception\ObsoleteExtensionException;
use Drupal\Core\Extension\Exception\UnknownExtensionException;
use Drupal\Core\Installer\InstallerKernel;
use Drupal\Core\Serialization\Yaml;
use Drupal\Core\Update\Attribute\MarkFutureUpdateEquivalent;
@@ -514,58 +515,71 @@ private function doInstall(array $module_list, array $installed_modules, bool $s
  /**
   * {@inheritdoc}
   */
  public function uninstall(array $module_list, $uninstall_dependents = TRUE) {
    // Get all module data so we can find dependencies and sort.
  public function getModulesToUninstall(array $module_list, bool $uninstall_dependents = TRUE): array {
    // Get all module data so we can find dependents and sort.
    $module_data = \Drupal::service('extension.list.module')->getList();
    $sync_status = \Drupal::service('config.installer')->isSyncing();
    $module_list = $module_list ? array_combine($module_list, $module_list) : [];
    if (array_diff_key($module_list, $module_data)) {
      // One or more of the given modules doesn't exist.
      return FALSE;
    if ($missing = array_diff_key($module_list, $module_data)) {
      throw new UnknownExtensionException(sprintf('Unable to uninstall modules %s due to unknown modules %s.', implode(', ', $module_list), implode(', ', $missing)));
    }

    $extension_config = \Drupal::configFactory()->getEditable('core.extension');
    $installed_modules = $extension_config->get('module') ?: [];
    // Only process currently installed modules.
    $installed_modules = \Drupal::config('core.extension')->get('module') ?: [];
    if (!$module_list = array_intersect_key($module_list, $installed_modules)) {
      // Nothing to do. All modules already uninstalled.
      return TRUE;
      return [];
    }

    if ($uninstall_dependents) {
      $theme_list = \Drupal::service('extension.list.theme')->getList();

      // Add dependent modules to the list. The new modules will be processed as
      // the foreach loop continues.
      foreach ($module_list as $module => $value) {
      // Add installed dependent modules to the list. Themes that depend on a
      // module are left to the uninstall validators.
      foreach ($module_list as $module) {
        foreach (array_keys($module_data[$module]->required_by) as $dependent) {
          if (!isset($module_data[$dependent]) && !isset($theme_list[$dependent])) {
            // The dependent module or theme does not exist.
            return FALSE;
            throw new UnknownExtensionException("Unable to uninstall module '$module' due to unknown dependent '$dependent'.");
          }

          // Skip already uninstalled modules.
          if (isset($installed_modules[$dependent]) && !isset($module_list[$dependent])) {
          if (isset($installed_modules[$dependent])) {
            $module_list[$dependent] = $dependent;
          }
        }
      }
    }

    // Use the validators and throw an exception with the reasons.
    if ($reasons = $this->validateUninstall($module_list)) {
    // Sort the module list by their weights.
    $module_list = array_map(fn ($module) => $module_data[$module]->sort, $module_list);
    asort($module_list);
    return array_keys($module_list);
  }

  /**
   * {@inheritdoc}
   */
  public function uninstall(array $module_list, $uninstall_dependents = TRUE) {
    $requested = $module_list;
    try {
      $module_list = $this->getModulesToUninstall($module_list, $uninstall_dependents);
    }
    catch (UnknownExtensionException) {
      // One or more of the given modules, or a dependent, doesn't exist.
      return FALSE;
    }
    if (empty($module_list)) {
      // Nothing to do. All modules already uninstalled.
      return TRUE;
    }

    // Use the validators and throw an exception with the reasons, reported in
    // the requested order followed by any added dependents.
    $to_validate = array_merge(array_intersect($requested, $module_list), array_diff($module_list, $requested));
    if ($reasons = $this->validateUninstall($to_validate)) {
      foreach ($reasons as $reason) {
        $reason_message[] = implode(', ', $reason);
      }
      throw new ModuleUninstallValidatorException('The following reasons prevent the modules from being uninstalled: ' . implode('; ', $reason_message));
    }
    // Set the actual module weights.
    $module_list = array_map(function ($module) use ($module_data) {
      return $module_data[$module]->sort;
    }, $module_list);

    // Sort the module list by their weights.
    asort($module_list);
    $module_list = array_keys($module_list);
    $sync_status = \Drupal::service('config.installer')->isSyncing();

    // Only process modules that are enabled. A module is only enabled if it is
    // configured as enabled. Custom or overridden module handlers might contain
+20 −0
Changes for core/lib/Drupal/Core/Extension/ModuleInstallerInterface.php: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -96,6 +96,26 @@ public function getModulesToInstall(array $module_list, bool $enable_dependencie
   */
  public function uninstall(array $module_list, $uninstall_dependents = TRUE);

  /**
   * Determines the full set of modules to uninstall for the given list.
   *
   * @param string[] $module_list
   *   An array of module machine names.
   * @param bool $uninstall_dependents
   *   (optional) If TRUE, installed modules that depend on the given modules
   *   are added to the returned list. If FALSE, only the given modules that
   *   are currently installed are returned.
   *
   * @return string[]
   *   The machine names of the installed modules to uninstall, including any
   *   dependents, ordered the way ::uninstall() would uninstall them. Modules
   *   that are not installed are omitted.
   *
   * @throws \Drupal\Core\Extension\Exception\UnknownExtensionException
   *   Thrown when a requested module, or a dependent of one, does not exist.
   */
  public function getModulesToUninstall(array $module_list, bool $uninstall_dependents = TRUE): array;

  /**
   * Determines whether a list of modules can be uninstalled.
   *
+23 −10
Changes for core/lib/Drupal/Core/Extension/ThemeInstaller.php: 23 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -212,12 +212,11 @@ public function install(array $theme_list, $install_dependencies = TRUE) {
  /**
   * {@inheritdoc}
   */
  public function uninstall(array $theme_list) {
    $extension_config = $this->configFactory->getEditable('core.extension');
    $theme_config = $this->configFactory->getEditable('system.theme');
  public function getThemesToUninstall(array $theme_list, bool $uninstall_dependents = TRUE): array {
    $extension_config = $this->configFactory->get('core.extension');
    $theme_config = $this->configFactory->get('system.theme');
    $list = $this->themeHandler->listInfo();
    $installed_themes = $extension_config->get('theme') ?: [];
    $theme_data = $this->themeExtensionList->reset()->getList();
    $theme_list = array_combine(array_values($theme_list), array_values($theme_list));
    foreach ($theme_list as $key) {
      if ($extension_config->get("theme.$key") === NULL) {
        throw new UnknownExtensionException("Unknown theme: $key.");
@@ -228,16 +227,30 @@ public function uninstall(array $theme_list) {
      if ($key === $theme_config->get('admin')) {
        throw new \InvalidArgumentException("The current administration theme $key cannot be uninstalled.");
      }
      // Base themes cannot be uninstalled if sub themes are installed, and if
      // they are not uninstalled at the same time.
      if (isset($list[$key]) && !empty($list[$key]->sub_themes)) {
        foreach ($list[$key]->sub_themes as $sub_key => $sub_label) {
          if (isset($list[$sub_key]) && !in_array($sub_key, $theme_list, TRUE)) {
      // Base themes cannot be uninstalled if sub themes are installed, unless
      // they are uninstalled at the same time.
      foreach (array_keys($list[$key]->sub_themes ?? []) as $sub_key) {
        if (isset($list[$sub_key]) && !isset($theme_list[$sub_key])) {
          if (!$uninstall_dependents) {
            throw new \InvalidArgumentException("The base theme $key cannot be uninstalled, because theme $sub_key depends on it.");
          }
          $theme_list[$sub_key] = $sub_key;
        }
      }
    }
    return array_values($theme_list);
  }

  /**
   * {@inheritdoc}
   */
  public function uninstall(array $theme_list) {
    // Validate the request without adding sub-themes: callers must list them
    // explicitly, see ::getThemesToUninstall() to resolve them beforehand.
    $theme_list = $this->getThemesToUninstall($theme_list, FALSE);
    $extension_config = $this->configFactory->getEditable('core.extension');
    $installed_themes = $extension_config->get('theme') ?: [];
    $theme_data = $this->themeExtensionList->reset()->getList();

    $this->cssCollectionOptimizer->deleteAll();
    foreach ($theme_list as $key) {
Loading