From 5e9ada58821e7e54fed9fed5dd2b1e7968967067 Mon Sep 17 00:00:00 2001 From: Tim Starling Date: Wed, 19 Sep 2018 15:43:14 +1000 Subject: [PATCH] install.php: Allow extensions and skins to be specified Allow the extensions and skins installed by maintenance/install.php to be customised using --skins= and --extensions=. If the argument is am empty string then no extensions/skins are installed. For backwards compatibility, the default is to install all skins, but to install all extensions only if --with-extensions is given. The new CLI options may be specified multiple times, but for convenience, comma-separated lists can also be used. Also: * Rename $option to $options * If an extension has a dependency error, propagate the very readable error message generated by ExtensionRegistry back to the user. * Split getExtensionInfo() from the loop body of findExtensionsByType(), so that CliInstaller can use it to validate its parameters and get error messages. * I didn't like the idea of removing the "s" from the directory name in order to construct the JSON file name, so I split findExtensionsByType() from findExtensions(), with the former not having this hack. In findExtensions(), make the previous assumption that the directory name is always "extensions" or "skins" explicit, throwing an exception if it is otherwise. Change-Id: Id0fb63cd4e61a047ef3396ee1c38d6073dfc7fd1 --- includes/installer/CliInstaller.php | 71 ++++++++++++---- includes/installer/Installer.php | 126 +++++++++++++++++++--------- includes/installer/i18n/en.json | 2 + includes/installer/i18n/qqq.json | 2 + maintenance/install.php | 4 + 5 files changed, 149 insertions(+), 56 deletions(-) diff --git a/includes/installer/CliInstaller.php b/includes/installer/CliInstaller.php index aee51e78cd..f59b5da3ad 100644 --- a/includes/installer/CliInstaller.php +++ b/includes/installer/CliInstaller.php @@ -50,30 +50,30 @@ class CliInstaller extends Installer { /** * @param string $siteName * @param string|null $admin - * @param array $option + * @param array $options */ - function __construct( $siteName, $admin = null, array $option = [] ) { + function __construct( $siteName, $admin = null, array $options = [] ) { global $wgContLang; parent::__construct(); - if ( isset( $option['scriptpath'] ) ) { + if ( isset( $options['scriptpath'] ) ) { $this->specifiedScriptPath = true; } foreach ( $this->optionMap as $opt => $global ) { - if ( isset( $option[$opt] ) ) { - $GLOBALS[$global] = $option[$opt]; - $this->setVar( $global, $option[$opt] ); + if ( isset( $options[$opt] ) ) { + $GLOBALS[$global] = $options[$opt]; + $this->setVar( $global, $options[$opt] ); } } - if ( isset( $option['lang'] ) ) { + if ( isset( $options['lang'] ) ) { global $wgLang, $wgLanguageCode; - $this->setVar( '_UserLang', $option['lang'] ); - $wgLanguageCode = $option['lang']; + $this->setVar( '_UserLang', $options['lang'] ); + $wgLanguageCode = $options['lang']; $wgContLang = MediaWikiServices::getInstance()->getContentLanguage(); - $wgLang = Language::factory( $option['lang'] ); + $wgLang = Language::factory( $options['lang'] ); RequestContext::getMain()->setLanguage( $wgLang ); } @@ -89,32 +89,47 @@ class CliInstaller extends Installer { $this->setVar( '_AdminName', $admin ); } - if ( !isset( $option['installdbuser'] ) ) { + if ( !isset( $options['installdbuser'] ) ) { $this->setVar( '_InstallUser', $this->getVar( 'wgDBuser' ) ); $this->setVar( '_InstallPassword', $this->getVar( 'wgDBpassword' ) ); } else { $this->setVar( '_InstallUser', - $option['installdbuser'] ); + $options['installdbuser'] ); $this->setVar( '_InstallPassword', - $option['installdbpass'] ?? "" ); + $options['installdbpass'] ?? "" ); // Assume that if we're given the installer user, we'll create the account. $this->setVar( '_CreateDBAccount', true ); } - if ( isset( $option['pass'] ) ) { - $this->setVar( '_AdminPassword', $option['pass'] ); + if ( isset( $options['pass'] ) ) { + $this->setVar( '_AdminPassword', $options['pass'] ); } // Detect and inject any extension found - if ( isset( $option['with-extensions'] ) ) { + if ( isset( $options['extensions'] ) ) { + $status = $this->validateExtensions( + 'extension', 'extensions', $options['extensions'] ); + if ( !$status->isOK() ) { + $this->showStatusMessage( $status ); + } + $this->setVar( '_Extensions', $status->value ); + } elseif ( isset( $options['with-extensions'] ) ) { $this->setVar( '_Extensions', array_keys( $this->findExtensions() ) ); } // Set up the default skins - $skins = array_keys( $this->findExtensions( 'skins' ) ); + if ( isset( $options['skins'] ) ) { + $status = $this->validateExtensions( 'skin', 'skins', $options['skins'] ); + if ( !$status->isOK() ) { + $this->showStatusMessage( $status ); + } + $skins = $status->value; + } else { + $skins = array_keys( $this->findExtensions( 'skins' ) ); + } $this->setVar( '_Skins', $skins ); if ( $skins ) { @@ -123,6 +138,28 @@ class CliInstaller extends Installer { } } + private function validateExtensions( $type, $directory, $nameLists ) { + $extensions = []; + $status = new Status; + foreach ( (array)$nameLists as $nameList ) { + foreach ( explode( ',', $nameList ) as $name ) { + $name = trim( $name ); + if ( $name === '' ) { + continue; + } + $extStatus = $this->getExtensionInfo( $type, $directory, $name ); + if ( $extStatus->isOK() ) { + $extensions[] = $name; + } else { + $status->merge( $extStatus ); + } + } + } + $extensions = array_unique( $extensions ); + $status->value = $extensions; + return $status; + } + /** * Main entry point. */ diff --git a/includes/installer/Installer.php b/includes/installer/Installer.php index 0f8a5b092c..d51ea2ed1b 100644 --- a/includes/installer/Installer.php +++ b/includes/installer/Installer.php @@ -1264,15 +1264,33 @@ abstract class Installer { } /** - * Finds extensions that follow the format /$directory/Name/Name.php, - * and returns an array containing the value for 'Name' for each found extension. + * Find extensions or skins in a subdirectory of $IP. + * Returns an array containing the value for 'Name' for each found extension. * - * Reasonable values for $directory include 'extensions' (the default) and 'skins'. - * - * @param string $directory Directory to search in + * @param string $directory Directory to search in, relative to $IP, must be either "extensions" + * or "skins" * @return array [ $extName => [ 'screenshots' => [ '...' ] ] */ public function findExtensions( $directory = 'extensions' ) { + switch ( $directory ) { + case 'extensions': + return $this->findExtensionsByType( 'extension', 'extensions' ); + case 'skins': + return $this->findExtensionsByType( 'skin', 'skins' ); + default: + throw new InvalidArgumentException( "Invalid extension type" ); + } + } + + /** + * Find extensions or skins, and return an array containing the value for 'Name' for each found + * extension. + * + * @param string $type Either "extension" or "skin" + * @param string $directory Directory to search in, relative to $IP + * @return array [ $extName => [ 'screenshots' => [ '...' ] ] + */ + protected function findExtensionsByType( $type = 'extension', $directory = 'extensions' ) { if ( $this->getVar( 'IP' ) === null ) { return []; } @@ -1282,40 +1300,15 @@ abstract class Installer { return []; } - // extensions -> extension.json, skins -> skin.json - $jsonFile = substr( $directory, 0, strlen( $directory ) - 1 ) . '.json'; - $dh = opendir( $extDir ); $exts = []; while ( ( $file = readdir( $dh ) ) !== false ) { if ( !is_dir( "$extDir/$file" ) ) { continue; } - $fullJsonFile = "$extDir/$file/$jsonFile"; - $isJson = file_exists( $fullJsonFile ); - $isPhp = false; - if ( !$isJson ) { - // Only fallback to PHP file if JSON doesn't exist - $fullPhpFile = "$extDir/$file/$file.php"; - $isPhp = file_exists( $fullPhpFile ); - } - if ( $isJson || $isPhp ) { - // Extension exists. Now see if there are screenshots - $exts[$file] = []; - if ( is_dir( "$extDir/$file/screenshots" ) ) { - $paths = glob( "$extDir/$file/screenshots/*.png" ); - foreach ( $paths as $path ) { - $exts[$file]['screenshots'][] = str_replace( $extDir, "../$directory", $path ); - } - - } - } - if ( $isJson ) { - $info = $this->readExtension( $fullJsonFile ); - if ( $info === false ) { - continue; - } - $exts[$file] += $info; + $status = $this->getExtensionInfo( $type, $directory, $file ); + if ( $status->isOK() ) { + $exts[$file] = $status->value; } } closedir( $dh ); @@ -1324,12 +1317,65 @@ abstract class Installer { return $exts; } + /** + * @param string $type Either "extension" or "skin" + * @param string $parentRelPath The parent directory relative to $IP + * @param string $name The extension or skin name + * @return Status An object containing an error list. If there were no errors, an associative + * array of information about the extension can be found in $status->value. + */ + protected function getExtensionInfo( $type, $parentRelPath, $name ) { + if ( $this->getVar( 'IP' ) === null ) { + throw new Exception( 'Cannot find extensions since the IP variable is not yet set' ); + } + if ( $type !== 'extension' && $type !== 'skin' ) { + throw new InvalidArgumentException( "Invalid extension type" ); + } + $absDir = $this->getVar( 'IP' ) . "/$parentRelPath/$name"; + $relDir = "../$parentRelPath/$name"; + if ( !is_dir( $absDir ) ) { + return Status::newFatal( 'config-extension-not-found', $name ); + } + $jsonFile = $type . '.json'; + $fullJsonFile = "$absDir/$jsonFile"; + $isJson = file_exists( $fullJsonFile ); + $isPhp = false; + if ( !$isJson ) { + // Only fallback to PHP file if JSON doesn't exist + $fullPhpFile = "$absDir/$name.php"; + $isPhp = file_exists( $fullPhpFile ); + } + if ( !$isJson && !$isPhp ) { + return Status::newFatal( 'config-extension-not-found', $name ); + } + + // Extension exists. Now see if there are screenshots + $info = []; + if ( is_dir( "$absDir/screenshots" ) ) { + $paths = glob( "$absDir/screenshots/*.png" ); + foreach ( $paths as $path ) { + $info['screenshots'][] = str_replace( $absDir, $relDir, $path ); + } + } + + if ( $isJson ) { + $jsonStatus = $this->readExtension( $fullJsonFile ); + if ( !$jsonStatus->isOK() ) { + return $jsonStatus; + } + $info += $jsonStatus->value; + } + + return Status::newGood( $info ); + } + /** * @param string $fullJsonFile * @param array $extDeps * @param array $skinDeps * - * @return array|bool False if this extension can't be loaded + * @return Status On success, an array of extension information is in $status->value. On + * failure, the Status object will have an error list. */ private function readExtension( $fullJsonFile, $extDeps = [], $skinDeps = [] ) { $load = [ @@ -1340,7 +1386,7 @@ abstract class Installer { foreach ( $extDeps as $dep ) { $fname = "$extDir/$dep/extension.json"; if ( !file_exists( $fname ) ) { - return false; + return Status::newFatal( 'config-extension-not-found', $dep ); } $load[$fname] = 1; } @@ -1350,7 +1396,7 @@ abstract class Installer { foreach ( $skinDeps as $dep ) { $fname = "$skinDir/$dep/skin.json"; if ( !file_exists( $fname ) ) { - return false; + return Status::newFatal( 'config-extension-not-found', $dep ); } $load[$fname] = 1; } @@ -1364,7 +1410,8 @@ abstract class Installer { ) { // If something is incompatible with a dependency, we have no real // option besides skipping it - return false; + return Status::newFatal( 'config-extension-dependency', + basename( dirname( $fullJsonFile ) ), $e->getMessage() ); } elseif ( $e->missingExtensions || $e->missingSkins ) { // There's an extension missing in the dependency tree, // so add those to the dependency list and try again @@ -1375,7 +1422,8 @@ abstract class Installer { ); } // Some other kind of dependency error? - return false; + return Status::newFatal( 'config-extension-dependency', + basename( dirname( $fullJsonFile ) ), $e->getMessage() ); } $ret = []; // The order of credits will be the order of $load, @@ -1397,7 +1445,7 @@ abstract class Installer { } $ret['type'] = $credits['type']; - return $ret; + return Status::newGood( $ret ); } /** diff --git a/includes/installer/i18n/en.json b/includes/installer/i18n/en.json index c89be17604..893df5af3a 100644 --- a/includes/installer/i18n/en.json +++ b/includes/installer/i18n/en.json @@ -309,6 +309,8 @@ "config-skins-screenshot": "$1 ($2)", "config-extensions-requires": "$1 (requires $2)", "config-screenshot": "screenshot", + "config-extension-not-found": "Could not find the registration file for the extension \"$1\"", + "config-extension-dependency": "A dependency error was encountered while installing the extension \"$1\": $2", "mainpagetext": "MediaWiki has been installed.", "mainpagedocfooter": "Consult the [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide] for information on using the wiki software.\n\n== Getting started ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Learn how to combat spam on your wiki]" } diff --git a/includes/installer/i18n/qqq.json b/includes/installer/i18n/qqq.json index e423bcd93b..39dbbcaec4 100644 --- a/includes/installer/i18n/qqq.json +++ b/includes/installer/i18n/qqq.json @@ -330,6 +330,8 @@ "config-skins-screenshot": "Radio button text, $1 is the skin name, and $2 is a link to a screenshot of that skin, where the link text is {{msg-mw|config-screenshot}}.", "config-extensions-requires": "Radio button text, $1 is the extension name, and $2 are links to other extensions that this one requires.\n{{Identical|Require}}", "config-screenshot": "Link text for the link in {{msg-mw|config-skins-screenshot}}\n{{Identical|Screenshot}}", + "config-extension-not-found": "An error shown when an extension or skin named by the user could not be found.\n* $1 is the extension name", + "config-extension-dependency": "An error shown if an extension could not be loaded due to it depending on the wrong version of MediaWiki or an uninstallable extension.\n* $1 is the extension name\n* $2 is a more detailed explanation, in English", "mainpagetext": "Along with {{msg-mw|mainpagedocfooter}}, the text you will see on the Main Page when your wiki is installed.", "mainpagedocfooter": "Along with {{msg-mw|mainpagetext}}, the text you will see on the Main Page when your wiki is installed.\nThis might be a good place to put information about {{GRAMMAR:}}. See [[{{NAMESPACE}}:{{BASEPAGENAME}}/fi]] for an example. For languages having grammatical distinctions and not having an appropriate {{GRAMMAR:}} software available, a suggestion to check and possibly amend the messages having {{SITENAME}} may be valuable. See [[{{NAMESPACE}}:{{BASEPAGENAME}}/ksh]] for an example." } diff --git a/maintenance/install.php b/maintenance/install.php index 438e9dc478..3395458d9f 100644 --- a/maintenance/install.php +++ b/maintenance/install.php @@ -90,6 +90,10 @@ class CommandLineInstaller extends Maintenance { $this->addOption( 'env-checks', "Run environment checks only, don't change anything" ); $this->addOption( 'with-extensions', "Detect and include extensions" ); + $this->addOption( 'extensions', 'Comma-separated list of extensions to install', + false, true, false, true ); + $this->addOption( 'skins', 'Comma-separated list of skins to install (default: all)', + false, true, false, true ); } public function getDbType() { -- 2.20.1