1<?php
2
3namespace Drupal\Core\Command;
4
5use Drupal\Component\Utility\Crypt;
6use Drupal\Core\Database\ConnectionNotDefinedException;
7use Drupal\Core\Database\Database;
8use Drupal\Core\DrupalKernel;
9use Drupal\Core\Extension\ExtensionDiscovery;
10use Drupal\Core\Extension\InfoParserDynamic;
11use Drupal\Core\Site\Settings;
12use Symfony\Component\Console\Command\Command;
13use Symfony\Component\Console\Input\InputArgument;
14use Symfony\Component\Console\Input\InputInterface;
15use Symfony\Component\Console\Input\InputOption;
16use Symfony\Component\Console\Output\OutputInterface;
17use Symfony\Component\Console\Style\SymfonyStyle;
18
19/**
20 * Installs a Drupal site for local testing/development.
21 *
22 * @internal
23 *   This command makes no guarantee of an API for Drupal extensions.
24 */
25class InstallCommand extends Command {
26
27  /**
28   * The class loader.
29   *
30   * @var object
31   */
32  protected $classLoader;
33
34  /**
35   * Constructs a new InstallCommand command.
36   *
37   * @param object $class_loader
38   *   The class loader.
39   */
40  public function __construct($class_loader) {
41    parent::__construct('install');
42    $this->classLoader = $class_loader;
43  }
44
45  /**
46   * {@inheritdoc}
47   */
48  protected function configure() {
49    $this->setName('install')
50      ->setDescription('Installs a Drupal demo site. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.')
51      ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.')
52      ->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in.', 'en')
53      ->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name.', 'Drupal')
54      ->addUsage('demo_umami --langcode fr')
55      ->addUsage('standard --site-name QuickInstall');
56
57    parent::configure();
58  }
59
60  /**
61   * {@inheritdoc}
62   */
63  protected function execute(InputInterface $input, OutputInterface $output) {
64    $io = new SymfonyStyle($input, $output);
65    if (!extension_loaded('pdo_sqlite')) {
66      $io->getErrorStyle()->error('You must have the pdo_sqlite PHP extension installed. See core/INSTALL.sqlite.txt for instructions.');
67      return 1;
68    }
69
70    // Change the directory to the Drupal root.
71    chdir(dirname(__DIR__, 5));
72
73    // Check whether there is already an installation.
74    if ($this->isDrupalInstalled()) {
75      // Do not fail if the site is already installed so this command can be
76      // chained with ServerCommand.
77      $output->writeln('<info>Drupal is already installed.</info> If you want to reinstall, remove sites/default/files and sites/default/settings.php.');
78      return 0;
79    }
80
81    $install_profile = $input->getArgument('install-profile');
82    if ($install_profile && !$this->validateProfile($install_profile, $io)) {
83      return 1;
84    }
85    if (!$install_profile) {
86      $install_profile = $this->selectProfile($io);
87    }
88
89    return $this->install($this->classLoader, $io, $install_profile, $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name'));
90  }
91
92  /**
93   * Returns whether there is already an existing Drupal installation.
94   *
95   * @return bool
96   */
97  protected function isDrupalInstalled() {
98    try {
99      $kernel = new DrupalKernel('prod', $this->classLoader, FALSE);
100      $kernel::bootEnvironment();
101      $kernel->setSitePath($this->getSitePath());
102      Settings::initialize($kernel->getAppRoot(), $kernel->getSitePath(), $this->classLoader);
103      $kernel->boot();
104    }
105    catch (ConnectionNotDefinedException $e) {
106      return FALSE;
107    }
108    return !empty(Database::getConnectionInfo());
109  }
110
111  /**
112   * Installs Drupal with specified installation profile.
113   *
114   * @param object $class_loader
115   *   The class loader.
116   * @param \Symfony\Component\Console\Style\SymfonyStyle $io
117   *   The Symfony output decorator.
118   * @param string $profile
119   *   The installation profile to use.
120   * @param string $langcode
121   *   The language to install the site in.
122   * @param string $site_path
123   *   The path to install the site to, like 'sites/default'.
124   * @param string $site_name
125   *   The site name.
126   *
127   * @throws \Exception
128   *   Thrown when failing to create the $site_path directory or settings.php.
129   *
130   * @return int
131   *   The command exit status.
132   */
133  protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name) {
134    $password = Crypt::randomBytesBase64(12);
135    $parameters = [
136      'interactive' => FALSE,
137      'site_path' => $site_path,
138      'parameters' => [
139        'profile' => $profile,
140        'langcode' => $langcode,
141      ],
142      'forms' => [
143        'install_settings_form' => [
144          'driver' => 'sqlite',
145          'sqlite' => [
146            'database' => $site_path . '/files/.sqlite',
147          ],
148        ],
149        'install_configure_form' => [
150          'site_name' => $site_name,
151          'site_mail' => 'drupal@localhost',
152          'account' => [
153            'name' => 'admin',
154            'mail' => 'admin@localhost',
155            'pass' => [
156              'pass1' => $password,
157              'pass2' => $password,
158            ],
159          ],
160          'enable_update_status_module' => TRUE,
161          // form_type_checkboxes_value() requires NULL instead of FALSE values
162          // for programmatic form submissions to disable a checkbox.
163          'enable_update_status_emails' => NULL,
164        ],
165      ],
166    ];
167
168    // Create the directory and settings.php if not there so that the installer
169    // works.
170    if (!is_dir($site_path)) {
171      if ($io->isVerbose()) {
172        $io->writeln("Creating directory: $site_path");
173      }
174      if (!mkdir($site_path, 0775)) {
175        throw new \RuntimeException("Failed to create directory $site_path");
176      }
177    }
178    if (!file_exists("{$site_path}/settings.php")) {
179      if ($io->isVerbose()) {
180        $io->writeln("Creating file: {$site_path}/settings.php");
181      }
182      if (!copy('sites/default/default.settings.php', "{$site_path}/settings.php")) {
183        throw new \RuntimeException("Copying sites/default/default.settings.php to {$site_path}/settings.php failed.");
184      }
185    }
186
187    require_once 'core/includes/install.core.inc';
188
189    $progress_bar = $io->createProgressBar();
190    install_drupal($class_loader, $parameters, function ($install_state) use ($progress_bar) {
191      static $started = FALSE;
192      if (!$started) {
193        $started = TRUE;
194        // We've already done 1.
195        $progress_bar->setFormat("%current%/%max% [%bar%]\n%message%\n");
196        $progress_bar->setMessage(t('Installing @drupal', ['@drupal' => drupal_install_profile_distribution_name()]));
197        $tasks = install_tasks($install_state);
198        $progress_bar->start(count($tasks) + 1);
199      }
200      $tasks_to_perform = install_tasks_to_perform($install_state);
201      $task = current($tasks_to_perform);
202      if (isset($task['display_name'])) {
203        $progress_bar->setMessage($task['display_name']);
204      }
205      $progress_bar->advance();
206    });
207    $success_message = t('Congratulations, you installed @drupal!', [
208      '@drupal' => drupal_install_profile_distribution_name(),
209      '@name' => 'admin',
210      '@pass' => $password,
211    ], ['langcode' => $langcode]);
212    $progress_bar->setMessage('<info>' . $success_message . '</info>');
213    $progress_bar->display();
214    $progress_bar->finish();
215    $io->writeln('<info>Username:</info> admin');
216    $io->writeln("<info>Password:</info> $password");
217
218    return 0;
219  }
220
221  /**
222   * Gets the site path.
223   *
224   * Defaults to 'sites/default'. For testing purposes this can be overridden
225   * using the DRUPAL_DEV_SITE_PATH environment variable.
226   *
227   * @return string
228   *   The site path to use.
229   */
230  protected function getSitePath() {
231    return getenv('DRUPAL_DEV_SITE_PATH') ?: 'sites/default';
232  }
233
234  /**
235   * Selects the install profile to use.
236   *
237   * @param \Symfony\Component\Console\Style\SymfonyStyle $io
238   *   Symfony style output decorator.
239   *
240   * @return string
241   *   The selected install profile.
242   *
243   * @see _install_select_profile()
244   * @see \Drupal\Core\Installer\Form\SelectProfileForm
245   */
246  protected function selectProfile(SymfonyStyle $io) {
247    $profiles = $this->getProfiles();
248
249    // If there is a distribution there will be only one profile.
250    if (count($profiles) == 1) {
251      return key($profiles);
252    }
253    // Display alphabetically by human-readable name, but always put the core
254    // profiles first (if they are present in the filesystem).
255    natcasesort($profiles);
256    if (isset($profiles['minimal'])) {
257      // If the expert ("Minimal") core profile is present, put it in front of
258      // any non-core profiles rather than including it with them
259      // alphabetically, since the other profiles might be intended to group
260      // together in a particular way.
261      $profiles = ['minimal' => $profiles['minimal']] + $profiles;
262    }
263    if (isset($profiles['standard'])) {
264      // If the default ("Standard") core profile is present, put it at the very
265      // top of the list. This profile will have its radio button pre-selected,
266      // so we want it to always appear at the top.
267      $profiles = ['standard' => $profiles['standard']] + $profiles;
268    }
269    reset($profiles);
270    return $io->choice('Select an installation profile', $profiles, current($profiles));
271  }
272
273  /**
274   * Validates a user provided install profile.
275   *
276   * @param string $install_profile
277   *   Install profile to validate.
278   * @param \Symfony\Component\Console\Style\SymfonyStyle $io
279   *   Symfony style output decorator.
280   *
281   * @return bool
282   *   TRUE if the profile is valid, FALSE if not.
283   */
284  protected function validateProfile($install_profile, SymfonyStyle $io) {
285    // Allow people to install hidden and non-distribution profiles if they
286    // supply the argument.
287    $profiles = $this->getProfiles(TRUE, FALSE);
288    if (!isset($profiles[$install_profile])) {
289      $error_msg = sprintf("'%s' is not a valid install profile.", $install_profile);
290      $alternatives = [];
291      foreach (array_keys($profiles) as $profile_name) {
292        $lev = levenshtein($install_profile, $profile_name);
293        if ($lev <= strlen($profile_name) / 4 || FALSE !== strpos($profile_name, $install_profile)) {
294          $alternatives[] = $profile_name;
295        }
296      }
297      if (!empty($alternatives)) {
298        $error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives));
299      }
300      $io->getErrorStyle()->error($error_msg);
301      return FALSE;
302    }
303    return TRUE;
304  }
305
306  /**
307   * Gets a list of profiles.
308   *
309   * @param bool $include_hidden
310   *   (optional) Whether to include hidden profiles. Defaults to FALSE.
311   * @param bool $auto_select_distributions
312   *   (optional) Whether to only return the first distribution found.
313   *
314   * @return string[]
315   *   An array of profile descriptions keyed by the profile machine name.
316   */
317  protected function getProfiles($include_hidden = FALSE, $auto_select_distributions = TRUE) {
318    // Build a list of all available profiles.
319    $listing = new ExtensionDiscovery(getcwd(), FALSE);
320    $listing->setProfileDirectories([]);
321    $profiles = [];
322    $info_parser = new InfoParserDynamic(getcwd());
323    foreach ($listing->scan('profile') as $profile) {
324      $details = $info_parser->parse($profile->getPathname());
325      // Don't show hidden profiles.
326      if (!$include_hidden && !empty($details['hidden'])) {
327        continue;
328      }
329      // Determine the name of the profile; default to the internal name if none
330      // is specified.
331      $name = isset($details['name']) ? $details['name'] : $profile->getName();
332      $description = isset($details['description']) ? $details['description'] : $name;
333      $profiles[$profile->getName()] = $description;
334
335      if ($auto_select_distributions && !empty($details['distribution'])) {
336        return [$profile->getName() => $description];
337      }
338    }
339    return $profiles;
340  }
341
342}
343