diff --git a/system/defines.php b/system/defines.php index a56126901..7d1ce424a 100644 --- a/system/defines.php +++ b/system/defines.php @@ -2,7 +2,7 @@ // Some standard defines define('GRAV', true); -define('GRAV_VERSION', '1.0.10'); +define('GRAV_VERSION', '1.1.0-beta'); define('DS', '/'); define('GRAV_PHP_MIN', '5.5.9'); diff --git a/system/src/Grav/Common/GPM/Common/Package.php b/system/src/Grav/Common/GPM/Common/Package.php index ff9a087c9..b6dd3febd 100644 --- a/system/src/Grav/Common/GPM/Common/Package.php +++ b/system/src/Grav/Common/GPM/Common/Package.php @@ -3,6 +3,17 @@ namespace Grav\Common\GPM\Common; use Grav\Common\Data\Data; +/** + * @property string name + * @property string version + * @property string available + * @property string package_type + * @property string description_plain + * @property string slug + * @property array author + * @property mixed changelog + */ + class Package { protected $data; diff --git a/system/src/Grav/Common/GPM/GPM.php b/system/src/Grav/Common/GPM/GPM.php index b18075a2a..246d40a28 100644 --- a/system/src/Grav/Common/GPM/GPM.php +++ b/system/src/Grav/Common/GPM/GPM.php @@ -194,6 +194,28 @@ class GPM extends Iterator return $items; } + /** + * Get the latest release of a package from the GPM + * + * @param $package_name + * + * @return string + */ + public function getLatestVersionOfPackage($package_name) + { + $repository = $this->repository['plugins']; + + if (isset($repository[$package_name])) { + return $repository[$package_name]->version; + } + + //Not a plugin, it's a theme? + $repository = $this->repository['themes']; + if (isset($repository[$package_name])) { + return $repository[$package_name]->version; + } + } + /** * Check if a Plugin or Theme is updatable * @param string $slug The slug of the package @@ -349,11 +371,6 @@ class GPM extends Iterator return false; } - /** - * Returns the list of Plugins and Themes available in the repository - * @return array Array of available Plugins and Themes - * Format: ['plugins' => array, 'themes' => array] - */ /** * Searches for a list of Packages in the repository * @param array $searches An array of either slugs or names diff --git a/system/src/Grav/Common/GPM/Remote/Plugins.php b/system/src/Grav/Common/GPM/Remote/Plugins.php index 036fc0c2a..17b6a7f35 100644 --- a/system/src/Grav/Common/GPM/Remote/Plugins.php +++ b/system/src/Grav/Common/GPM/Remote/Plugins.php @@ -12,7 +12,7 @@ class Plugins extends AbstractPackageCollection */ protected $type = 'plugins'; - protected $repository = 'https://getgrav.org/downloads/plugins.json'; + protected $repository = 'https://getgrav.org/downloads/plugins.json?v=' . GRAV_VERSION; /** * Local Plugins Constructor diff --git a/system/src/Grav/Common/GPM/Remote/Themes.php b/system/src/Grav/Common/GPM/Remote/Themes.php index 43a5af54d..e5008dc87 100644 --- a/system/src/Grav/Common/GPM/Remote/Themes.php +++ b/system/src/Grav/Common/GPM/Remote/Themes.php @@ -12,7 +12,7 @@ class Themes extends AbstractPackageCollection */ protected $type = 'themes'; - protected $repository = 'https://getgrav.org/downloads/themes.json'; + protected $repository = 'https://getgrav.org/downloads/themes.json?v=' . GRAV_VERSION; /** * Local Themes Constructor diff --git a/system/src/Grav/Console/Cli/BackupCommand.php b/system/src/Grav/Console/Cli/BackupCommand.php index cc55b224d..235e402cf 100644 --- a/system/src/Grav/Console/Cli/BackupCommand.php +++ b/system/src/Grav/Console/Cli/BackupCommand.php @@ -14,7 +14,10 @@ use Symfony\Component\Console\Input\InputArgument; */ class BackupCommand extends ConsoleCommand { + /** @var string $source */ protected $source; + + /** @var ProgressBar $progress */ protected $progress; /** diff --git a/system/src/Grav/Console/Cli/CleanCommand.php b/system/src/Grav/Console/Cli/CleanCommand.php index ff6d28614..d664942bc 100644 --- a/system/src/Grav/Console/Cli/CleanCommand.php +++ b/system/src/Grav/Console/Cli/CleanCommand.php @@ -4,7 +4,6 @@ namespace Grav\Console\Cli; use Grav\Common\Filesystem\Folder; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Formatter\OutputFormatterStyle; @@ -184,6 +183,9 @@ class CleanCommand extends Command } /** + * @param InputInterface $input + * @param OutputInterface $output + * * @return int|null|void */ protected function execute(InputInterface $input, OutputInterface $output) @@ -214,7 +216,7 @@ class CleanCommand extends Command } } - /** + /** * Set colors style definition for the formatter. * * @param InputInterface $input @@ -222,16 +224,16 @@ class CleanCommand extends Command */ public function setupConsole(InputInterface $input, OutputInterface $output) { - $this->input = $input; + $this->input = $input; $this->output = $output; $this->output->getFormatter()->setStyle('normal', new OutputFormatterStyle('white')); - $this->output->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, array('bold'))); - $this->output->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, array('bold'))); - $this->output->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, array('bold'))); - $this->output->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, array('bold'))); - $this->output->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, array('bold'))); - $this->output->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, array('bold'))); + $this->output->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, ['bold'])); + $this->output->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, ['bold'])); + $this->output->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, ['bold'])); + $this->output->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, ['bold'])); + $this->output->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, ['bold'])); + $this->output->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, ['bold'])); } } diff --git a/system/src/Grav/Console/Cli/ComposerCommand.php b/system/src/Grav/Console/Cli/ComposerCommand.php index 729b425a8..259bf23c6 100644 --- a/system/src/Grav/Console/Cli/ComposerCommand.php +++ b/system/src/Grav/Console/Cli/ComposerCommand.php @@ -2,7 +2,6 @@ namespace Grav\Console\Cli; use Grav\Console\ConsoleCommand; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; /** diff --git a/system/src/Grav/Console/Cli/NewProjectCommand.php b/system/src/Grav/Console/Cli/NewProjectCommand.php index 2d63f04dd..e0854f776 100644 --- a/system/src/Grav/Console/Cli/NewProjectCommand.php +++ b/system/src/Grav/Console/Cli/NewProjectCommand.php @@ -43,17 +43,17 @@ class NewProjectCommand extends ConsoleCommand $sandboxCommand = $this->getApplication()->find('sandbox'); $installCommand = $this->getApplication()->find('install'); - $sandboxArguments = new ArrayInput(array( + $sandboxArguments = new ArrayInput([ 'command' => 'sandbox', 'destination' => $this->input->getArgument('destination'), '-s' => $this->input->getOption('symlink') - )); + ]); - $installArguments = new ArrayInput(array( + $installArguments = new ArrayInput([ 'command' => 'install', 'destination' => $this->input->getArgument('destination'), '-s' => $this->input->getOption('symlink') - )); + ]); $sandboxCommand->run($sandboxArguments, $this->output); $installCommand->run($installArguments, $this->output); diff --git a/system/src/Grav/Console/Cli/SandboxCommand.php b/system/src/Grav/Console/Cli/SandboxCommand.php index be692dc02..5fa320b67 100644 --- a/system/src/Grav/Console/Cli/SandboxCommand.php +++ b/system/src/Grav/Console/Cli/SandboxCommand.php @@ -15,7 +15,7 @@ class SandboxCommand extends ConsoleCommand /** * @var array */ - protected $directories = array( + protected $directories = [ '/backup', '/cache', '/logs', @@ -27,22 +27,22 @@ class SandboxCommand extends ConsoleCommand '/user/data', '/user/plugins', '/user/themes', - ); + ]; /** * @var array */ - protected $files = array( + protected $files = [ '/.dependencies', '/.htaccess', '/user/config/site.yaml', '/user/config/system.yaml', - ); + ]; /** * @var array */ - protected $mappings = array( + protected $mappings = [ '/.editorconfig' => '/.editorconfig', '/.gitignore' => '/.gitignore', '/CHANGELOG.md' => '/CHANGELOG.md', @@ -56,7 +56,7 @@ class SandboxCommand extends ConsoleCommand '/vendor' => '/vendor', '/webserver-configs' => '/webserver-configs', '/codeception.yml' => '/codeception.yml', - ); + ]; /** * @var string @@ -200,7 +200,7 @@ class SandboxCommand extends ConsoleCommand */ private function initFiles() { - $this->check($this->output); + $this->check(); $this->output->writeln(''); $this->output->writeln('File Initializing'); @@ -225,8 +225,6 @@ class SandboxCommand extends ConsoleCommand if (!$files_init) { $this->output->writeln(' Files already exist'); } - - } /** @@ -239,7 +237,7 @@ class SandboxCommand extends ConsoleCommand // get pages files and initialize if no pages exist $pages_dir = $this->destination . '/user/pages'; - $pages_files = array_diff(scandir($pages_dir), array('..', '.')); + $pages_files = array_diff(scandir($pages_dir), ['..', '.']); if (count($pages_files) == 0) { $destination = $this->source . '/user/pages'; @@ -269,7 +267,6 @@ class SandboxCommand extends ConsoleCommand $this->output->writeln(""); } - /** * */ @@ -295,6 +292,7 @@ class SandboxCommand extends ConsoleCommand $success = false; } } + if (!$success) { $this->output->writeln(''); $this->output->writeln('install should be run with --symlink|--s to symlink first'); diff --git a/system/src/Grav/Console/ConsoleCommand.php b/system/src/Grav/Console/ConsoleCommand.php index 349c77ac8..e75bc3537 100644 --- a/system/src/Grav/Console/ConsoleCommand.php +++ b/system/src/Grav/Console/ConsoleCommand.php @@ -3,7 +3,6 @@ namespace Grav\Console; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** diff --git a/system/src/Grav/Console/Gpm/InfoCommand.php b/system/src/Grav/Console/Gpm/InfoCommand.php index 99f8bd2e2..f52e66595 100644 --- a/system/src/Grav/Console/Gpm/InfoCommand.php +++ b/system/src/Grav/Console/Gpm/InfoCommand.php @@ -83,7 +83,7 @@ class InfoCommand extends ConsoleCommand $this->output->writeln("" . str_pad("Author", 12) . ": " . $foundPackage->author['name'] . ' <' . $foundPackage->author['email'] . '> ' . $packageURL); - foreach (array( + foreach ([ 'version', 'keywords', 'date', @@ -95,7 +95,7 @@ class InfoCommand extends ConsoleCommand 'bugs', 'zipball_url', 'license' - ) as $info) { + ] as $info) { if (isset($foundPackage->$info)) { $name = ucfirst($info); $data = $foundPackage->$info; diff --git a/system/src/Grav/Console/Gpm/InstallCommand.php b/system/src/Grav/Console/Gpm/InstallCommand.php index dca4fe77b..f8d93ae4d 100644 --- a/system/src/Grav/Console/Gpm/InstallCommand.php +++ b/system/src/Grav/Console/Gpm/InstallCommand.php @@ -5,12 +5,12 @@ use Grav\Common\Filesystem\Folder; use Grav\Common\GPM\GPM; use Grav\Common\GPM\Installer; use Grav\Common\GPM\Response; +use Grav\Common\Grav; use Grav\Common\Utils; use Grav\Console\ConsoleCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; -use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Yaml\Yaml; define('GIT_REGEX', '/http[s]?:\/\/(?:.*@)?(github|bitbucket)(?:.org|.com)\/.*\/(.*)/'); @@ -21,29 +21,29 @@ define('GIT_REGEX', '/http[s]?:\/\/(?:.*@)?(github|bitbucket)(?:.org|.com)\/.*\/ */ class InstallCommand extends ConsoleCommand { - /** - * @var - */ + /** @var */ protected $data; - /** - * @var - */ + + /** @var GPM */ protected $gpm; - /** - * @var - */ + + /** @var */ protected $destination; - /** - * @var - */ + + /** @var */ protected $file; - /** - * @var - */ + + /** @var */ protected $tmp; + /** @var */ protected $local_config; + /** @var bool */ + protected $use_symlinks; + + /** @var array */ + protected $demo_processing = []; /** * @@ -81,7 +81,17 @@ class InstallCommand extends ConsoleCommand } /** - * @return int|null|void + * Allows to set the GPM object, used for testing the class + * + * @param $gpm + */ + public function setGpm($gpm) + { + $this->gpm = $gpm; + } + + /** + * @return int|null|void|bool */ protected function serve() { @@ -91,7 +101,7 @@ class InstallCommand extends ConsoleCommand $packages = array_map('strtolower', $this->input->getArgument('package')); $this->data = $this->gpm->findPackages($packages); - if (false === $this->isWindows() && @is_file(getenv("HOME").'/.grav/config')) { + if (false === $this->isWindows() && @is_file(getenv("HOME") . '/.grav/config')) { $local_config_file = exec('eval echo ~/.grav/config'); if (file_exists($local_config_file)) { $this->local_config = Yaml::parse($local_config_file); @@ -122,36 +132,344 @@ class InstallCommand extends ConsoleCommand unset($this->data['not_found']); unset($this->data['total']); - foreach ($this->data as $data) { - foreach ($data as $package) { - //Check for dependencies - if (isset($package->dependencies)) { - $this->output->writeln("Package " . $package->name . " has ". count($package->dependencies) . " required dependencies that must be installed first..."); - $this->output->writeln(''); + if (isset($this->local_config)) { + // Symlinks available, ask if Grav should use them - $dependency_data = $this->gpm->findPackages($package->dependencies); + $this->use_symlinks = false; + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Should Grav use the symlinks if available? [y|N] ', false); - if (!$dependency_data['total']) { - $this->output->writeln("No dependencies found..."); - $this->output->writeln(''); - } else { - unset($dependency_data['total']); + if ($helper->ask($this->input, $this->output, $question)) { + $this->use_symlinks = true; + } + } - foreach($dependency_data as $type => $dep_data) { - foreach($dep_data as $name => $dep_package) { + $this->output->writeln(''); - $this->processPackage($dep_package); - } - } - } + try { + $dependencies = $this->processDependencies($packages); + } catch (\Exception $e) { + //Error out if there are incompatible packages requirements and tell which ones, and what to do + //Error out if there is any error in parsing the dependencies and their versions, and tell which one is broken + $this->output->writeln("" . $e->getMessage() . ""); + return false; + } + + if ($dependencies) { + //First, check for Grav dependency. If a dependency requires Grav > the current version, abort and tell. + if (isset($dependencies['grav'])) { + if (version_compare($this->calculateVersionNumberFromDependencyVersion($dependencies['grav']), GRAV_VERSION) === 1) { + //Needs a Grav update first + $this->output->writeln("One of the package dependencies requires Grav " . $dependencies['grav'] . ". Please update Grav first with `bin/gpm selfupgrade`"); + return false; } + unset($dependencies['grav']); + } - $this->processPackage($package); + try { + $this->installDependencies($dependencies, 'install', "The following dependencies need to be installed..."); + $this->installDependencies($dependencies, 'update', "The following dependencies need to be updated..."); + $this->installDependencies($dependencies, 'ignore', "The following dependencies can be updated as there is a newer version, but it's not mandatory..."); + } catch (\Exception $e) { + $this->output->writeln("Installation aborted"); + return false; + } + } + + //We're done installing dependencies. Install the actual packages + foreach ($this->data as $data) { + foreach ($data as $packageName => $package) { + if (in_array($packageName, array_keys($dependencies))) { + $this->output->writeln("Package " . $packageName . " already installed as dependency"); + } else { + $this->processPackage($package); + } + } + } + + if (count($this->demo_processing) > 0) { + foreach ($this->demo_processing as $package) { + $this->installDemoContent($package); } } // clear cache after successful upgrade $this->clearCache(); + + return true; + } + + /** + * Given a $dependencies list, filters their type according to $type and + * shows $message prior to listing them to the user. Then asks the user a confirmation prior + * to installing them. + * + * @param array $dependencies The dependencies array + * @param string $type The type of dependency to show: install, update, ignore + * @param string $message A message to be shown prior to listing the dependencies + * + * @throws \Exception + */ + public function installDependencies($dependencies, $type, $message) { + $packages = array_filter($dependencies, function ($action) use ($type) { return $action === $type; }); + if (count($packages) > 0) { + $this->output->writeln($message); + + foreach ($packages as $dependencyName => $dependencyVersion) { + $this->output->writeln(" |- Package " . $dependencyName . " requires a newer version"); + } + + $this->output->writeln(""); + + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Update these packages? [y|N] ', false); + + if ($helper->ask($this->input, $this->output, $question)) { + foreach ($packages as $dependencyName => $dependencyVersion) { + $this->processPackage($dependencyName); + } + $this->output->writeln(''); + } else { + throw new \Exception(); + } + } + } + + /** + * Fetch the dependencies, check the installed packages and return an array with + * the list of packages with associated an information on what to do: install, update or ignore. + * + * `ignore` means the package is already installed and can be safely left as-is. + * `install` means the package is not installed and must be installed. + * `update` means the package is already installed and must be updated as a dependency needs a higher version. + * + * @param array $packages + * + * @return mixed + * @throws \Exception + */ + public function processDependencies($packages) { + $dependencies = $this->calculateMergedDependenciesOfPackages($packages); + + foreach ($dependencies as $dependencySlug => $dependencyVersion) { + if ($this->gpm->isPluginInstalled($dependencySlug)) { + $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersion); + + // check the version, if an update is not strictly required mark as 'ignore' + $locator = Grav::instance()['locator']; + $blueprints_path = $locator->findResource('plugins://' . $dependencySlug . DS . 'blueprints.yaml'); + $package_yaml = Yaml::parse(file_get_contents($blueprints_path)); + $currentlyInstalledVersion = $package_yaml['version']; + + //if I already have the latest release, remove the dependency + $latestRelease = $this->gpm->getLatestVersionOfPackage($dependencySlug); + + if (version_compare($latestRelease, $dependencyVersion) == -1) { + //throw an exception if a required version cannot be found in the GPM yet + throw new \Exception('Dependency ' . $package_yaml['name'] . ' is required in a version higher than the latest release. Try running `bin/gpm -f index` to force a refresh of the GPM cache', 1); + } + + if (version_compare($currentlyInstalledVersion, $dependencyVersion) == -1) { + $dependencies[$dependencySlug] = 'update'; + } else { + if ($currentlyInstalledVersion == $latestRelease) { + unset($dependencies[$dependencySlug]); + } else { + $dependencies[$dependencySlug] = 'ignore'; + } + } + } else { + $dependencies[$dependencySlug] = 'install'; + } + } + + return $dependencies; + } + + /** + * Calculates and merges the dependencies of a package + * + * @param string $packageName The package information + * + * @param array $dependencies The dependencies array + * + * @return array + * @throws \Exception + */ + private function calculateMergedDependenciesOfPackage($packageName, $dependencies) + { + $packageData = $this->gpm->findPackage($packageName); + + //Check for dependencies + if (isset($packageData->dependencies)) { + foreach ($packageData->dependencies as $dependency) { + $current_package_name = $dependency['name']; + if (isset($dependency['version'])) { + $current_package_version_information = $dependency['version']; + } + + if (!isset($dependencies[$current_package_name])) { + // Dependency added for the first time + + if (!isset($current_package_version_information)) { + $dependencies[$current_package_name] = '*'; + } else { + $dependencies[$current_package_name] = $current_package_version_information; + } + + //Factor in the package dependencies too + $dependencies = $this->calculateMergedDependenciesOfPackage($current_package_name, $dependencies); + } + else { + // Dependency already added by another package + //if this package requires a version higher than the currently stored one, store this requirement instead + if (isset($current_package_version_information) && $current_package_version_information !== '*') { + + $currently_stored_version_information = $dependencies[$current_package_name]; + $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currently_stored_version_information); + + $currently_stored_version_is_in_next_significant_release_format = false; + if ($this->versionFormatIsNextSignificantRelease($currently_stored_version_information)) { + $currently_stored_version_is_in_next_significant_release_format = true; + } + + if (!$currently_stored_version_number) { + $currently_stored_version_number = '*'; + } + + $current_package_version_number = $this->calculateVersionNumberFromDependencyVersion($current_package_version_information); + if (!$current_package_version_number) { + throw new \Exception('Bad format for version of dependency ' . $current_package_name . ' for package ' . $packageName, 1); + } + + $current_package_version_is_in_next_significant_release_format = false; + if ($this->versionFormatIsNextSignificantRelease($current_package_version_information)) { + $current_package_version_is_in_next_significant_release_format = true; + } + + //If I had stored '*', change right away with the more specific version required + if ($currently_stored_version_number === '*') { + $dependencies[$current_package_name] = $current_package_version_information; + } else { + if (!$currently_stored_version_is_in_next_significant_release_format && !$current_package_version_is_in_next_significant_release_format) { + //Comparing versions equals or higher, a simple version_compare is enough + if (version_compare($currently_stored_version_number, $current_package_version_number) == -1) { //Current package version is higher + $dependencies[$current_package_name] = $current_package_version_information; + } + } else { + $compatible = $this->checkNextSignificantReleasesAreCompatible($currently_stored_version_number, $current_package_version_number); + if (!$compatible) { + throw new \Exception('Dependency ' . $current_package_name . ' is required in two incompatible versions', 2); + } + } + } + } + } + } + } + + return $dependencies; + } + + /** + * Calculates and merges the dependencies of the passed packages + * + * @param array $packages + * + * @return mixed + * @throws \Exception + */ + public function calculateMergedDependenciesOfPackages($packages) + { + $dependencies = []; + + foreach ($packages as $package) { + $dependencies = $this->calculateMergedDependenciesOfPackage($package, $dependencies); + } + + return $dependencies; + } + + /** + * Returns the actual version from a dependency version string. + * Examples: + * $versionInformation == '~2.0' => returns '2.0' + * $versionInformation == '>=2.0.2' => returns '2.0.2' + * $versionInformation == '*' => returns null + * $versionInformation == '' => returns null + * + * @param $versionInformation + * + * @return null|string + */ + public function calculateVersionNumberFromDependencyVersion($versionInformation) + { + if ($this->versionFormatIsNextSignificantRelease($versionInformation)) { + return substr($versionInformation, 1); + } elseif ($this->versionFormatIsEqualOrHigher($versionInformation)) { + return substr($versionInformation, 2); + } + + return null; + } + + /** + * Check if the passed version information contains next significant release (tilde) operator + * + * Example: returns true for $version: '~2.0' + * + * @param $version + * + * @return bool + */ + public function versionFormatIsNextSignificantRelease($version) { + return substr($version, 0, 1) == '~'; + } + + /** + * Check if the passed version information contains equal or higher operator + * + * Example: returns true for $version: '>=2.0' + * + * @param $version + * + * @return bool + */ + public function versionFormatIsEqualOrHigher($version) { + return substr($version, 0, 2) == '>='; + } + + /** + * Check if two releases are compatible by next significant release + * + * ~1.2 is equivalent to >=1.2 <2.0.0 + * ~1.2.3 is equivalent to >=1.2.3 <1.3.0 + * + * In short, allows the last digit specified to go up + * + * @param string $version1 the version string (e.g. '2.0.0' or '1.0') + * @param string $version2 the version string (e.g. '2.0.0' or '1.0') + * + * @return bool + */ + public function checkNextSignificantReleasesAreCompatible($version1, $version2) + { + $version1array = explode('.', $version1); + $version2array = explode('.', $version2); + + if (count($version1array) > count($version2array)) { + list($version1array, $version2array) = [$version2array, $version1array]; + } + + $i = 0; + while ($i < count($version1array) - 1) { + if ($version1array[$i] != $version2array[$i]) { + return false; + } + $i++; + } + + return true; } /** @@ -159,78 +477,65 @@ class InstallCommand extends ConsoleCommand */ private function processPackage($package) { - $install_options = ['GPM']; - - // if no name, not found in GPM - if (!isset($package->version)) { - unset($install_options[0]); - } - // if local config found symlink is a valid option - if (isset($this->local_config) && $this->getSymlinkSource($package)) { - $install_options[] = 'Symlink'; - } - // if override set, can install via git - if (isset($package->override_repository)) { - $install_options[] = 'Git'; + $symlink = false; + if ($this->use_symlinks) { + if ($this->getSymlinkSource($package) || !isset($package->version)) { + $symlink = true; + } } - // reindex list - $install_options = array_values($install_options); + $symlink ? $this->processSymlink($package) : $this->processGpm($package); - if (count($install_options) == 0) { - // no valid install options - error and return - $this->output->writeln("not valid installation methods found!"); - return; - } elseif (count($install_options) == 1) { - // only one option, use it... - $method = $install_options[0]; - } else { - $helper = $this->getHelper('question'); - $question = new ChoiceQuestion( - 'Please select installation method for ' . $package->name . ' ('.$install_options[0].' is default)', array_values($install_options), 0 - ); - $question->setErrorMessage('Method %s is invalid'); - $method = $helper->ask($this->input, $this->output, $question); - } - - $this->output->writeln(''); - - $method_name = 'process'.$method; - $this->$method_name($package); - - $this->installDemoContent($package); + $this->processDemo($package); } + /** + * Add package to the queue to process the demo content, if demo content exists + * + * @param $package + */ + private function processDemo($package) + { + $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo'; + if (file_exists($demo_dir)) { + $this->demo_processing[] = $package; + } + } /** + * Prompt to install the demo content of a package + * * @param $package */ private function installDemoContent($package) { $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo'; - $dest_dir = $this->destination . DS . 'user'; - $pages_dir = $dest_dir . DS . 'pages'; if (file_exists($demo_dir)) { + $dest_dir = $this->destination . DS . 'user'; + $pages_dir = $dest_dir . DS . 'pages'; + // Demo content exists, prompt to install it. - $this->output->writeln("Attention: ".$package->name . " contains demo content"); + $this->output->writeln("Attention: " . $package->name . " contains demo content"); $helper = $this->getHelper('question'); $question = new ConfirmationQuestion('Do you wish to install this demo content? [y|N] ', false); if (!$helper->ask($this->input, $this->output, $question)) { $this->output->writeln(" '- Skipped! "); $this->output->writeln(''); + return; } // if pages folder exists in demo if (file_exists($demo_dir . DS . 'pages')) { $pages_backup = 'pages.' . date('m-d-Y-H-i-s'); - $question = new ConfirmationQuestion('This will backup your current `user/pages` folder to `user/'. $pages_backup. '`, continue? [y|N]', false); + $question = new ConfirmationQuestion('This will backup your current `user/pages` folder to `user/' . $pages_backup . '`, continue? [y|N]', false); if (!$helper->ask($this->input, $this->output, $question)) { $this->output->writeln(" '- Skipped! "); $this->output->writeln(''); + return; } @@ -259,9 +564,7 @@ class InstallCommand extends ConsoleCommand */ private function getGitRegexMatches($package) { - if (isset($package->override_repository)) { - $repository = $package->override_repository; - } elseif (isset($package->repository)) { + if (isset($package->repository)) { $repository = $package->repository; } else { return false; @@ -294,6 +597,7 @@ class InstallCommand extends ConsoleCommand return $from; } } + return false; } @@ -336,6 +640,7 @@ class InstallCommand extends ConsoleCommand } + return; } @@ -346,34 +651,7 @@ class InstallCommand extends ConsoleCommand /** * @param $package */ - private function processGit($package) - { - $matches = $this->getGitRegexMatches($package); - - $this->output->writeln("Preparing to Git clone " . $package->name . " from " . $matches[0]); - - $this->output->write(" |- Checking destination... "); - $checks = $this->checkDestination($package); - - if (!$checks) { - $this->output->writeln(" '- Installation failed or aborted."); - $this->output->writeln(''); - } else { - $cmd = 'cd ' . $this->destination . ' && git clone ' . $matches[0] . ' ' . $package->install_path; - exec($cmd); - - // extra white spaces to clear out the buffer properly - $this->output->writeln(" |- Cloning package... ok "); - - $this->output->writeln(" '- Success! "); - $this->output->writeln(''); - } - } - - /** - * @param $package - */ - private function processGPM($package) + private function processGpm($package) { $version = isset($package->available) ? $package->available : $package->version; diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 542878380..6178fb5ec 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -35,7 +35,7 @@ class SelfupgradeCommand extends ConsoleCommand /** * @var array */ - protected $types = array('plugins', 'themes'); + protected $types = ['plugins', 'themes']; /** * @var */ diff --git a/system/src/Grav/Console/Gpm/UninstallCommand.php b/system/src/Grav/Console/Gpm/UninstallCommand.php index 63555aa9e..c03a30d25 100644 --- a/system/src/Grav/Console/Gpm/UninstallCommand.php +++ b/system/src/Grav/Console/Gpm/UninstallCommand.php @@ -58,6 +58,38 @@ class UninstallCommand extends ConsoleCommand ->setHelp('The uninstall command allows to uninstall plugins and themes'); } + /** + * Return the list of packages that have the passed one as dependency + * + * @param $package_slug The slug name of the package + * + * @return bool + */ + protected function getPackagesThatDependOnPackage($package_slug) + { + $plugins = $this->gpm->getInstalledPlugins(); + $themes = $this->gpm->getInstalledThemes(); + $packages = array_merge($plugins->toArray(), $themes->toArray()); + + $dependent_packages = []; + + foreach($packages as $package_name => $package) { + if (isset($package['dependencies'])) { + foreach($package['dependencies'] as $dependency) { + if (is_array($dependency)) { + $dependency = array_keys($dependency)[0]; + } + + if ($dependency == $package_slug) { + $dependent_packages[] = $package_name; + } + } + } + } + + return $dependent_packages; + } + /** * @return int|null|void */ @@ -98,7 +130,6 @@ class UninstallCommand extends ConsoleCommand foreach ($this->data as $slug => $package) { $this->output->writeln("Preparing to uninstall " . $package->name . " [v" . $package->version . "]"); - $this->output->write(" |- Checking destination... "); $checks = $this->checkDestination($slug, $package); @@ -117,6 +148,7 @@ class UninstallCommand extends ConsoleCommand $this->output->writeln(''); } } + } // clear cache after successful upgrade @@ -132,6 +164,23 @@ class UninstallCommand extends ConsoleCommand */ private function uninstallPackage($slug, $package) { + //check if there are packages that have this as a dependency. Abort and show list + $dependency_packages = $this->getPackagesThatDependOnPackage($slug); + if ($dependency_packages) { + $this->output->writeln(''); + $this->output->writeln(''); + $this->output->writeln("Uninstallation failed."); + $this->output->writeln(''); + if (count($dependency_packages) > 1) { + $this->output->writeln("The installed packages " . implode(', ', $dependency_packages) . " depend on this package. Please remove those first."); + } else { + $this->output->writeln("The installed package " . implode(', ', $dependency_packages) . " depend on this package. Please remove it first."); + } + + $this->output->writeln(''); + exit; + } + $path = Grav::instance()['locator']->findResource($package->package_type . '://' .$slug); Installer::uninstall($path); $errorCode = Installer::lastErrorCode(); @@ -149,6 +198,35 @@ class UninstallCommand extends ConsoleCommand // extra white spaces to clear out the buffer properly $this->output->writeln(" |- Uninstalling package... ok "); + + if (isset($package->dependencies)) { + $questionHelper = $this->getHelper('question'); + + foreach($package->dependencies as $dependency) { + if (is_array($dependency)) { + $dependency = array_keys($dependency)[0]; + } + + $dependencyPackage = $this->gpm->findPackage($dependency); + $question = new ConfirmationQuestion(" | '- Delete dependency " . $dependency . " too? [y|N] ", false); + $answer = $questionHelper->ask($this->input, $this->output, $question); + + if ($answer) { + $this->output->writeln(" | '- You decided to delete " . $dependency . "."); + + $uninstall = $this->uninstallPackage($dependency, $dependencyPackage); + + if (!$uninstall) { + $this->output->writeln(" '- Uninstallation failed or aborted."); + $this->output->writeln(''); + } else { + $this->output->writeln(" '- Success! "); + $this->output->writeln(''); + } + } + } + } + return true; } diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index 5ca795ea8..74bcd1be2 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -38,7 +38,7 @@ class UpdateCommand extends ConsoleCommand /** * @var array */ - protected $types = array('plugins', 'themes'); + protected $types = ['plugins', 'themes']; /** * @var GPM $gpm */ @@ -149,13 +149,13 @@ class UpdateCommand extends ConsoleCommand // finally update $install_command = $this->getApplication()->find('install'); - $args = new ArrayInput(array( + $args = new ArrayInput([ 'command' => 'install', 'package' => $slugs, '-f' => $this->input->getOption('force'), '-d' => $this->destination, '-y' => true - )); + ]); $command_exec = $install_command->run($args, $this->output); if ($command_exec != 0) { diff --git a/system/src/Grav/Console/Gpm/VersionCommand.php b/system/src/Grav/Console/Gpm/VersionCommand.php index 231645dcc..4b64586d0 100644 --- a/system/src/Grav/Console/Gpm/VersionCommand.php +++ b/system/src/Grav/Console/Gpm/VersionCommand.php @@ -70,7 +70,8 @@ class VersionCommand extends ConsoleCommand } } else { - if ($installed = $this->gpm->findPackage($package)) { + $installed = $this->gpm->findPackage($package); + if ($installed) { $name = $installed->name; $version = $installed->version; diff --git a/tests/unit/Grav/Common/InflectorTest.php b/tests/unit/Grav/Common/InflectorTest.php index be6f68386..9caa3e810 100644 --- a/tests/unit/Grav/Common/InflectorTest.php +++ b/tests/unit/Grav/Common/InflectorTest.php @@ -138,9 +138,9 @@ class InflectorTest extends \Codeception\TestCase\Test public function testMonthize() { $this->assertSame(0, $this->inflector->monthize(10)); - $this->assertSame(1, $this->inflector->monthize(30)); + $this->assertSame(1, $this->inflector->monthize(33)); $this->assertSame(1, $this->inflector->monthize(41)); - $this->assertSame(11, $this->inflector->monthize(365)); + $this->assertSame(11, $this->inflector->monthize(364)); } } diff --git a/tests/unit/Grav/Console/Gpm/InstallCommandTest.php b/tests/unit/Grav/Console/Gpm/InstallCommandTest.php new file mode 100644 index 000000000..a9088ca11 --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/InstallCommandTest.php @@ -0,0 +1,326 @@ +data[$packageName])) { + return $this->data[$packageName]; + } + + } + + public function findPackages() + { + return $this->data; + } +} + +/** + * Class InstallCommandTest + */ +class InstallCommandTest extends \Codeception\TestCase\Test +{ + /** @var Grav $grav */ + protected $grav; + + /** @var InstallCommand */ + protected $installCommand; + + /** @var GpmStub */ + protected $gpm; + + protected function _before() + { + $this->grav = Fixtures::get('grav'); + $this->installCommand = new InstallCommand(); + + $this->gpm = new GpmStub(); + } + + protected function _after() + { + } + + public function testCalculateMergedDependenciesOfPackages() + { + ////////////////////////////////////////////////////////////////////////////////////////// + // First working example + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + 'admin' => (object)[ + 'dependencies' => [ + ["name" => "grav", "version" => ">=1.0.10"], + ["name" => "form", "version" => "~2.0"], + ["name" => "login", "version" => ">=2.0"], + ["name" => "errors", "version" => "*"], + ["name" => "problems"], + ] + ], + 'test' => (object)[ + 'dependencies' => [ + ["name" => "errors", "version" => ">=1.0"] + ] + ], + 'grav', + 'form' => (object)[ + 'dependencies' => [ + ["name" => "errors", "version" => ">=3.2"] + ] + ] + + + ]; + $this->installCommand->setGpm($this->gpm); + + $packages = ['admin', 'test']; + + $dependencies = $this->installCommand->calculateMergedDependenciesOfPackages($packages); + + $this->assertTrue(is_array($dependencies)); + $this->assertSame(5, count($dependencies)); + + $this->assertTrue($dependencies['grav'] == '>=1.0.10'); + $this->assertTrue(isset($dependencies['errors'])); + $this->assertTrue(isset($dependencies['problems'])); + + ////////////////////////////////////////////////////////////////////////////////////////// + // Second working example + ////////////////////////////////////////////////////////////////////////////////////////// + $packages = ['admin', 'form']; + + $dependencies = $this->installCommand->calculateMergedDependenciesOfPackages($packages); + $this->assertTrue(is_array($dependencies)); + $this->assertSame(5, count($dependencies)); + $this->assertTrue($dependencies['errors'] == '>=3.2'); + + ////////////////////////////////////////////////////////////////////////////////////////// + // Third working example + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + + 'admin' => (object)[ + 'dependencies' => [ + ["name" => "errors", "version" => ">=4.0"], + ] + ], + 'test' => (object)[ + 'dependencies' => [ + ["name" => "errors", "version" => ">=1.0"] + ] + ], + 'another' => (object)[ + 'dependencies' => [ + ["name" => "errors", "version" => ">=3.2"] + ] + ] + + ]; + $this->installCommand->setGpm($this->gpm); + + $packages = ['admin', 'test', 'another']; + + + $dependencies = $this->installCommand->calculateMergedDependenciesOfPackages($packages); + $this->assertTrue(is_array($dependencies)); + $this->assertSame(1, count($dependencies)); + $this->assertTrue($dependencies['errors'] == '>=4.0'); + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // Test alpha / beta / rc + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + 'admin' => (object)[ + 'dependencies' => [ + ["name" => "package1", "version" => ">=4.0.0-rc1"], + ["name" => "package4", "version" => ">=3.2.0"], + ] + ], + 'test' => (object)[ + 'dependencies' => [ + ["name" => "package1", "version" => ">=4.0.0-rc2"], + ["name" => "package2", "version" => ">=3.2.0-alpha"], + ["name" => "package3", "version" => ">=3.2.0-alpha.2"], + ["name" => "package4", "version" => ">=3.2.0-alpha"], + ] + ], + 'another' => (object)[ + 'dependencies' => [ + ["name" => "package2", "version" => ">=3.2.0-beta.11"], + ["name" => "package3", "version" => ">=3.2.0-alpha.1"], + ["name" => "package4", "version" => ">=3.2.0-beta"], + ] + ] + ]; + $this->installCommand->setGpm($this->gpm); + + $packages = ['admin', 'test', 'another']; + + + $dependencies = $this->installCommand->calculateMergedDependenciesOfPackages($packages); + $this->assertTrue($dependencies['package1'] == '>=4.0.0-rc2'); + $this->assertTrue($dependencies['package2'] == '>=3.2.0-beta.11'); + $this->assertTrue($dependencies['package3'] == '>=3.2.0-alpha.2'); + $this->assertTrue($dependencies['package4'] == '>=3.2.0'); + + + ////////////////////////////////////////////////////////////////////////////////////////// + // Raise exception if no version is specified + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + + 'admin' => (object)[ + 'dependencies' => [ + ["name" => "errors", "version" => ">=4.0"], + ] + ], + 'test' => (object)[ + 'dependencies' => [ + ["name" => "errors", "version" => ">="] + ] + ], + + ]; + $this->installCommand->setGpm($this->gpm); + $packages = ['admin', 'test']; + + try { + $this->installCommand->calculateMergedDependenciesOfPackages($packages); + $this->fail("Expected Exception not thrown"); + } catch (Exception $e) { + $this->assertEquals(EXCEPTION_BAD_FORMAT, $e->getCode()); + $this->assertStringStartsWith("Bad format for version of dependency", $e->getMessage()); + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // Raise exception if incompatible versions are specified + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + 'admin' => (object)[ + 'dependencies' => [ + ["name" => "errors", "version" => "~4.0"], + ] + ], + 'test' => (object)[ + 'dependencies' => [ + ["name" => "errors", "version" => "~3.0"] + ] + ], + ]; + $this->installCommand->setGpm($this->gpm); + $packages = ['admin', 'test']; + + try { + $this->installCommand->calculateMergedDependenciesOfPackages($packages); + $this->fail("Expected Exception not thrown"); + } catch (Exception $e) { + $this->assertEquals(EXCEPTION_INCOMPATIBLE_VERSIONS, $e->getCode()); + $this->assertStringEndsWith("required in two incompatible versions", $e->getMessage()); + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // Test dependencies of dependencies + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + 'admin' => (object)[ + 'dependencies' => [ + ["name" => "grav", "version" => ">=1.0.10"], + ["name" => "form", "version" => "~2.0"], + ["name" => "login", "version" => ">=2.0"], + ["name" => "errors", "version" => "*"], + ["name" => "problems"], + ] + ], + 'login' => (object)[ + 'dependencies' => [ + ["name" => "antimatter", "version" => ">=1.0"] + ] + ], + 'grav', + 'antimatter' => (object)[ + 'dependencies' => [ + ["name" => "something", "version" => ">=3.2"] + ] + ] + + + ]; + $this->installCommand->setGpm($this->gpm); + + $packages = ['admin']; + + $dependencies = $this->installCommand->calculateMergedDependenciesOfPackages($packages); + + $this->assertTrue(is_array($dependencies)); + $this->assertSame(7, count($dependencies)); + + $this->assertSame('>=1.0.10', $dependencies['grav']); + $this->assertTrue(isset($dependencies['errors'])); + $this->assertTrue(isset($dependencies['problems'])); + $this->assertTrue(isset($dependencies['antimatter'])); + $this->assertTrue(isset($dependencies['something'])); + $this->assertSame('>=3.2', $dependencies['something']); + } + + public function testVersionFormatIsNextSignificantRelease() + { + $this->assertFalse($this->installCommand->versionFormatIsNextSignificantRelease('>=1.0')); + $this->assertFalse($this->installCommand->versionFormatIsNextSignificantRelease('>=2.3.4')); + $this->assertFalse($this->installCommand->versionFormatIsNextSignificantRelease('>=2.3.x')); + $this->assertFalse($this->installCommand->versionFormatIsNextSignificantRelease('1.0')); + $this->assertTrue($this->installCommand->versionFormatIsNextSignificantRelease('~2.3.x')); + $this->assertTrue($this->installCommand->versionFormatIsNextSignificantRelease('~2.0')); + } + + public function testVersionFormatIsEqualOrHigher() + { + $this->assertTrue($this->installCommand->versionFormatIsEqualOrHigher('>=1.0')); + $this->assertTrue($this->installCommand->versionFormatIsEqualOrHigher('>=2.3.4')); + $this->assertTrue($this->installCommand->versionFormatIsEqualOrHigher('>=2.3.x')); + $this->assertFalse($this->installCommand->versionFormatIsEqualOrHigher('~2.3.x')); + $this->assertFalse($this->installCommand->versionFormatIsEqualOrHigher('1.0')); + } + + public function testCheckNextSignificantReleasesAreCompatible() + { + /* + * ~1.0 is equivalent to >=1.0 < 2.0.0 + * ~1.2 is equivalent to >=1.2 <2.0.0 + * ~1.2.3 is equivalent to >=1.2.3 <1.3.0 + */ + $this->assertTrue($this->installCommand->checkNextSignificantReleasesAreCompatible('1.0', '1.2')); + $this->assertTrue($this->installCommand->checkNextSignificantReleasesAreCompatible('1.2', '1.0')); + $this->assertTrue($this->installCommand->checkNextSignificantReleasesAreCompatible('1.0', '1.0.10')); + $this->assertTrue($this->installCommand->checkNextSignificantReleasesAreCompatible('1.1', '1.1.10')); + $this->assertTrue($this->installCommand->checkNextSignificantReleasesAreCompatible('30.0', '30.10')); + $this->assertTrue($this->installCommand->checkNextSignificantReleasesAreCompatible('1.0', '1.1.10')); + $this->assertTrue($this->installCommand->checkNextSignificantReleasesAreCompatible('1.0', '1.8')); + $this->assertTrue($this->installCommand->checkNextSignificantReleasesAreCompatible('1.0.1', '1.1')); + + $this->assertFalse($this->installCommand->checkNextSignificantReleasesAreCompatible('1.0', '2.2')); + $this->assertFalse($this->installCommand->checkNextSignificantReleasesAreCompatible('0.9.99', '1.0.0')); + $this->assertFalse($this->installCommand->checkNextSignificantReleasesAreCompatible('0.9.99', '1.0.10')); + $this->assertFalse($this->installCommand->checkNextSignificantReleasesAreCompatible('0.9.99', '1.0.10.2')); + } + + + public function testCalculateVersionNumberFromDependencyVersion() + { + $this->assertSame('2.0', $this->installCommand->calculateVersionNumberFromDependencyVersion('>=2.0')); + $this->assertSame('2.0.2', $this->installCommand->calculateVersionNumberFromDependencyVersion('>=2.0.2')); + $this->assertSame('2.0.2', $this->installCommand->calculateVersionNumberFromDependencyVersion('~2.0.2')); + $this->assertSame('1', $this->installCommand->calculateVersionNumberFromDependencyVersion('~1')); + $this->assertSame(null, $this->installCommand->calculateVersionNumberFromDependencyVersion('')); + $this->assertSame(null, $this->installCommand->calculateVersionNumberFromDependencyVersion('*')); + $this->assertSame(null, $this->installCommand->calculateVersionNumberFromDependencyVersion('2.0.2')); + } +} \ No newline at end of file