diff --git a/core/INSTALL.txt b/core/INSTALL.txt index 5d5525b..c9803a4 100644 --- a/core/INSTALL.txt +++ b/core/INSTALL.txt @@ -2,6 +2,7 @@ CONTENTS OF THIS FILE --------------------- + * Quickstart * Requirements and notes * Optional server requirements * Installation @@ -10,6 +11,33 @@ CONTENTS OF THIS FILE * Multisite configuration * Multilingual configuration +QUICKSTART +---------------------- + +Prerequisites: +- PHP 5.5.9 (or greater) (https://php.net). + +In the instructions below, replace the version 8.x.x with the specific version +you wish to download. Example: 8.6.0.zip. You can find the latest stable version +at https://www.drupal.org/project/drupal. + +Download and extract the Drupal package +- curl -sS https://ftp.drupal.org/files/projects/drupal-8.x.x.zip --output drupal-8.x.x.zip +- unzip drupal-8.x.x.zip +- cd /path/to/drupal-8.x.x +// Installs Drupal and open it up +- php core/scripts/drupal quick-start + +Wait… installation can take a minute or two. A successful installation will +result in logging in to your new site in your web browser. + +NOTE: This quick start solution uses PHP's built-in web server and is not +intended for production use. Read more about how to run Drupal in a production +environment below. + +You may need to configure server settings such as the host address or port. Run +php core/scripts/drupal quick-start --help for a list of available options. + REQUIREMENTS AND NOTES ---------------------- diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc index c27f75e..fb0400a 100644 --- a/core/includes/install.core.inc +++ b/core/includes/install.core.inc @@ -89,10 +89,13 @@ * page request (optimized for the command line) and not send any output * intended for the web browser. See install_state_defaults() for a list of * elements that are allowed to appear in this array. + * @param callable $callback + * (optional) A callback to allow command line processes to update a progress + * bar. The callback is passed the $install_state variable. * * @see install_state_defaults() */ -function install_drupal($class_loader, $settings = []) { +function install_drupal($class_loader, $settings = [], callable $callback = NULL) { // Support the old way of calling this function with just a settings array. // @todo Remove this when Drush is updated in the Drupal testing // infrastructure in https://www.drupal.org/node/2389243 @@ -114,7 +117,7 @@ function install_drupal($class_loader, $settings = []) { install_begin_request($class_loader, $install_state); // Based on the installation state, run the remaining tasks for this page // request, and collect any output. - $output = install_run_tasks($install_state); + $output = install_run_tasks($install_state, $callback); } catch (InstallerException $e) { // In the non-interactive installer, exceptions are always thrown directly. @@ -285,6 +288,8 @@ function install_state_defaults() { * @param $install_state * An array of information about the current installation state. This is * modified with information gleaned from the beginning of the page request. + * + * @see install_drupal() */ function install_begin_request($class_loader, &$install_state) { $request = Request::createFromGlobals(); @@ -324,7 +329,7 @@ function install_begin_request($class_loader, &$install_state) { date_default_timezone_set('Australia/Sydney'); } - $site_path = DrupalKernel::findSitePath($request, FALSE); + $site_path = empty($install_state['site_path']) ? DrupalKernel::findSitePath($request, FALSE) : $install_state['site_path']; Settings::initialize(dirname(dirname(__DIR__)), $site_path, $class_loader); // Ensure that procedural dependencies are loaded as early as possible, @@ -535,11 +540,14 @@ function install_begin_request($class_loader, &$install_state) { * @param $install_state * An array of information about the current installation state. This is * passed along to each task, so it can be modified if necessary. + * @param callable $callback + * (optional) A callback to allow command line processes to update a progress + * bar. The callback is passed the $install_state variable. * * @return * HTML output from the last completed task. */ -function install_run_tasks(&$install_state) { +function install_run_tasks(&$install_state, callable $callback = NULL) { do { // Obtain a list of tasks to perform. The list of tasks itself can be // dynamic (e.g., some might be defined by the installation profile, @@ -571,6 +579,9 @@ function install_run_tasks(&$install_state) { \Drupal::state()->set('install_task', $install_state['installation_finished'] ? 'done' : $task_name); } } + if ($callback) { + $callback($install_state); + } // Stop when there are no tasks left. In the case of an interactive // installation, also stop if we have some output to send to the browser, // the URL parameters have changed, or an end to the page request was diff --git a/core/lib/Drupal/Core/Command/InstallCommand.php b/core/lib/Drupal/Core/Command/InstallCommand.php new file mode 100644 index 0000000..ffa342d --- /dev/null +++ b/core/lib/Drupal/Core/Command/InstallCommand.php @@ -0,0 +1,336 @@ +classLoader = $class_loader; + } + + /** + * {@inheritdoc} + */ + protected function configure() { + $this->setName('install') + ->setDescription('Installs a Drupal development site. This is not meant for production or any custom development. It is a quick and easy way to get Drupal running.') + ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.') + ->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in.', 'en') + ->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name.', 'Drupal') + ->addUsage('demo_umami --langcode fr') + ->addUsage('standard --site-name QuickInstall'); + + parent::configure(); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); + if (!extension_loaded('pdo_sqlite')) { + $io->getErrorStyle()->error('You must have the SQLite PHP extension installed. See https://secure.php.net/manual/en/sqlite.installation.php for instructions.'); + return 1; + } + + // Change the directory to the Drupal root. + chdir(dirname(dirname(dirname(dirname(dirname(__DIR__)))))); + + // Check whether there is already an installation. + if ($this->isDrupalInstalled()) { + // Do not fail if the site is already installed so this command can be + // chained with ServerCommand. + $output->writeln('Drupal is already installed.'); + return 0; + } + + $install_profile = $input->getArgument('install-profile'); + if ($install_profile && !$this->validateProfile($install_profile, $io)) { + return 1; + } + if (!$install_profile) { + $install_profile = $this->selectProfile($io); + } + + return $this->install($this->classLoader, $io, $install_profile, $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name')); + } + + /** + * Returns whether there is already an existing Drupal installation. + * + * @return bool + */ + protected function isDrupalInstalled() { + try { + $kernel = new DrupalKernel('prod', $this->classLoader, FALSE); + $kernel::bootEnvironment(); + $kernel->setSitePath($this->getSitePath()); + Settings::initialize($kernel->getAppRoot(), $kernel->getSitePath(), $this->classLoader); + $kernel->boot(); + } + catch (ConnectionNotDefinedException $e) { + return FALSE; + } + return !empty(Database::getConnectionInfo()); + } + + /** + * Installs Drupal with specified installation profile. + * + * @param object $class_loader + * The class loader. + * @param \Symfony\Component\Console\Style\SymfonyStyle $io + * The Symfony output decorator. + * @param string $profile + * The installation profile to use. + * @param string $langcode + * The language to install the site in. + * @param string $site_path + * The path to install the site to, like 'sites/default'. + * @param string $site_name + * The site name. + * + * @throws \Exception + * Thrown when failing to create the $site_path directory or settings.php. + */ + protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name) { + $password = Crypt::randomBytesBase64(12); + $parameters = [ + 'interactive' => FALSE, + 'site_path' => $site_path, + 'parameters' => [ + 'profile' => $profile, + 'langcode' => $langcode, + ], + 'forms' => [ + 'install_settings_form' => [ + 'driver' => 'sqlite', + 'sqlite' => [ + 'database' => $site_path . '/files/.sqlite', + ], + ], + 'install_configure_form' => [ + 'site_name' => $site_name, + 'site_mail' => 'drupal@localhost', + 'account' => [ + 'name' => 'admin', + 'mail' => 'admin@localhost', + 'pass' => [ + 'pass1' => $password, + 'pass2' => $password, + ], + ], + 'enable_update_status_module' => TRUE, + // form_type_checkboxes_value() requires NULL instead of FALSE values + // for programmatic form submissions to disable a checkbox. + 'enable_update_status_emails' => NULL, + ], + ], + ]; + + // Create the directory and settings.php if not there so that the installer + // works. + if (!is_dir($site_path)) { + if ($io->isVerbose()) { + $io->writeln("Creating directory: $site_path"); + } + if (!mkdir($site_path, 0775)) { + throw new \RuntimeException("Failed to create directory $site_path"); + } + } + if (!file_exists("{$site_path}/settings.php")) { + if ($io->isVerbose()) { + $io->writeln("Creating file: {$site_path}/settings.php"); + } + if (!copy('sites/default/default.settings.php', "{$site_path}/settings.php")) { + throw new \RuntimeException("Copying sites/default/default.settings.php to {$site_path}/settings.php failed."); + } + } + + $io->writeln('Drupal installation started. This could take a minute.'); + require_once 'core/includes/install.core.inc'; + + $progress_bar = $io->createProgressBar(); + install_drupal($class_loader, $parameters, function ($install_state) use ($progress_bar) { + static $started = FALSE; + if (!$started) { + $started = TRUE; + $tasks = install_tasks_to_perform($install_state); + // We've already done 1. + $progress_bar->setFormat("%current%/%max% [%bar%]\n%message%\n"); + $progress_bar->start(count($tasks) + 1); + $progress_bar->setMessage(t('Installing @drupal', ['@drupal' => drupal_install_profile_distribution_name()])); + } + $task = current(install_tasks_to_perform($install_state)); + if (isset($task['display_name'])) { + $progress_bar->setMessage($task['display_name']); + } + $progress_bar->advance(); + }); + $success_message = t('Congratulations, you installed @drupal!', [ + '@drupal' => drupal_install_profile_distribution_name(), + '@name' => 'admin', + '@pass' => $password, + ], ['langcode' => $langcode]); + $progress_bar->setMessage('' . $success_message . ''); + $progress_bar->finish(); + $io->writeln('Username: admin'); + $io->writeln("Password: $password"); + } + + /** + * Gets the site path. + * + * Defaults to 'sites/default'. For testing purposes this can be overridden + * using the DRUPAL_DEV_SITE_PATH environment variable. + * + * @return string + * The site path to use. + */ + protected function getSitePath() { + return getenv('DRUPAL_DEV_SITE_PATH') ?: 'sites/default'; + } + + /** + * Selects the install profile to use. + * + * @param \Symfony\Component\Console\Style\SymfonyStyle $io + * Symfony style output decorator. + * + * @return string + * The selected install profile. + * + * @see _install_select_profile() + * @see \Drupal\Core\Installer\Form\SelectProfileForm + */ + protected function selectProfile(SymfonyStyle $io) { + $profiles = $this->getProfiles(); + + // If there is a distribution there will be only one profile. + if (count($profiles) == 1) { + return key($profiles); + } + // Display alphabetically by human-readable name, but always put the core + // profiles first (if they are present in the filesystem). + natcasesort($profiles); + if (isset($profiles['minimal'])) { + // If the expert ("Minimal") core profile is present, put it in front of + // any non-core profiles rather than including it with them + // alphabetically, since the other profiles might be intended to group + // together in a particular way. + $profiles = ['minimal' => $profiles['minimal']] + $profiles; + } + if (isset($profiles['standard'])) { + // If the default ("Standard") core profile is present, put it at the very + // top of the list. This profile will have its radio button pre-selected, + // so we want it to always appear at the top. + $profiles = ['standard' => $profiles['standard']] + $profiles; + } + reset($profiles); + return $io->choice('Select an installation profile', $profiles, current($profiles)); + } + + /** + * Validates a user provided install profile. + * + * @param string $install_profile + * Install profile to validate. + * @param \Symfony\Component\Console\Style\SymfonyStyle $io + * Symfony style output decorator. + * + * @return bool + * TRUE if the profile is valid, FALSE if not. + */ + protected function validateProfile($install_profile, SymfonyStyle $io) { + // Allow people to install hidden and non-distribution profiles if they + // supply the argument. + $profiles = $this->getProfiles(TRUE, FALSE); + if (!isset($profiles[$install_profile])) { + $error_msg = sprintf("'%s' is not a valid install profile.", $install_profile); + $alternatives = []; + foreach (array_keys($profiles) as $profile_name) { + $lev = levenshtein($install_profile, $profile_name); + if ($lev <= strlen($profile_name) / 4 || FALSE !== strpos($profile_name, $install_profile)) { + $alternatives[] = $profile_name; + } + } + if (!empty($alternatives)) { + $error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives)); + } + $io->getErrorStyle()->error($error_msg); + return FALSE; + } + return TRUE; + } + + /** + * Gets a list of profiles. + * + * @param bool $include_hidden + * (optional) Whether to include hidden profiles. Defaults to FALSE. + * @param bool $auto_select_distributions + * (optional) Whether to only return the first distribution found. + * + * @return string[] + * An array of profile descriptions keyed by the profile machine name. + */ + protected function getProfiles($include_hidden = FALSE, $auto_select_distributions = TRUE) { + // Build a list of all available profiles. + $listing = new ExtensionDiscovery(getcwd(), FALSE); + $listing->setProfileDirectories([]); + $profiles = []; + $info_parser = new InfoParserDynamic(); + foreach ($listing->scan('profile') as $profile) { + $details = $info_parser->parse($profile->getPathname()); + // Don't show hidden profiles. + if (!$include_hidden && !empty($details['hidden'])) { + continue; + } + // Determine the name of the profile; default to the internal name if none + // is specified. + $name = isset($details['name']) ? $details['name'] : $profile->getName(); + $description = isset($details['description']) ? $details['description'] : $name; + $profiles[$profile->getName()] = $description; + + if ($auto_select_distributions && !empty($details['distribution'])) { + return [$profile->getName() => $description]; + } + } + return $profiles; + } + +} diff --git a/core/lib/Drupal/Core/Command/QuickStartCommand.php b/core/lib/Drupal/Core/Command/QuickStartCommand.php new file mode 100644 index 0000000..15a2922 --- /dev/null +++ b/core/lib/Drupal/Core/Command/QuickStartCommand.php @@ -0,0 +1,75 @@ +setName('quick-start') + ->setDescription('Installs a Drupal site and runs a web server. This is not meant for production or any custom development. It is a quick and easy way to get Drupal running.') + ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.') + ->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in. Defaults to en.', 'en') + ->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name. Defaults to Drupal.', 'Drupal') + ->addOption('host', NULL, InputOption::VALUE_OPTIONAL, 'Provide a host for the server to run on. Defaults to 127.0.0.1.', '127.0.0.1') + ->addOption('port', NULL, InputOption::VALUE_OPTIONAL, 'Provide a port for the server to run on. Will be determined automatically if none supplied.') + ->addOption('suppress-login', 's', InputOption::VALUE_NONE, 'Disable opening a login URL in a browser.') + ->addUsage('demo_umami --langcode fr') + ->addUsage('standard --site-name QuickInstall --host localhost --port 8080'); + + parent::configure(); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) { + $command = $this->getApplication()->find('install'); + + $arguments = [ + 'command' => 'install', + 'install-profile' => $input->getArgument('install-profile'), + '--langcode' => $input->getOption('langcode'), + '--site-name' => $input->getOption('site-name'), + ]; + + $installInput = new ArrayInput($arguments); + $returnCode = $command->run($installInput, $output); + + if ($returnCode === 0) { + $command = $this->getApplication()->find('server'); + $arguments = [ + 'command' => 'server', + '--host' => $input->getOption('host'), + '--port' => $input->getOption('port'), + ]; + if ($input->getOption('suppress-login')) { + $arguments['--suppress-login'] = TRUE; + } + $serverInput = new ArrayInput($arguments); + $returnCode = $command->run($serverInput, $output); + } + return $returnCode; + } + +} diff --git a/core/lib/Drupal/Core/Command/ServerCommand.php b/core/lib/Drupal/Core/Command/ServerCommand.php new file mode 100644 index 0000000..f06daf3 --- /dev/null +++ b/core/lib/Drupal/Core/Command/ServerCommand.php @@ -0,0 +1,263 @@ +classLoader = $class_loader; + } + + /** + * {@inheritdoc} + */ + protected function configure() { + $this->setDescription('Starts up a webserver for a site.') + ->addOption('host', NULL, InputOption::VALUE_OPTIONAL, 'Provide a host for the server to run on.', '127.0.0.1') + ->addOption('port', NULL, InputOption::VALUE_OPTIONAL, 'Provide a port for the server to run on. Will be determined automatically if none supplied.') + ->addOption('suppress-login', 's', InputOption::VALUE_NONE, 'Disable opening a login URL in a browser.') + ->addUsage('--host localhost --port 8080'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); + + $host = $input->getOption('host'); + $port = $input->getOption('port'); + if (!$port) { + $port = $this->findAvailablePort($host); + } + if (!$port) { + $io->getErrorStyle()->error('Unable to automatically determine a port. Use the --port to hardcode an available port.'); + } + + try { + $kernel = $this->boot(); + } + catch (ConnectionNotDefinedException $e) { + $io->getErrorStyle()->error('No installation found. Use the \'install\' command.'); + return 1; + } + return $this->start($host, $port, $kernel, $input, $io); + } + + /** + * Boots up a Drupal environment. + * + * @return \Drupal\Core\DrupalKernelInterface + * The Drupal kernel. + * + * @throws \Exception + * Exception thrown if kernel does not boot. + */ + protected function boot() { + $kernel = new DrupalKernel('prod', $this->classLoader, FALSE); + $kernel::bootEnvironment(); + $kernel->setSitePath($this->getSitePath()); + Settings::initialize($kernel->getAppRoot(), $kernel->getSitePath(), $this->classLoader); + $kernel->boot(); + // Some services require a request to work. For example, CommentManager. + // This is needed as generating the URL fires up entity load hooks. + $kernel->getContainer() + ->get('request_stack') + ->push(Request::createFromGlobals()); + + return $kernel; + } + + /** + * Finds an available port. + * + * @param string $host + * The host to find a port on. + * + * @return int|false + * The available port or FALSE, if no available port found, + */ + protected function findAvailablePort($host) { + $port = 8888; + while ($port >= 8888 && $port <= 9999) { + $connection = @fsockopen($host, $port); + if (is_resource($connection)) { + // Port is being used. + fclose($connection); + } + else { + // Port is available. + return $port; + } + $port++; + } + return FALSE; + } + + /** + * Opens a URL in your system default browser. + * + * @param string $url + * The URL to browser to. + * @param \Symfony\Component\Console\Style\SymfonyStyle $io + * The IO. + */ + protected function openBrowser($url, SymfonyStyle $io) { + $is_windows = defined('PHP_WINDOWS_VERSION_BUILD'); + if ($is_windows) { + // Handle escaping ourselves. + $cmd = 'start "web" "' . $url . '""'; + } + else { + $url = escapeshellarg($url); + } + + $is_linux = (new Process('which xdg-open'))->run(); + $is_osx = (new Process('which open'))->run(); + if ($is_linux === 0) { + $cmd = 'xdg-open ' . $url; + } + elseif ($is_osx === 0) { + $cmd = 'open ' . $url; + } + + if (empty($cmd)) { + $io->getErrorStyle() + ->error('No suitable browser opening command found, open yourself: ' . $url); + return; + } + + if ($io->isVerbose()) { + $io->writeln("Browser command: $cmd"); + } + + // Need to escape double quotes in the command so the PHP will work. + $cmd = str_replace('"', '\"', $cmd); + // Sleep for 2 seconds before opening the browser. This allows the command + // to start up the PHP built-in webserver in the meantime. We use a + // PhpProcess so that Windows powershell users also get a browser opened + // for them. + $php = ""; + $process = new PhpProcess($php); + $process->start(); + return; + } + + /** + * Gets a one time login URL for user 1. + * + * @return string + * The one time login URL for user 1. + */ + protected function getOneTimeLoginUrl() { + $user = User::load(1); + \Drupal::moduleHandler()->load('user'); + return user_pass_reset_url($user); + } + + /** + * Starts up a webserver with a running Drupal. + * + * @param string $host + * The hostname of the webserver. + * @param int $port + * The port to start the webserver on. + * @param \Drupal\Core\DrupalKernelInterface $kernel + * The Drupal kernel. + * @param \Symfony\Component\Console\Input\InputInterface $input + * The input. + * @param \Symfony\Component\Console\Style\SymfonyStyle $io + * The IO. + * + * @return int + * The exit status of the PHP in-built webserver command. + */ + protected function start($host, $port, DrupalKernelInterface $kernel, InputInterface $input, SymfonyStyle $io) { + $finder = new PhpExecutableFinder(); + $binary = $finder->find(); + if ($binary === FALSE) { + throw new \RuntimeException('Unable to find the PHP binary.'); + } + + $io->writeln("Drupal development server started: "); + $one_time_login = "http://$host:$port{$this->getOneTimeLoginUrl()}/login"; + $io->writeln("One time login url: <$one_time_login>"); + $io->writeln('Press Ctrl-C to quit.'); + + if (!$input->getOption('suppress-login')) { + if ($this->openBrowser("$one_time_login?destination=" . urlencode("/"), $io) === 1) { + $io->error('Error while opening up a one time login URL'); + } + } + + // Use the Process object to construct an escaped command line. + $process = new Process([ + $binary, + '-S', + $host . ':' . $port, + '.ht.router.php', + ], $kernel->getAppRoot(), [], NULL, NULL); + if ($io->isVerbose()) { + $io->writeln("Server command: {$process->getCommandLine()}"); + } + + // Write a blank line so that server output and the useful information are + // visually separated. + $io->writeln(''); + $cwd = getcwd(); + chdir($kernel->getAppRoot()); + // To support Windows we can't open the server in another process. + passthru($process->getCommandLine(), $status); + chdir($cwd); + return $status; + } + + /** + * Gets the site path. + * + * Defaults to 'sites/default'. For testing purposes this can be overridden + * using the DRUPAL_DEV_SITE_PATH environment variable. + * + * @return string + * The site path to use. + */ + protected function getSitePath() { + return getenv('DRUPAL_DEV_SITE_PATH') ?: 'sites/default'; + } + +} diff --git a/core/lib/Drupal/Core/Composer/Composer.php b/core/lib/Drupal/Core/Composer/Composer.php index c84797f..9833b7e 100644 --- a/core/lib/Drupal/Core/Composer/Composer.php +++ b/core/lib/Drupal/Core/Composer/Composer.php @@ -6,6 +6,7 @@ use Composer\Script\Event; use Composer\Installer\PackageEvent; use Composer\Semver\Constraint\Constraint; +use Composer\Util\ProcessExecutor; /** * Provides static functions for composer script events. @@ -270,6 +271,13 @@ protected static function findPackageKey($package_name) { } /** + * Removes Composer's timeout so that scripts can run indefinitely. + */ + public static function removeTimeout() { + ProcessExecutor::setTimeout(0); + } + + /** * Helper method to remove directories and the files they contain. * * @param string $path diff --git a/core/lib/Drupal/Core/Installer/InstallerKernel.php b/core/lib/Drupal/Core/Installer/InstallerKernel.php index adeb5c5..eefeffe 100644 --- a/core/lib/Drupal/Core/Installer/InstallerKernel.php +++ b/core/lib/Drupal/Core/Installer/InstallerKernel.php @@ -3,6 +3,7 @@ namespace Drupal\Core\Installer; use Drupal\Core\DrupalKernel; +use Symfony\Component\HttpFoundation\Request; /** * Extend DrupalKernel to handle force some kernel behaviors. @@ -66,4 +67,15 @@ public function getInstallProfile() { return $profile; } + /** + * {@inheritdoc} + */ + public static function createFromRequest(Request $request, $class_loader, $environment, $allow_dumping = TRUE, $app_root = NULL) { + // This override exists because we don't need to initialize the settings + // again as they already are in install_begin_request(). + $kernel = new static($environment, $class_loader, $allow_dumping, $app_root); + static::bootEnvironment($app_root); + return $kernel; + } + } diff --git a/core/scripts/drupal b/core/scripts/drupal new file mode 100644 index 0000000..7f71228 --- /dev/null +++ b/core/scripts/drupal @@ -0,0 +1,26 @@ +#!/usr/bin/env php +add(new QuickStartCommand()); +$application->add(new InstallCommand($classloader)); +$application->add(new ServerCommand($classloader)); + +$application->run(); diff --git a/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php b/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php new file mode 100644 index 0000000..b8f346f --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php @@ -0,0 +1,256 @@ +php = $php_executable_finder->find(); + $this->root = dirname(dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)))); + chdir($this->root); + if (!is_writable("{$this->root}/sites/simpletest")) { + $this->markTestSkipped('This test requires a writable sites/simpletest directory'); + } + // Get a lock and a valid site path. + $this->testDb = new TestDatabase(); + } + + /** + * {@inheritdoc} + */ + public function tearDown() { + if ($this->testDb) { + $test_site_directory = $this->root . DIRECTORY_SEPARATOR . $this->testDb->getTestSitePath(); + if (file_exists($test_site_directory)) { + // @todo use the tear down command from + // https://www.drupal.org/project/drupal/issues/2926633 + // Delete test site directory. + $this->fileUnmanagedDeleteRecursive($test_site_directory, [ + BrowserTestBase::class, + 'filePreDeleteCallback' + ]); + } + } + parent::tearDown(); + } + + /** + * Tests the quick-start command. + */ + public function testQuickStartCommand() { + // Install a site using the standard profile to ensure the one time login + // link generation works. + $install_command = "{$this->php} core/scripts/drupal quick-start standard --site-name='Test site {$this->testDb->getDatabasePrefix()}' --suppress-login"; + $process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]); + $process->inheritEnvironmentVariables(); + $process->setTimeout(500); + $process->start(); + $guzzle = new Client(); + $port = FALSE; + while ($process->isRunning()) { + if (preg_match('/127.0.0.1:(\d+)/', $process->getOutput(), $match)) { + $port = $match[1]; + break; + } + // Wait for more output. + sleep(1); + } + $this->assertContains('Drupal installation started. This could take a minute.', $process->getOutput()); + $this->assertContains('Congratulations, you installed Drupal!', $process->getOutput()); + $this->assertNotFalse($port, "Web server running on port $port"); + $this->assertContains("127.0.0.1:$port/user/reset/1/", $process->getOutput()); + + // Give the server a couple of seconds to be ready. + sleep(2); + + // Generate a cookie so we can make a request against the installed site. + include $this->root . '/core/includes/bootstrap.inc'; + define('DRUPAL_TEST_IN_CHILD_SITE', FALSE); + chmod($this->testDb->getTestSitePath(), 0755); + $cookieJar = CookieJar::fromArray([ + 'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()) + ], '127.0.0.1'); + + $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]); + $content = (string) $response->getBody(); + $this->assertContains('Test site ' . $this->testDb->getDatabasePrefix(), $content); + + // Stop the web server. + $process->stop(); + } + + /** + * Tests the quick-start commands. + */ + public function testQuickStartInstallAndServerCommands() { + // Install a site. + $install_command = "{$this->php} core/scripts/drupal install testing --site-name='Test site {$this->testDb->getDatabasePrefix()}'"; + $install_process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]); + $install_process->inheritEnvironmentVariables(); + $install_process->setTimeout(500); + $result = $install_process->run(); + $this->assertContains('Drupal installation started. This could take a minute.', $install_process->getOutput()); + $this->assertContains('Congratulations, you installed Drupal!', $install_process->getOutput()); + $this->assertSame(0, $result); + + // Run the PHP built-in webserver. + $server_command = "{$this->php} core/scripts/drupal server --suppress-login"; + $server_process = new Process($server_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]); + $server_process->inheritEnvironmentVariables(); + $server_process->start(); + $guzzle = new Client(); + $port = FALSE; + while ($server_process->isRunning()) { + if (preg_match('/127.0.0.1:(\d+)/', $server_process->getOutput(), $match)) { + $port = $match[1]; + break; + } + // Wait for more output. + sleep(1); + } + $this->assertEquals('', $server_process->getErrorOutput()); + $this->assertContains("127.0.0.1:$port/user/reset/1/", $server_process->getOutput()); + $this->assertNotFalse($port, "Web server running on port $port"); + + // Give the server a couple of seconds to be ready. + sleep(2); + + // Generate a cookie so we can make a request against the installed site. + include $this->root . '/core/includes/bootstrap.inc'; + define('DRUPAL_TEST_IN_CHILD_SITE', FALSE); + chmod($this->testDb->getTestSitePath(), 0755); + $cookieJar = CookieJar::fromArray([ + 'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()) + ], '127.0.0.1'); + + $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]); + $content = (string) $response->getBody(); + $this->assertContains('Test site ' . $this->testDb->getDatabasePrefix(), $content); + + // Try to re-install over the top of an existing site. + $install_command = "{$this->php} core/scripts/drupal install testing --site-name='Test another site {$this->testDb->getDatabasePrefix()}'"; + $install_process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]); + $install_process->inheritEnvironmentVariables(); + $install_process->setTimeout(500); + $result = $install_process->run(); + $this->assertContains('Drupal is already installed.', $install_process->getOutput()); + $this->assertSame(0, $result); + + // Ensure the site name has not changed. + $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]); + $content = (string) $response->getBody(); + $this->assertContains('Test site ' . $this->testDb->getDatabasePrefix(), $content); + + // Stop the web server. + $server_process->stop(); + } + + /** + * Tests the install command with an invalid profile. + */ + public function testQuickStartCommandProfileValidation() { + // Install a site using the standard profile to ensure the one time login + // link generation works. + $install_command = "{$this->php} core/scripts/drupal quick-start umami --site-name='Test site {$this->testDb->getDatabasePrefix()}' --suppress-login"; + $process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]); + $process->inheritEnvironmentVariables(); + $process->run(); + $this->assertContains('\'umami\' is not a valid install profile. Did you mean \'demo_umami\'?', $process->getErrorOutput()); + } + + /** + * Tests the server command when there is no installation. + */ + public function testServerWithNoInstall() { + $server_command = "{$this->php} core/scripts/drupal server --suppress-login"; + $server_process = new Process($server_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]); + $server_process->inheritEnvironmentVariables(); + $server_process->run(); + $this->assertContains('No installation found. Use the \'install\' command.', $server_process->getErrorOutput()); + } + + /** + * Deletes all files and directories in the specified path recursively. + * + * Note this method has no dependencies on Drupal core to ensure that the + * test site can be torn down even if something in the test site is broken. + * + * @param string $path + * A string containing either an URI or a file or directory path. + * @param callable $callback + * (optional) Callback function to run on each file prior to deleting it and + * on each directory prior to traversing it. For example, can be used to + * modify permissions. + * + * @return bool + * TRUE for success or if path does not exist, FALSE in the event of an + * error. + * + * @see file_unmanaged_delete_recursive() + */ + protected function fileUnmanagedDeleteRecursive($path, $callback = NULL) { + if (isset($callback)) { + call_user_func($callback, $path); + } + if (is_dir($path)) { + $dir = dir($path); + while (($entry = $dir->read()) !== FALSE) { + if ($entry == '.' || $entry == '..') { + continue; + } + $entry_path = $path . '/' . $entry; + $this->fileUnmanagedDeleteRecursive($entry_path, $callback); + } + $dir->close(); + + return rmdir($path); + } + return unlink($path); + } + +}