2 namespace Drush\Commands\core;
4 use Composer\Semver\Comparator;
5 use Consolidation\AnnotatedCommand\CommandData;
6 use Drupal\Component\FileCache\FileCacheFactory;
7 use Drupal\Core\Database\ConnectionNotDefinedException;
8 use Drush\Commands\DrushCommands;
10 use Drush\Exceptions\UserAbortException;
11 use Drupal\Core\Config\FileStorage;
12 use Consolidation\SiteAlias\SiteAliasManagerAwareInterface;
13 use Consolidation\SiteAlias\SiteAliasManagerAwareTrait;
14 use Drush\Sql\SqlBase;
15 use Drush\Utils\StringUtils;
16 use Webmozart\PathUtil\Path;
18 class SiteInstallCommands extends DrushCommands implements SiteAliasManagerAwareInterface
20 use SiteAliasManagerAwareTrait;
23 * Install Drupal along with modules/themes/configuration/profile.
25 * @command site:install
26 * @param $profile An install profile name. Defaults to 'standard' unless an install profile is marked as a distribution. Additional info for the install profile may also be provided with additional arguments. The key is in the form [form name].[parameter name]
27 * @option db-url A Drupal 6 style database URL. Required for initial install, not re-install. If omitted and required, Drush prompts for this item.
28 * @option db-prefix An optional table prefix to use for initial install.
29 * @option db-su Account to use when creating a new database. Must have Grant permission (mysql only). Optional.
30 * @option db-su-pw Password for the "db-su" account. Optional.
31 * @option account-name uid1 name. Defaults to admin
32 * @option account-pass uid1 pass. Defaults to a randomly generated password. If desired, set a fixed password in config.yml.
33 * @option account-mail uid1 email. Defaults to admin@example.com
34 * @option locale A short language code. Sets the default site language. Language files must already be present.
35 * @option site-name Defaults to Site-Install
36 * @option site-mail From: for system mailings. Defaults to admin@example.com
37 * @option sites-subdir Name of directory under 'sites' which should be created.
38 * @option config-dir Deprecated - only use with Drupal 8.5-. A path pointing to a full set of configuration which should be installed during installation.
39 * @option existing-config Configuration from "sync" directory should be imported during installation. Use with Drupal 8.6+.
40 * @usage drush si expert --locale=uk
41 * (Re)install using the expert install profile. Set default language to Ukrainian.
42 * @usage drush si --db-url=mysql://root:pass@localhost:port/dbname
43 * Install using the specified DB params.
44 * @usage drush si --db-url=sqlite://sites/example.com/files/.ht.sqlite
45 * Install using SQLite
46 * @usage drush si --account-pass=mom
47 * Re-install with specified uid1 password.
48 * @usage drush si --existing-config
49 * Install based on the yml files stored in the config export/import directory.
50 * @usage drush si standard install_configure_form.enable_update_status_emails=NULL
51 * Disable email notification during install and later. If your server has no mail transfer agent, this gets rid of an error during install.
54 * @aliases si,sin,site-install
57 public function install(array $profile, $options = ['db-url' => self::REQ, 'db-prefix' => self::REQ, 'db-su' => self::REQ, 'db-su-pw' => self::REQ, 'account-name' => 'admin', 'account-mail' => 'admin@example.com', 'site-mail' => 'admin@example.com', 'account-pass' => self::REQ, 'locale' => 'en', 'site-name' => 'Drush Site-Install', 'site-pass' => self::REQ, 'sites-subdir' => self::REQ, 'config-dir' => self::REQ, 'existing-config' => false])
59 $additional = $profile;
60 $profile = array_shift($additional) ?: '';
62 foreach ((array)$additional as $arg) {
63 list($key, $value) = explode('=', $arg, 2);
65 // Allow for numeric and NULL values to be passed in.
66 if (is_numeric($value)) {
67 $value = intval($value);
68 } elseif ($value == 'NULL') {
72 $form_options[$key] = $value;
75 $this->serverGlobals(Drush::bootstrapManager()->getUri());
76 $class_loader = Drush::service('loader');
77 $profile = $this->determineProfile($profile, $options, $class_loader);
79 $sql = SqlBase::create($options);
80 $db_spec = $sql->getDbSpec();
82 $account_pass = $options['account-pass'] ?: StringUtils::generatePassword();
84 // Was giving error during validate() so its here for now.
85 if ($options['existing-config']) {
86 $existing_config_dir = config_get_config_directory(CONFIG_SYNC_DIRECTORY);
87 if (!is_dir($existing_config_dir)) {
88 throw new \Exception(dt('Existing config directory @dir not found', ['@dir' => $existing_config_dir]));
90 $this->logger()->info(dt('Installing from existing config at @dir', ['@dir' => $existing_config_dir]));
95 'profile' => $profile,
96 'langcode' => $options['locale'],
97 'existing_config' => $options['existing-config'],
100 'install_settings_form' => [
101 'driver' => $db_spec['driver'],
102 $db_spec['driver'] => $db_spec,
103 'op' => dt('Save and continue'),
105 'install_configure_form' => [
106 'site_name' => $options['site-name'],
107 'site_mail' => $options['site-mail'],
109 'name' => $options['account-name'],
110 'mail' => $options['account-mail'],
112 'pass1' => $account_pass,
113 'pass2' => $account_pass,
116 'enable_update_status_module' => true,
117 'enable_update_status_emails' => true,
119 'op' => dt('Save and continue'),
122 'config_install_path' => $options['config-dir'],
125 // Merge in the additional options.
126 foreach ($form_options as $key => $value) {
127 $current = &$settings['forms'];
128 foreach (explode('.', $key) as $param) {
129 $current = &$current[$param];
134 $msg = 'Starting Drupal installation. This takes a while.';
135 $this->logger()->notice(dt($msg));
137 // Define some functions which alter away the install_finished task.
138 require_once Path::join(DRUSH_BASE_PATH, 'includes/site_install.inc');
140 require_once DRUSH_DRUPAL_CORE . '/includes/install.core.inc';
141 drush_op('install_drupal', $class_loader, $settings);
142 if (empty($options['account-pass'])) {
143 $this->logger()->success(dt('Installation complete. User name: @name User password: @pass', ['@name' => $options['account-name'], '@pass' => $account_pass]));
145 $this->logger()->success(dt('Installation complete.'));
149 protected function determineProfile($profile, $options, $class_loader)
151 // --config-dir fails with Standard profile and any other one that carries content entities.
152 // Force to minimal install profile only for drupal < 8.6.
153 if ($options['config-dir'] && Comparator::lessThan(self::getVersion(), '8.6')) {
154 $this->logger()->info(dt("Using 'minimal' install profile since --config-dir option was provided."));
155 $profile = 'minimal';
158 // Try to get profile from existing config if not provided as an argument.
159 // @todo Arguably Drupal core [$boot->getKernel()->getInstallProfile()] could do this - https://github.com/drupal/drupal/blob/8.6.x/core/lib/Drupal/Core/DrupalKernel.php#L1606 reads from DB storage but not file storage.
160 if (empty($profile) && $options['existing-config']) {
161 FileCacheFactory::setConfiguration([FileCacheFactory::DISABLE_CACHE => true]);
162 $source_storage = new FileStorage(config_get_config_directory(CONFIG_SYNC_DIRECTORY));
163 if (!$source_storage->exists('core.extension')) {
164 throw new \Exception('Existing configuration directory not found or does not contain a core.extension.yml file.".');
166 $config = $source_storage->read('core.extension');
167 $profile = $config['profile'];
170 if (empty($profile)) {
171 $boot = Drush::bootstrap();
172 $profile = $boot->getKernel()->getInstallProfile();
175 if (empty($profile)) {
176 // If there is an installation profile that acts as a distribution, use it.
177 // You can turn your installation profile into a distribution by providing a
180 // name: 'Distribution name'
182 // block in the profile's info YAML file.
183 // See https://www.drupal.org/node/2210443 for more information.
184 require_once DRUSH_DRUPAL_CORE . '/includes/install.core.inc';
185 $install_state = ['interactive' => false] + install_state_defaults();
187 install_begin_request($class_loader, $install_state);
188 $profile = _install_select_profile($install_state);
189 } catch (\Exception $e) {
190 // This is only a best effort to provide a better default, no harm done
195 // Drupal currently requires that non-interactive installs provide a profile.
196 if (empty($profile)) {
197 $profile = 'standard';
203 * Post installation, run the configuration import.
205 * @hook post-command site-install
207 public function post($result, CommandData $commandData)
209 if ($config = $commandData->input()->getOption('config-dir') && Comparator::lessThan(self::getVersion(), '8.6')) {
210 // Set the destination site UUID to match the source UUID, to bypass a core fail-safe.
211 $source_storage = new FileStorage($config);
212 $options = ['yes' => true];
213 drush_invoke_process('@self', 'config-set', ['system.site', 'uuid', $source_storage->read('system.site')['uuid']], $options);
214 // Run a full configuration import.
215 drush_invoke_process('@self', 'config-import', [], ['source' => $config] + $options);
220 * Check to see if there are any .yml files in the provided config directory.
222 protected function hasConfigFiles($config)
224 $files = glob("$config/*.yml");
225 return !empty($files);
229 * @hook validate site-install
231 public function validate(CommandData $commandData)
233 if ($sites_subdir = $commandData->input()->getOption('sites-subdir')) {
234 $lower = strtolower($sites_subdir);
235 if ($sites_subdir != $lower) {
236 $this->logger()->warning(dt('Only lowercase sites-subdir are valid. Switching to !lower.', ['!lower' => $lower]));
237 $commandData->input()->setOption('sites-subdir', $lower);
239 // Make sure that we will bootstrap to the 'sites-subdir' site.
240 $bootstrapManager = Drush::bootstrapManager();
241 $bootstrapManager->setUri('http://' . $sites_subdir);
244 if ($config = $commandData->input()->getOption('config-dir')) {
245 $this->validateConfigDir($commandData, $config);
249 // Get AnnotationData. @todo Find a better way.
250 $annotationData = Drush::getApplication()->find('site:install')->getAnnotationData();
251 Drush::bootstrapManager()->bootstrapMax(DRUSH_BOOTSTRAP_DRUPAL_CONFIGURATION, $annotationData);
252 $sql = SqlBase::create($commandData->input()->getOptions());
253 } catch (\Exception $e) {
254 // Ask questions to get our data.
255 // TODO: we should only 'ask' in hook interact, never in hook validate
256 if ($commandData->input()->getOption('db-url') == '') {
257 // Prompt for the db-url data if it was not provided via --db-url.
258 $database = $this->io()->ask('Database name', 'drupal');
259 $driver = $this->io()->ask('Database driver', 'mysql');
260 $username = $this->io()->ask('Database username', 'drupal');
261 $password = $this->io()->ask('Database password', 'drupal');
262 $host = $this->io()->ask('Database host', '127.0.0.1');
263 $port = $this->io()->ask('Database port', '3306');
264 $db_url = "$driver://$username:$password@$host:$port/$database";
265 $commandData->input()->setOption('db-url', $db_url);
268 $sql = SqlBase::create($commandData->input()->getOptions());
269 } catch (\Exception $e) {
270 throw new \Exception(dt('Could not determine database connection parameters. Pass --db-url option.'));
277 * Perform setup tasks before installation.
279 * @hook pre-command site-install
282 public function pre(CommandData $commandData)
284 $sql = SqlBase::create($commandData->input()->getOptions());
285 $db_spec = $sql->getDbSpec();
287 $aliasRecord = $this->siteAliasManager()->getSelf();
288 $root = $aliasRecord->root();
290 $dir = $commandData->input()->getOption('sites-subdir');
292 // We will allow the 'uri' from the site alias to provide
293 // a fallback name when '--sites-subdir' is not specified, but
294 // only if the uri and the folder name match, and only if
295 // the sites directory has already been created.
296 $dir = $this->getSitesSubdirFromUri($root, $aliasRecord->get('uri'));
300 throw new \Exception(dt('Could not determine target sites directory for site to install. Use --sites-subdir to specify.'));
303 $sites_subdir = Path::join('sites', $dir);
304 $confPath = $sites_subdir;
305 $settingsfile = Path::join($confPath, 'settings.php');
306 $sitesfile = "sites/sites.php";
307 $default = realpath(Path::join($root, 'sites/default'));
308 $sitesfile_write = realpath($confPath) != $default && !file_exists($sitesfile);
310 if (!file_exists($settingsfile)) {
311 $msg[] = dt('create a @settingsfile file', ['@settingsfile' => $settingsfile]);
313 if ($sitesfile_write) {
314 $msg[] = dt('create a @sitesfile file', ['@sitesfile' => $sitesfile]);
316 if ($sql->dbExists()) {
317 $msg[] = dt("DROP all tables in your '@db' database.", ['@db' => $db_spec['database']]);
319 $msg[] = dt("CREATE the '@db' database.", ['@db' => $db_spec['database']]);
322 if (!$this->io()->confirm(dt('You are about to ') . implode(dt(' and '), $msg) . ' Do you want to continue?')) {
323 throw new UserAbortException();
326 // Can't install without sites subdirectory and settings.php.
327 if (!file_exists($confPath)) {
328 if (!drush_mkdir($confPath) && !Drush::simulate()) {
329 throw new \Exception(dt('Failed to create directory @confPath', ['@confPath' => $confPath]));
332 $this->logger()->info(dt('Sites directory @subdir already exists - proceeding.', ['@subdir' => $confPath]));
335 if (!drush_file_not_empty($settingsfile)) {
336 if (!drush_op('copy', 'sites/default/default.settings.php', $settingsfile) && !Drush::simulate()) {
337 throw new \Exception(dt('Failed to copy sites/default/default.settings.php to @settingsfile', ['@settingsfile' => $settingsfile]));
341 // Write an empty sites.php if we using multi-site.
342 if ($sitesfile_write) {
343 if (!drush_op('copy', 'sites/example.sites.php', $sitesfile) && !Drush::simulate()) {
344 throw new \Exception(dt('Failed to copy sites/example.sites.php to @sitesfile', ['@sitesfile' => $sitesfile]));
348 // We need to be at least at DRUSH_BOOTSTRAP_DRUPAL_SITE to select the site uri to install to
349 define('MAINTENANCE_MODE', 'install');
350 $bootstrapManager = Drush::bootstrapManager();
351 $bootstrapManager->doBootstrap(DRUSH_BOOTSTRAP_DRUPAL_SITE);
353 if (!$sql->dropOrCreate()) {
354 throw new \Exception(dt('Failed to create database: @error', ['@error' => implode(drush_shell_exec_output())]));
359 * Determine an appropriate site subdir name to use for the
362 protected function getSitesSubdirFromUri($root, $uri)
364 $dir = strtolower($uri);
365 // Always accept simple uris (e.g. 'dev', 'stage', etc.)
366 if (preg_match('#^[a-z0-9_-]*$#', $dir)) {
369 // Strip off the protocol from the provided uri -- however,
370 // now we will require that the sites subdir already exist.
371 $dir = preg_replace('#[^/]*/*#', '', $dir);
372 if ($dir && file_exists(Path::join($root, $dir))) {
375 // Find the dir from sites.php file
376 $sites_file = $root . '/sites/sites.php';
377 if (file_exists($sites_file)) {
379 /** @var array $sites */
380 if (array_key_exists($uri, $sites)) {
384 // Fall back to default directory if it exists.
385 if (file_exists(Path::join($root, 'sites', 'default'))) {
391 public static function getVersion()
393 $drupal_root = Drush::bootstrapManager()->getRoot();
394 return Drush::bootstrap()->getVersion($drupal_root);
398 * Fake the necessary HTTP headers that the Drupal installer still needs:
399 * @see https://github.com/drupal/drupal/blob/d260101f1ea8a6970df88d2f1899248985c499fc/core/includes/install.core.inc#L287
401 public function serverGlobals($drupal_base_url)
403 $drupal_base_url = parse_url($drupal_base_url);
406 $drupal_base_url += [
412 $_SERVER['HTTP_HOST'] = $drupal_base_url['host'];
414 if ($drupal_base_url['scheme'] == 'https') {
415 $_SERVER['HTTPS'] = 'on';
418 if ($drupal_base_url['port']) {
419 $_SERVER['HTTP_HOST'] .= ':' . $drupal_base_url['port'];
421 $_SERVER['SERVER_PORT'] = $drupal_base_url['port'];
423 $_SERVER['REQUEST_URI'] = $drupal_base_url['path'] . '/';
425 $_SERVER['PHP_SELF'] = $_SERVER['REQUEST_URI'] . 'index.php';
426 $_SERVER['SCRIPT_NAME'] = $_SERVER['PHP_SELF'];
427 $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
428 $_SERVER['REQUEST_METHOD'] = 'GET';
430 $_SERVER['SERVER_SOFTWARE'] = null;
431 $_SERVER['HTTP_USER_AGENT'] = null;
432 $_SERVER['SCRIPT_FILENAME'] = DRUPAL_ROOT . '/index.php';
436 * Assure that a config directory exists and is populated.
438 * @param CommandData $commandData
442 protected function validateConfigDir(CommandData $commandData, $directory)
444 if (!file_exists($directory)) {
445 throw new \Exception(dt('The config source directory @config does not exist.', ['@config' => $directory]));
447 if (!is_dir($directory)) {
448 throw new \Exception(dt('The config source @config is not a directory.', ['@config' => $directory]));
450 // Skip config import with a warning if specified config dir is empty.
451 if (!$this->hasConfigFiles($directory)) {
452 $this->logger()->warning(dt('Configuration import directory @config does not contain any configuration; will skip import.', ['@config' => $directory]));
453 $commandData->input()->setOption('config-dir', '');