praiadeseselle/wire/core/ModulesInstaller.php

841 lines
28 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* ProcessWire Modules: Installer
*
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com
*
*/
class ModulesInstaller extends ModulesClass {
/**
* Get an associative array [name => path] for all modules that arent currently installed.
*
* #pw-internal
*
* @return array Array of elements with $moduleName => $pathName
*
*/
public function getInstallable() {
return $this->modules->getInstallable();
}
/**
* Is the given module name installable? (i.e. not already installed)
*
* #pw-internal
*
* @param string $class Module class name
* @param bool $now Is module installable RIGHT NOW? This makes it check that all dependencies are already fulfilled (default=false)
* @return bool True if module is installable, false if not
*
*/
public function isInstallable($class, $now = false) {
$installableFiles = $this->modules->installableFiles;
if(!array_key_exists($class, $installableFiles)) return false;
if(!wireInstanceOf($class, 'Module')) {
$nsClass = $this->modules->getModuleClass($class, true);
if(!wireInstanceOf($nsClass, 'ProcessWire\\Module')) return false;
}
if($now) {
$requires = $this->getRequiresForInstall($class);
if(count($requires)) return false;
}
return true;
}
/**
* Install the given module name
*
* #pw-group-manipulation
*
* @param string $class Module name (class name)
* @param array|bool $options Optional associative array that can contain any of the following:
* - `dependencies` (boolean): When true, dependencies will also be installed where possible. Specify false to prevent installation of uninstalled modules. (default=true)
* - `resetCache` (boolean): When true, module caches will be reset after installation. (default=true)
* - `force` (boolean): Force installation, even if dependencies can't be met.
* @return null|Module Returns null if unable to install, or ready-to-use Module object if successfully installed.
* @throws WireException
*
*/
public function install($class, $options = array()) {
$defaults = array(
'dependencies' => true,
'resetCache' => true,
'force' => false,
);
if(is_bool($options)) {
// dependencies argument allowed instead of $options, for backwards compatibility
$dependencies = $options;
$options = array('dependencies' => $dependencies);
}
$options = array_merge($defaults, $options);
$dependencyOptions = $options;
$dependencyOptions['resetCache'] = false;
if(!$this->isInstallable($class)) return null;
$requires = $this->getRequiresForInstall($class);
if(count($requires)) {
$error = '';
$installable = false;
if($options['dependencies']) {
$installable = true;
foreach($requires as $requiresModule) {
if(!$this->isInstallable($requiresModule)) $installable = false;
}
if($installable) {
foreach($requires as $requiresModule) {
if(!$this->modules->install($requiresModule, $dependencyOptions)) {
$error = $this->_('Unable to install required module') . " - $requiresModule. ";
$installable = false;
break;
}
}
}
}
if(!$installable) {
$error = sprintf($this->_('Module %s requires: %s'), $class, implode(', ', $requires)) . ' ' . $error;
if($options['force']) {
$this->warning($this->_('Warning!') . ' ' . $error);
} else {
throw new WireException($error);
}
}
}
$database = $this->wire()->database;
$languages = $this->wire()->languages;
$config = $this->wire()->config;
if($languages) $languages->setDefault();
$pathname = $this->modules->installableFile($class);
if(strpos($class, "\\") === false) {
$ns = $this->modules->info->getModuleNamespace($class, array(
'file' => $pathname
));
$nsClass = $ns . $class;
} else {
$nsClass = $class;
}
if(!class_exists($nsClass, false)) {
$this->modules->files->includeModuleFile($pathname, $class);
$this->modules->files->setConfigPaths($class, dirname($pathname));
}
$module = $this->modules->newModule($nsClass, $class);
if(!$module) return null;
$flags = 0;
$moduleID = 0;
if($this->modules->isSingular($module)) $flags = $flags | Modules::flagsSingular;
if($this->modules->isAutoload($module)) $flags = $flags | Modules::flagsAutoload;
$sql = "INSERT INTO modules SET class=:class, flags=:flags, data=''";
if($config->systemVersion >= 7) $sql .= ", created=NOW()";
$query = $database->prepare($sql, "modules.install($class)");
$query->bindValue(":class", $class, \PDO::PARAM_STR);
$query->bindValue(":flags", $flags, \PDO::PARAM_INT);
try {
if($query->execute()) $moduleID = (int) $database->lastInsertId();
} catch(\Exception $e) {
if($languages) $languages->unsetDefault();
$this->trackException($e, false, true);
return null;
}
$this->modules->moduleID($class, $moduleID);
$this->modules->add($module);
$this->modules->installableFile($class, false); // unset
// note: the module's install is called here because it may need to know its module ID for installation of permissions, etc.
if(method_exists($module, '___install') || method_exists($module, 'install')) {
try {
/** @var _Module $module */
$module->install();
} catch(\PDOException $e) {
$error = $this->_('Module reported error during install') . " ($class): " . $e->getMessage();
$this->error($error);
$this->trackException($e, false, $error);
} catch(\Exception $e) {
// remove the module from the modules table if the install failed
$moduleID = (int) $moduleID;
$error = $this->_('Unable to install module') . " ($class): " . $e->getMessage();
$ee = null;
try {
$query = $database->prepare('DELETE FROM modules WHERE id=:id LIMIT 1'); // QA
$query->bindValue(":id", $moduleID, \PDO::PARAM_INT);
$query->execute();
} catch(\Exception $ee) {
$this->trackException($e, false, $error)->trackException($ee, true);
}
if($languages) $languages->unsetDefault();
if(is_null($ee)) $this->trackException($e, false, $error);
return null;
}
}
$info = $this->modules->info->getModuleInfoVerbose($class, array('noCache' => true));
$sanitizer = $this->wire()->sanitizer;
$permissions = $this->wire()->permissions;
// if this module has custom permissions defined in its getModuleInfo()['permissions'] array, install them
foreach($info['permissions'] as $name => $title) {
$name = $sanitizer->pageName($name);
if(ctype_digit("$name") || empty($name)) continue; // permission name not valid
$permission = $permissions->get($name);
if($permission->id) continue; // permision already there
try {
$permission = $permissions->add($name);
$permission->title = $title;
$permissions->save($permission);
if($languages) $languages->unsetDefault();
$this->message(sprintf($this->_('Added Permission: %s'), $permission->name));
} catch(\Exception $e) {
if($languages) $languages->unsetDefault();
$error = sprintf($this->_('Error adding permission: %s'), $name);
$this->trackException($e, false, $error);
}
}
// check if there are any modules in 'installs' that this module didn't handle installation of, and install them
$label = $this->_('Module Auto Install');
foreach($info['installs'] as $name) {
if(!$this->modules->isInstalled($name)) {
try {
$this->modules->install($name, $dependencyOptions);
$this->message("$label: $name");
} catch(\Exception $e) {
$error = "$label: $name - " . $e->getMessage();
$this->trackException($e, false, $error);
}
}
}
$this->log("Installed module '$module'");
if($languages) $languages->unsetDefault();
if($options['resetCache']) $this->modules->info->clearModuleInfoCache();
return $module;
}
/**
* Returns whether the module can be uninstalled
*
* #pw-internal
*
* @param string|Module $class
* @param bool $returnReason If true, the reason why it can't be uninstalled with be returned rather than boolean false.
* @return bool|string
*
*/
public function isUninstallable($class, $returnReason = false) {
$reason = '';
$reason1 = $this->_("Module is not already installed");
$namespace = $this->modules->info->getModuleNamespace($class);
$class = $this->modules->getModuleClass($class);
if(!$this->modules->isInstalled($class)) {
$reason = $reason1 . ' (a)';
} else {
$this->modules->includeModule($class);
if(!wireClassExists($namespace . $class, false)) {
$reason = $reason1 . " (b: $namespace$class)";
}
}
if(!$reason) {
// if the moduleInfo contains a non-empty 'permanent' property, then it's not uninstallable
$info = $this->modules->info->getModuleInfo($class);
if(!empty($info['permanent'])) {
$reason = $this->_("Module is permanent");
} else {
$dependents = $this->getRequiresForUninstall($class);
if(count($dependents)) $reason = $this->_("Module is required by other modules that must be removed first");
}
if(!$reason && in_array('Fieldtype', wireClassParents($namespace . $class))) {
foreach($this->wire()->fields as $field) {
$fieldtype = wireClassName($field->type, false);
if($fieldtype == $class) {
$reason = $this->_("This module is a Fieldtype currently in use by one or more fields");
break;
}
}
}
}
if($returnReason && $reason) return $reason;
return $reason ? false : true;
}
/**
* Returns whether the module can be deleted (have it's files physically removed)
*
* #pw-internal
*
* @param string|Module $class
* @param bool $returnReason If true, the reason why it can't be removed will be returned rather than boolean false.
* @return bool|string
*
*/
public function isDeleteable($class, $returnReason = false) {
$reason = '';
$class = $this->modules->getModuleClass($class);
$filename = $this->modules->installableFile($class);
$dirname = dirname($filename);
if(empty($filename) || $this->modules->isInstalled($class)) {
$reason = "Module must be uninstalled before it can be deleted.";
} else if(is_link($filename) || is_link($dirname) || is_link(dirname($dirname))) {
$reason = "Module is linked to another location";
} else if(!is_file($filename)) {
$reason = "Module file does not exist";
} else if(strpos($filename, $this->modules->coreModulesPath) === 0) {
$reason = "Core modules may not be deleted.";
} else if(!is_writable($filename)) {
$reason = "We have no write access to the module file, it must be removed manually.";
}
if($returnReason && $reason) return $reason;
return $reason ? false : true;
}
/**
* Delete the given module, physically removing its files
*
* #pw-group-manipulation
*
* @param string $class Module name (class name)
* @return bool
* @throws WireException If module can't be deleted, exception will be thrown containing reason.
*
*/
public function delete($class) {
$config = $this->wire()->config;
$fileTools = $this->wire()->files;
$class = $this->modules->getModuleClass($class);
$success = true;
$reason = $this->isDeleteable($class, true);
if($reason !== true) throw new WireException($reason);
$siteModulesPath = $config->paths->siteModules;
$filename = $this->modules->installableFile($class);
$basename = basename($filename);
// double check that $class is consistent with the actual $basename
if($basename === "$class.module" || $basename === "$class.module.php") {
// good, this is consistent with the format we require
} else {
throw new WireException("Unrecognized module filename format");
}
// now determine if module is the owner of the directory it exists in
// this is the case if the module class name is the same as the directory name
$path = dirname($filename); // full path to directory, i.e. .../site/modules/ProcessHello
$name = basename($path); // just name of directory that module is, i.e. ProcessHello
$parentPath = dirname($path); // full path to parent directory, i.e. ../site/modules
$backupPath = $parentPath . "/.$name"; // backup path, in case module is backed up
// first check that we are still in the /site/modules/ (or another non core modules path)
$inPath = false; // is module somewhere beneath /site/modules/ ?
$inRoot = false; // is module in /site/modules/ root? i.e. /site/modules/ModuleName.module
foreach($this->modules->getPaths() as $key => $modulesPath) {
if($key === 0) continue; // skip core modules path
if(strpos("$parentPath/", $modulesPath) === 0) $inPath = true;
if($modulesPath === $path) $inRoot = true;
}
$basename = basename($basename, '.php');
$basename = basename($basename, '.module');
$files = array(
"$basename.module",
"$basename.module.php",
"$basename.info.php",
"$basename.info.json",
"$basename.config.php",
"{$basename}Config.php",
);
if($inPath) {
// module is in /site/modules/[ModuleName]/
$numOtherModules = 0; // num modules in dir other than this one
$numLinks = 0; // number of symbolic links
$dirs = array("$path/");
do {
$dir = array_shift($dirs);
$this->message("Scanning: $dir", Notice::debug);
foreach(new \DirectoryIterator($dir) as $file) {
if($file->isDot()) continue;
if($file->isLink()) {
$numLinks++;
continue;
}
if($file->isDir()) {
$dirs[] = $fileTools->unixDirName($file->getPathname());
continue;
}
if(in_array($file->getBasename(), $files)) continue; // skip known files
if(strpos($file->getBasename(), '.module') && preg_match('{(\.module|\.module\.php)$}', $file->getBasename())) {
// another module exists in this dir, so we don't want to delete that
$numOtherModules++;
}
if(preg_match('{^(' . $basename . '\.[-_.a-zA-Z0-9]+)$}', $file->getBasename(), $matches)) {
// keep track of potentially related files in case we have to delete them individually
$files[] = $matches[1];
}
}
} while(count($dirs));
if(!$inRoot && !$numOtherModules && !$numLinks) {
// the modulePath had no other modules or directories in it, so we can delete it entirely
$success = (bool) $fileTools->rmdir($path, true);
if($success) {
$this->message("Removed directory: $path", Notice::debug);
if(is_dir($backupPath)) {
if($fileTools->rmdir($backupPath, true)) $this->message("Removed directory: $backupPath", Notice::debug);
}
$files = array();
} else {
$this->error("Failed to remove directory: $path", Notice::debug);
}
}
}
// remove module files individually
foreach($files as $file) {
$file = "$path/$file";
if(!file_exists($file)) continue;
if($fileTools->unlink($file, $siteModulesPath)) {
$this->message("Removed file: $file", Notice::debug);
} else {
$this->error("Unable to remove file: $file", Notice::debug);
}
}
$this->log("Deleted module '$class'");
return $success;
}
/**
* Uninstall the given module name
*
* #pw-group-manipulation
*
* @param string $class Module name (class name)
* @return bool
* @throws WireException
*
*/
public function uninstall($class) {
$class = $this->modules->getModuleClass($class);
$reason = $this->modules->isUninstallable($class, true);
if($reason !== true) {
// throw new WireException("$class - Can't Uninstall - $reason");
return false;
}
// check if there are any modules still installed that this one says it is responsible for installing
foreach($this->getUninstalls($class) as $name) {
// catch uninstall exceptions at this point since original module has already been uninstalled
$label = $this->_('Module Auto Uninstall');
try {
$this->modules->uninstall($name);
$this->message("$label: $name");
} catch(\Exception $e) {
$error = "$label: $name - " . $e->getMessage();
$this->trackException($e, false, $error);
}
}
$info = $this->modules->info->getModuleInfoVerbose($class);
$module = $this->modules->getModule($class, array(
'noPermissionCheck' => true,
'noInstall' => true,
// 'noInit' => true
));
if(!$module) return false;
// remove all hooks attached to this module
$hooks = $module instanceof Wire ? $module->getHooks() : array();
foreach($hooks as $hook) {
if($hook['method'] == 'uninstall') continue;
$this->message("Removed hook $class => " . $hook['options']['fromClass'] . " $hook[method]", Notice::debug);
$module->removeHook($hook['id']);
}
// remove all hooks attached to other ProcessWire objects
$hooks = array_merge($this->getHooks('*'), $this->wire()->hooks->getAllLocalHooks());
foreach($hooks as $hook) {
/** @var Wire $toObject */
$toObject = $hook['toObject'];
$toClass = wireClassName($toObject, false);
$toMethod = $hook['toMethod'];
if($class === $toClass && $toMethod != 'uninstall') {
$toObject->removeHook($hook['id']);
$this->message("Removed hook $class => " . $hook['options']['fromClass'] . " $hook[method]", Notice::debug);
}
}
if(method_exists($module, '___uninstall') || method_exists($module, 'uninstall')) {
// note module's uninstall method may throw an exception to abort the uninstall
/** @var _Module $module */
$module->uninstall();
}
$database = $this->wire()->database;
$query = $database->prepare('DELETE FROM modules WHERE class=:class LIMIT 1'); // QA
$query->bindValue(":class", $class, \PDO::PARAM_STR);
$query->execute();
// add back to the installable list
if(class_exists("ReflectionClass")) {
$reflector = new \ReflectionClass($this->modules->getModuleClass($module, true));
$this->modules->installableFile($class, $reflector->getFileName());
}
$this->modules->moduleID($class, false);
$this->modules->remove($module);
$sanitizer = $this->wire()->sanitizer;
$permissions = $this->wire()->permissions;
// delete permissions installed by this module
if(isset($info['permissions']) && is_array($info['permissions'])) {
foreach($info['permissions'] as $name => $title) {
$name = $sanitizer->pageName($name);
if(ctype_digit("$name") || empty($name)) continue;
$permission = $permissions->get($name);
if(!$permission->id) continue;
try {
$permissions->delete($permission);
$this->message(sprintf($this->_('Deleted Permission: %s'), $name));
} catch(\Exception $e) {
$error = sprintf($this->_('Error deleting permission: %s'), $name);
$this->trackException($e, false, $error);
}
}
}
$this->log("Uninstalled module '$class'");
$this->modules->refresh();
return true;
}
/**
* Return an array of other module class names that are uninstalled when the given one is
*
* #pw-internal
*
* The opposite of this function is found in the getModuleInfo array property 'installs'.
* Note that 'installs' and uninstalls may be different, as only modules in the 'installs' list
* that indicate 'requires' for the installer module will be uninstalled.
*
* @param $class
* @return array
*
*/
public function getUninstalls($class) {
$uninstalls = array();
$class = $this->modules->getModuleClass($class);
if(!$class) return $uninstalls;
$info = $this->modules->info->getModuleInfoVerbose($class);
// check if there are any modules still installed that this one says it is responsible for installing
foreach($info['installs'] as $name) {
// if module isn't installed, then great
if(!$this->modules->isInstalled($name)) continue;
// if an 'installs' module doesn't indicate that it requires this one, then leave it installed
$i = $this->modules->info->getModuleInfo($name);
if(!in_array($class, $i['requires'])) continue;
// add it to the uninstalls array
$uninstalls[] = $name;
}
return $uninstalls;
}
/**
* Return an array of module class names that require the given one
*
* #pw-internal
*
* @param string $class
* @param bool $uninstalled Set to true to include modules dependent upon this one, even if they aren't installed.
* @param bool $installs Set to true to exclude modules that indicate their install/uninstall is controlled by $class.
* @return array()
*
*/
public function getRequiredBy($class, $uninstalled = false, $installs = false) {
$class = $this->modules->getModuleClass($class);
$info = $this->modules->info->getModuleInfo($class);
$dependents = array();
foreach($this->modules as $module) {
$c = $this->modules->getModuleClass($module);
if(!$uninstalled && !$this->modules->isInstalled($c)) continue;
$i = $this->modules->info->getModuleInfo($c);
if(!count($i['requires'])) continue;
if($installs && in_array($c, $info['installs'])) continue;
if(in_array($class, $i['requires'])) $dependents[] = $c;
}
return $dependents;
}
/**
* Return an array of module class names required by the given one
*
* Default behavior is to return all listed requirements, whether they are currently met by
* the environment or not. Specify TRUE for the 2nd argument to return only requirements
* that are not currently met.
*
* #pw-internal
*
* @param string $class
* @param bool $onlyMissing Set to true to return only required modules/versions that aren't
* yet installed or don't have the right version. It excludes those that the class says it
* will install (via 'installs' property of getModuleInfo)
* @param null|bool $versions Set to true to always include versions in the returned requirements list.
* Set to null to always exclude versions in requirements list (so only module class names will be there).
* Set to false (which is the default) to include versions only when version is the dependency issue.
* Note versions are already included when the installed version is not adequate.
* @return array of strings each with ModuleName Operator Version, i.e. "ModuleName>=1.0.0"
*
*/
public function getRequires($class, $onlyMissing = false, $versions = false) {
$class = $this->modules->getModuleClass($class);
$info = $this->modules->getModuleInfo($class);
$requires = $info['requires'];
$currentVersion = 0;
// quick exit if arguments permit it
if(!$onlyMissing) {
if($versions) foreach($requires as $key => $value) {
list($operator, $version) = $info['requiresVersions'][$value];
if(empty($version)) continue;
if(ctype_digit("$version")) $version = $this->modules->formatVersion($version);
if(!empty($version)) $requires[$key] .= "$operator$version";
}
return $requires;
}
foreach($requires as $key => $requiresClass) {
if(in_array($requiresClass, $info['installs'])) {
// if this module installs the required class, then we can stop now
// and we assume it's installing the version it wants
unset($requires[$key]);
}
list($operator, $requiresVersion) = $info['requiresVersions'][$requiresClass];
$installed = true;
if($requiresClass == 'PHP') {
$currentVersion = PHP_VERSION;
} else if($requiresClass == 'ProcessWire') {
$currentVersion = $this->wire()->config->version;
} else if($this->modules->isInstalled($requiresClass)) {
if(!$requiresVersion) {
// if no version is specified then requirement is already met
unset($requires[$key]);
continue;
}
$i = $this->modules->getModuleInfo($requiresClass, array('noCache' => true));
$currentVersion = $i['version'];
} else {
// module is not installed
$installed = false;
}
if($installed && $this->versionCompare($currentVersion, $requiresVersion, $operator)) {
// required version is installed
unset($requires[$key]);
} else if(empty($requiresVersion)) {
// just the class name is fine
continue;
} else if(is_null($versions)) {
// request is for no versions to be included (just class names)
$requires[$key] = $requiresClass;
} else {
// update the requires string to clarify what version it requires
if(ctype_digit("$requiresVersion")) $requiresVersion = $this->modules->formatVersion($requiresVersion);
$requires[$key] = "$requiresClass$operator$requiresVersion";
}
}
return $requires;
}
/**
* Compare one module version to another, returning TRUE if they match the $operator or FALSE otherwise
*
* #pw-internal
*
* @param int|string $currentVersion May be a number like 123 or a formatted version like 1.2.3
* @param int|string $requiredVersion May be a number like 123 or a formatted version like 1.2.3
* @param string $operator
* @return bool
*
*/
public function versionCompare($currentVersion, $requiredVersion, $operator) {
if(ctype_digit("$currentVersion") && ctype_digit("$requiredVersion")) {
// integer comparison is ok
$currentVersion = (int) $currentVersion;
$requiredVersion = (int) $requiredVersion;
$result = false;
switch($operator) {
case '=': $result = ($currentVersion == $requiredVersion); break;
case '>': $result = ($currentVersion > $requiredVersion); break;
case '<': $result = ($currentVersion < $requiredVersion); break;
case '>=': $result = ($currentVersion >= $requiredVersion); break;
case '<=': $result = ($currentVersion <= $requiredVersion); break;
case '!=': $result = ($currentVersion != $requiredVersion); break;
}
return $result;
}
// if either version has no periods or only one, like "1.2" then format it to stanard: "1.2.0"
if(substr_count($currentVersion, '.') < 2) $currentVersion = $this->modules->formatVersion($currentVersion);
if(substr_count($requiredVersion, '.') < 2) $requiredVersion = $this->modules->formatVersion($requiredVersion);
return version_compare($currentVersion, $requiredVersion, $operator);
}
/**
* Return an array of module class names required by the given one to be installed before this one.
*
* Excludes modules that are required but already installed.
* Excludes uninstalled modules that $class indicates it handles via it's 'installs' getModuleInfo property.
*
* #pw-internal
*
* @param string $class
* @return array()
*
*/
public function getRequiresForInstall($class) {
return $this->getRequires($class, true);
}
/**
* Return an array of module class names required by the given one to be uninstalled before this one.
*
* Excludes modules that the given one says it handles via it's 'installs' getModuleInfo property.
* Module class names in returned array include operator and version in the string.
*
* #pw-internal
*
* @param string $class
* @return array()
*
*/
public function getRequiresForUninstall($class) {
return $this->getRequiredBy($class, false, true);
}
/**
* Return array of dependency errors for given module name
*
* #pw-internal
*
* @param $moduleName
* @return array If no errors, array will be blank. If errors, array will be of strings (error messages)
*
*/
public function getDependencyErrors($moduleName) {
$moduleName = $this->modules->getModuleClass($moduleName);
$info = $this->modules->getModuleInfo($moduleName);
$errors = array();
if(empty($info['requires'])) return $errors;
foreach($info['requires'] as $requiresName) {
$error = '';
if(!$this->modules->isInstalled($requiresName)) {
$error = $requiresName;
} else if(!empty($info['requiresVersions'][$requiresName])) {
list($operator, $version) = $info['requiresVersions'][$requiresName];
$info2 = $this->modules->getModuleInfo($requiresName);
$requiresVersion = $info2['version'];
if(!empty($version) && !$this->versionCompare($requiresVersion, $version, $operator)) {
$error = "$requiresName $operator $version";
}
}
if($error) $errors[] = sprintf($this->_('Failed module dependency: %s requires %s'), $moduleName, $error);
}
return $errors;
}
/**
* Get URL where an administrator can install given module name
*
* If module is already installed, it returns the URL to edit the module.
*
* @param string $className
* @return string
*
*/
public function getModuleInstallUrl($className) {
if(!is_string($className)) $className = $this->modules->getModuleClass($className);
$className = $this->wire()->sanitizer->fieldName($className);
if($this->modules->isInstalled($className)) return $this->modules->getModuleEditUrl($className);
return $this->wire()->config->urls->admin . "module/installConfirm?name=$className";
}
}