GPM.php 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153
  1. <?php
  2. /**
  3. * @package Grav.Common.GPM
  4. *
  5. * @copyright Copyright (C) 2015 - 2018 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common\GPM;
  9. use Grav\Common\Grav;
  10. use Grav\Common\Filesystem\Folder;
  11. use Grav\Common\Inflector;
  12. use Grav\Common\Iterator;
  13. use Grav\Common\Utils;
  14. use RocketTheme\Toolbox\File\YamlFile;
  15. class GPM extends Iterator
  16. {
  17. /**
  18. * Local installed Packages
  19. * @var Local\Packages
  20. */
  21. private $installed;
  22. /**
  23. * Remote available Packages
  24. * @var Remote\Packages
  25. */
  26. private $repository;
  27. /**
  28. * @var Remote\GravCore
  29. */
  30. public $grav;
  31. /**
  32. * Internal cache
  33. * @var
  34. */
  35. protected $cache;
  36. protected $install_paths = [
  37. 'plugins' => 'user/plugins/%name%',
  38. 'themes' => 'user/themes/%name%',
  39. 'skeletons' => 'user/'
  40. ];
  41. /**
  42. * Creates a new GPM instance with Local and Remote packages available
  43. * @param boolean $refresh Applies to Remote Packages only and forces a refetch of data
  44. * @param callable $callback Either a function or callback in array notation
  45. */
  46. public function __construct($refresh = false, $callback = null)
  47. {
  48. $this->installed = new Local\Packages();
  49. try {
  50. $this->repository = new Remote\Packages($refresh, $callback);
  51. $this->grav = new Remote\GravCore($refresh, $callback);
  52. } catch (\Exception $e) {
  53. }
  54. }
  55. /**
  56. * Return the locally installed packages
  57. *
  58. * @return Local\Packages
  59. */
  60. public function getInstalled()
  61. {
  62. return $this->installed;
  63. }
  64. /**
  65. * Returns the Locally installable packages
  66. *
  67. * @param array $list_type_installed
  68. * @return array The installed packages
  69. */
  70. public function getInstallable($list_type_installed = ['plugins' => true, 'themes' => true])
  71. {
  72. $items = ['total' => 0];
  73. foreach ($list_type_installed as $type => $type_installed) {
  74. if ($type_installed === false) {
  75. continue;
  76. }
  77. $methodInstallableType = 'getInstalled' . ucfirst($type);
  78. $to_install = $this->$methodInstallableType();
  79. $items[$type] = $to_install;
  80. $items['total'] += count($to_install);
  81. }
  82. return $items;
  83. }
  84. /**
  85. * Returns the amount of locally installed packages
  86. * @return integer Amount of installed packages
  87. */
  88. public function countInstalled()
  89. {
  90. $installed = $this->getInstalled();
  91. return count($installed['plugins']) + count($installed['themes']);
  92. }
  93. /**
  94. * Return the instance of a specific Package
  95. *
  96. * @param string $slug The slug of the Package
  97. * @return Local\Package The instance of the Package
  98. */
  99. public function getInstalledPackage($slug)
  100. {
  101. if (isset($this->installed['plugins'][$slug])) {
  102. return $this->installed['plugins'][$slug];
  103. }
  104. if (isset($this->installed['themes'][$slug])) {
  105. return $this->installed['themes'][$slug];
  106. }
  107. return null;
  108. }
  109. /**
  110. * Return the instance of a specific Plugin
  111. * @param string $slug The slug of the Plugin
  112. * @return Local\Package The instance of the Plugin
  113. */
  114. public function getInstalledPlugin($slug)
  115. {
  116. return $this->installed['plugins'][$slug];
  117. }
  118. /**
  119. * Returns the Locally installed plugins
  120. * @return Iterator The installed plugins
  121. */
  122. public function getInstalledPlugins()
  123. {
  124. return $this->installed['plugins'];
  125. }
  126. /**
  127. * Checks if a Plugin is installed
  128. * @param string $slug The slug of the Plugin
  129. * @return boolean True if the Plugin has been installed. False otherwise
  130. */
  131. public function isPluginInstalled($slug)
  132. {
  133. return isset($this->installed['plugins'][$slug]);
  134. }
  135. public function isPluginInstalledAsSymlink($slug)
  136. {
  137. return $this->installed['plugins'][$slug]->symlink;
  138. }
  139. /**
  140. * Return the instance of a specific Theme
  141. * @param string $slug The slug of the Theme
  142. * @return Local\Package The instance of the Theme
  143. */
  144. public function getInstalledTheme($slug)
  145. {
  146. return $this->installed['themes'][$slug];
  147. }
  148. /**
  149. * Returns the Locally installed themes
  150. * @return Iterator The installed themes
  151. */
  152. public function getInstalledThemes()
  153. {
  154. return $this->installed['themes'];
  155. }
  156. /**
  157. * Checks if a Theme is installed
  158. * @param string $slug The slug of the Theme
  159. * @return boolean True if the Theme has been installed. False otherwise
  160. */
  161. public function isThemeInstalled($slug)
  162. {
  163. return isset($this->installed['themes'][$slug]);
  164. }
  165. /**
  166. * Returns the amount of updates available
  167. * @return integer Amount of available updates
  168. */
  169. public function countUpdates()
  170. {
  171. $count = 0;
  172. $count += count($this->getUpdatablePlugins());
  173. $count += count($this->getUpdatableThemes());
  174. return $count;
  175. }
  176. /**
  177. * Returns an array of Plugins and Themes that can be updated.
  178. * Plugins and Themes are extended with the `available` property that relies to the remote version
  179. * @param array $list_type_update specifies what type of package to update
  180. * @return array Array of updatable Plugins and Themes.
  181. * Format: ['total' => int, 'plugins' => array, 'themes' => array]
  182. */
  183. public function getUpdatable($list_type_update = ['plugins' => true, 'themes' => true])
  184. {
  185. $items = ['total' => 0];
  186. foreach ($list_type_update as $type => $type_updatable) {
  187. if ($type_updatable === false) {
  188. continue;
  189. }
  190. $methodUpdatableType = 'getUpdatable' . ucfirst($type);
  191. $to_update = $this->$methodUpdatableType();
  192. $items[$type] = $to_update;
  193. $items['total'] += count($to_update);
  194. }
  195. return $items;
  196. }
  197. /**
  198. * Returns an array of Plugins that can be updated.
  199. * The Plugins are extended with the `available` property that relies to the remote version
  200. * @return array Array of updatable Plugins
  201. */
  202. public function getUpdatablePlugins()
  203. {
  204. $items = [];
  205. $repository = $this->repository['plugins'];
  206. // local cache to speed things up
  207. if (isset($this->cache[__METHOD__])) {
  208. return $this->cache[__METHOD__];
  209. }
  210. foreach ($this->installed['plugins'] as $slug => $plugin) {
  211. if (!isset($repository[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {
  212. continue;
  213. }
  214. $local_version = $plugin->version ? $plugin->version : 'Unknown';
  215. $remote_version = $repository[$slug]->version;
  216. if (version_compare($local_version, $remote_version) < 0) {
  217. $repository[$slug]->available = $remote_version;
  218. $repository[$slug]->version = $local_version;
  219. $repository[$slug]->name = $repository[$slug]->name;
  220. $repository[$slug]->type = $repository[$slug]->release_type;
  221. $items[$slug] = $repository[$slug];
  222. }
  223. }
  224. $this->cache[__METHOD__] = $items;
  225. return $items;
  226. }
  227. /**
  228. * Get the latest release of a package from the GPM
  229. *
  230. * @param $package_name
  231. *
  232. * @return string|null
  233. */
  234. public function getLatestVersionOfPackage($package_name)
  235. {
  236. $repository = $this->repository['plugins'];
  237. if (isset($repository[$package_name])) {
  238. return $repository[$package_name]->available ?: $repository[$package_name]->version;
  239. }
  240. //Not a plugin, it's a theme?
  241. $repository = $this->repository['themes'];
  242. if (isset($repository[$package_name])) {
  243. return $repository[$package_name]->available ?: $repository[$package_name]->version;
  244. }
  245. return null;
  246. }
  247. /**
  248. * Check if a Plugin or Theme is updatable
  249. * @param string $slug The slug of the package
  250. * @return boolean True if updatable. False otherwise or if not found
  251. */
  252. public function isUpdatable($slug)
  253. {
  254. return $this->isPluginUpdatable($slug) || $this->isThemeUpdatable($slug);
  255. }
  256. /**
  257. * Checks if a Plugin is updatable
  258. * @param string $plugin The slug of the Plugin
  259. * @return boolean True if the Plugin is updatable. False otherwise
  260. */
  261. public function isPluginUpdatable($plugin)
  262. {
  263. return array_key_exists($plugin, (array)$this->getUpdatablePlugins());
  264. }
  265. /**
  266. * Returns an array of Themes that can be updated.
  267. * The Themes are extended with the `available` property that relies to the remote version
  268. * @return array Array of updatable Themes
  269. */
  270. public function getUpdatableThemes()
  271. {
  272. $items = [];
  273. $repository = $this->repository['themes'];
  274. // local cache to speed things up
  275. if (isset($this->cache[__METHOD__])) {
  276. return $this->cache[__METHOD__];
  277. }
  278. foreach ($this->installed['themes'] as $slug => $plugin) {
  279. if (!isset($repository[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {
  280. continue;
  281. }
  282. $local_version = $plugin->version ? $plugin->version : 'Unknown';
  283. $remote_version = $repository[$slug]->version;
  284. if (version_compare($local_version, $remote_version) < 0) {
  285. $repository[$slug]->available = $remote_version;
  286. $repository[$slug]->version = $local_version;
  287. $repository[$slug]->type = $repository[$slug]->release_type;
  288. $items[$slug] = $repository[$slug];
  289. }
  290. }
  291. $this->cache[__METHOD__] = $items;
  292. return $items;
  293. }
  294. /**
  295. * Checks if a Theme is Updatable
  296. * @param string $theme The slug of the Theme
  297. * @return boolean True if the Theme is updatable. False otherwise
  298. */
  299. public function isThemeUpdatable($theme)
  300. {
  301. return array_key_exists($theme, (array)$this->getUpdatableThemes());
  302. }
  303. /**
  304. * Get the release type of a package (stable / testing)
  305. *
  306. * @param $package_name
  307. *
  308. * @return string|null
  309. */
  310. public function getReleaseType($package_name)
  311. {
  312. $repository = $this->repository['plugins'];
  313. if (isset($repository[$package_name])) {
  314. return $repository[$package_name]->release_type;
  315. }
  316. //Not a plugin, it's a theme?
  317. $repository = $this->repository['themes'];
  318. if (isset($repository[$package_name])) {
  319. return $repository[$package_name]->release_type;
  320. }
  321. return null;
  322. }
  323. /**
  324. * Returns true if the package latest release is stable
  325. *
  326. * @param $package_name
  327. *
  328. * @return boolean
  329. */
  330. public function isStableRelease($package_name)
  331. {
  332. return $this->getReleaseType($package_name) === 'stable';
  333. }
  334. /**
  335. * Returns true if the package latest release is testing
  336. *
  337. * @param $package_name
  338. *
  339. * @return boolean
  340. */
  341. public function isTestingRelease($package_name)
  342. {
  343. $hasTesting = isset($this->getInstalledPackage($package_name)->testing);
  344. $testing = $hasTesting ? $this->getInstalledPackage($package_name)->testing : false;
  345. return $this->getReleaseType($package_name) === 'testing' || $testing;
  346. }
  347. /**
  348. * Returns a Plugin from the repository
  349. * @param string $slug The slug of the Plugin
  350. * @return mixed Package if found, NULL if not
  351. */
  352. public function getRepositoryPlugin($slug)
  353. {
  354. return @$this->repository['plugins'][$slug];
  355. }
  356. /**
  357. * Returns the list of Plugins available in the repository
  358. * @return Iterator The Plugins remotely available
  359. */
  360. public function getRepositoryPlugins()
  361. {
  362. return $this->repository['plugins'];
  363. }
  364. /**
  365. * Returns a Theme from the repository
  366. * @param string $slug The slug of the Theme
  367. * @return mixed Package if found, NULL if not
  368. */
  369. public function getRepositoryTheme($slug)
  370. {
  371. return @$this->repository['themes'][$slug];
  372. }
  373. /**
  374. * Returns the list of Themes available in the repository
  375. * @return Iterator The Themes remotely available
  376. */
  377. public function getRepositoryThemes()
  378. {
  379. return $this->repository['themes'];
  380. }
  381. /**
  382. * Returns the list of Plugins and Themes available in the repository
  383. * @return Remote\Packages Available Plugins and Themes
  384. * Format: ['plugins' => array, 'themes' => array]
  385. */
  386. public function getRepository()
  387. {
  388. return $this->repository;
  389. }
  390. /**
  391. * Searches for a Package in the repository
  392. * @param string $search Can be either the slug or the name
  393. * @param bool $ignore_exception True if should not fire an exception (for use in Twig)
  394. * @return Remote\Package|bool Package if found, FALSE if not
  395. */
  396. public function findPackage($search, $ignore_exception = false)
  397. {
  398. $search = strtolower($search);
  399. $found = $this->getRepositoryTheme($search);
  400. if ($found) {
  401. return $found;
  402. }
  403. $found = $this->getRepositoryPlugin($search);
  404. if ($found) {
  405. return $found;
  406. }
  407. $themes = $this->getRepositoryThemes();
  408. $plugins = $this->getRepositoryPlugins();
  409. if (!$themes && !$plugins) {
  410. if (!is_writable(ROOT_DIR . '/cache/gpm')) {
  411. throw new \RuntimeException("The cache/gpm folder is not writable. Please check the folder permissions.");
  412. }
  413. if ($ignore_exception) {
  414. return false;
  415. }
  416. throw new \RuntimeException("GPM not reachable. Please check your internet connection or check the Grav site is reachable");
  417. }
  418. if ($themes) {
  419. foreach ($themes as $slug => $theme) {
  420. if ($search == $slug || $search == $theme->name) {
  421. return $theme;
  422. }
  423. }
  424. }
  425. if ($plugins) {
  426. foreach ($plugins as $slug => $plugin) {
  427. if ($search == $slug || $search == $plugin->name) {
  428. return $plugin;
  429. }
  430. }
  431. }
  432. return false;
  433. }
  434. /**
  435. * Download the zip package via the URL
  436. *
  437. * @param $package_file
  438. * @param $tmp
  439. * @return null|string
  440. */
  441. public static function downloadPackage($package_file, $tmp)
  442. {
  443. $package = parse_url($package_file);
  444. $filename = basename($package['path']);
  445. if (Grav::instance()['config']->get('system.gpm.official_gpm_only') && $package['host'] !== 'getgrav.org') {
  446. throw new \RuntimeException("Only official GPM URLs are allowed. You can modify this behavior in the System configuration.");
  447. }
  448. $output = Response::get($package_file, []);
  449. if ($output) {
  450. Folder::mkdir($tmp);
  451. file_put_contents($tmp . DS . $filename, $output);
  452. return $tmp . DS . $filename;
  453. }
  454. return null;
  455. }
  456. /**
  457. * Copy the local zip package to tmp
  458. *
  459. * @param $package_file
  460. * @param $tmp
  461. * @return null|string
  462. */
  463. public static function copyPackage($package_file, $tmp)
  464. {
  465. $package_file = realpath($package_file);
  466. if (file_exists($package_file)) {
  467. $filename = basename($package_file);
  468. Folder::mkdir($tmp);
  469. copy(realpath($package_file), $tmp . DS . $filename);
  470. return $tmp . DS . $filename;
  471. }
  472. return null;
  473. }
  474. /**
  475. * Try to guess the package type from the source files
  476. *
  477. * @param $source
  478. * @return bool|string
  479. */
  480. public static function getPackageType($source)
  481. {
  482. $plugin_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Plugin/m';
  483. $theme_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Theme/m';
  484. if (
  485. file_exists($source . 'system/defines.php') &&
  486. file_exists($source . 'system/config/system.yaml')
  487. ) {
  488. return 'grav';
  489. } else {
  490. // must have a blueprint
  491. if (!file_exists($source . 'blueprints.yaml')) {
  492. return false;
  493. }
  494. // either theme or plugin
  495. $name = basename($source);
  496. if (Utils::contains($name, 'theme')) {
  497. return 'theme';
  498. } elseif (Utils::contains($name, 'plugin')) {
  499. return 'plugin';
  500. }
  501. foreach (glob($source . "*.php") as $filename) {
  502. $contents = file_get_contents($filename);
  503. if (preg_match($theme_regex, $contents)) {
  504. return 'theme';
  505. } elseif (preg_match($plugin_regex, $contents)) {
  506. return 'plugin';
  507. }
  508. }
  509. // Assume it's a theme
  510. return 'theme';
  511. }
  512. }
  513. /**
  514. * Try to guess the package name from the source files
  515. *
  516. * @param $source
  517. * @return bool|string
  518. */
  519. public static function getPackageName($source)
  520. {
  521. $ignore_yaml_files = ['blueprints', 'languages'];
  522. foreach (glob($source . "*.yaml") as $filename) {
  523. $name = strtolower(basename($filename, '.yaml'));
  524. if (in_array($name, $ignore_yaml_files)) {
  525. continue;
  526. }
  527. return $name;
  528. }
  529. return false;
  530. }
  531. /**
  532. * Find/Parse the blueprint file
  533. *
  534. * @param $source
  535. * @return array|bool
  536. */
  537. public static function getBlueprints($source)
  538. {
  539. $blueprint_file = $source . 'blueprints.yaml';
  540. if (!file_exists($blueprint_file)) {
  541. return false;
  542. }
  543. $file = YamlFile::instance($blueprint_file);
  544. $blueprint = (array)$file->content();
  545. $file->free();
  546. return $blueprint;
  547. }
  548. /**
  549. * Get the install path for a name and a particular type of package
  550. *
  551. * @param $type
  552. * @param $name
  553. * @return string
  554. */
  555. public static function getInstallPath($type, $name)
  556. {
  557. $locator = Grav::instance()['locator'];
  558. if ($type == 'theme') {
  559. $install_path = $locator->findResource('themes://', false) . DS . $name;
  560. } else {
  561. $install_path = $locator->findResource('plugins://', false) . DS . $name;
  562. }
  563. return $install_path;
  564. }
  565. /**
  566. * Searches for a list of Packages in the repository
  567. * @param array $searches An array of either slugs or names
  568. * @return array Array of found Packages
  569. * Format: ['total' => int, 'not_found' => array, <found-slugs>]
  570. */
  571. public function findPackages($searches = [])
  572. {
  573. $packages = ['total' => 0, 'not_found' => []];
  574. $inflector = new Inflector();
  575. foreach ($searches as $search) {
  576. $repository = '';
  577. // if this is an object, get the search data from the key
  578. if (is_object($search)) {
  579. $search = (array)$search;
  580. $key = key($search);
  581. $repository = $search[$key];
  582. $search = $key;
  583. }
  584. $found = $this->findPackage($search);
  585. if ($found) {
  586. // set override repository if provided
  587. if ($repository) {
  588. $found->override_repository = $repository;
  589. }
  590. if (!isset($packages[$found->package_type])) {
  591. $packages[$found->package_type] = [];
  592. }
  593. $packages[$found->package_type][$found->slug] = $found;
  594. $packages['total']++;
  595. } else {
  596. // make a best guess at the type based on the repo URL
  597. if (Utils::contains($repository, '-theme')) {
  598. $type = 'themes';
  599. } else {
  600. $type = 'plugins';
  601. }
  602. $not_found = new \stdClass();
  603. $not_found->name = $inflector->camelize($search);
  604. $not_found->slug = $search;
  605. $not_found->package_type = $type;
  606. $not_found->install_path = str_replace('%name%', $search, $this->install_paths[$type]);
  607. $not_found->override_repository = $repository;
  608. $packages['not_found'][$search] = $not_found;
  609. }
  610. }
  611. return $packages;
  612. }
  613. /**
  614. * Return the list of packages that have the passed one as dependency
  615. *
  616. * @param string $slug The slug name of the package
  617. *
  618. * @return array
  619. */
  620. public function getPackagesThatDependOnPackage($slug)
  621. {
  622. $plugins = $this->getInstalledPlugins();
  623. $themes = $this->getInstalledThemes();
  624. $packages = array_merge($plugins->toArray(), $themes->toArray());
  625. $dependent_packages = [];
  626. foreach ($packages as $package_name => $package) {
  627. if (isset($package['dependencies'])) {
  628. foreach ($package['dependencies'] as $dependency) {
  629. if (is_array($dependency) && isset($dependency['name'])) {
  630. $dependency = $dependency['name'];
  631. }
  632. if ($dependency == $slug) {
  633. $dependent_packages[] = $package_name;
  634. }
  635. }
  636. }
  637. }
  638. return $dependent_packages;
  639. }
  640. /**
  641. * Get the required version of a dependency of a package
  642. *
  643. * @param $package_slug
  644. * @param $dependency_slug
  645. *
  646. * @return mixed
  647. */
  648. public function getVersionOfDependencyRequiredByPackage($package_slug, $dependency_slug)
  649. {
  650. $dependencies = $this->getInstalledPackage($package_slug)->dependencies;
  651. foreach ($dependencies as $dependency) {
  652. if (isset($dependency[$dependency_slug])) {
  653. return $dependency[$dependency_slug];
  654. }
  655. }
  656. return null;
  657. }
  658. /**
  659. * Check the package identified by $slug can be updated to the version passed as argument.
  660. * Thrown an exception if it cannot be updated because another package installed requires it to be at an older version.
  661. *
  662. * @param string $slug
  663. * @param string $version_with_operator
  664. * @param array $ignore_packages_list
  665. *
  666. * @return bool
  667. * @throws \Exception
  668. */
  669. public function checkNoOtherPackageNeedsThisDependencyInALowerVersion(
  670. $slug,
  671. $version_with_operator,
  672. $ignore_packages_list
  673. ) {
  674. // check if any of the currently installed package need this in a lower version than the one we need. In case, abort and tell which package
  675. $dependent_packages = $this->getPackagesThatDependOnPackage($slug);
  676. $version = $this->calculateVersionNumberFromDependencyVersion($version_with_operator);
  677. if (count($dependent_packages)) {
  678. foreach ($dependent_packages as $dependent_package) {
  679. $other_dependency_version_with_operator = $this->getVersionOfDependencyRequiredByPackage($dependent_package,
  680. $slug);
  681. $other_dependency_version = $this->calculateVersionNumberFromDependencyVersion($other_dependency_version_with_operator);
  682. // check version is compatible with the one needed by the current package
  683. if ($this->versionFormatIsNextSignificantRelease($other_dependency_version_with_operator)) {
  684. $compatible = $this->checkNextSignificantReleasesAreCompatible($version,
  685. $other_dependency_version);
  686. if (!$compatible) {
  687. if (!in_array($dependent_package, $ignore_packages_list)) {
  688. throw new \Exception("Package <cyan>$slug</cyan> is required in an older version by package <cyan>$dependent_package</cyan>. This package needs a newer version, and because of this it cannot be installed. The <cyan>$dependent_package</cyan> package must be updated to use a newer release of <cyan>$slug</cyan>.",
  689. 2);
  690. }
  691. }
  692. }
  693. }
  694. }
  695. return true;
  696. }
  697. /**
  698. * Check the passed packages list can be updated
  699. *
  700. * @param $packages_names_list
  701. *
  702. * @throws \Exception
  703. */
  704. public function checkPackagesCanBeInstalled($packages_names_list)
  705. {
  706. foreach ($packages_names_list as $package_name) {
  707. $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package_name,
  708. $this->getLatestVersionOfPackage($package_name), $packages_names_list);
  709. }
  710. }
  711. /**
  712. * Fetch the dependencies, check the installed packages and return an array with
  713. * the list of packages with associated an information on what to do: install, update or ignore.
  714. *
  715. * `ignore` means the package is already installed and can be safely left as-is.
  716. * `install` means the package is not installed and must be installed.
  717. * `update` means the package is already installed and must be updated as a dependency needs a higher version.
  718. *
  719. * @param array $packages
  720. *
  721. * @return mixed
  722. * @throws \Exception
  723. */
  724. public function getDependencies($packages)
  725. {
  726. $dependencies = $this->calculateMergedDependenciesOfPackages($packages);
  727. foreach ($dependencies as $dependency_slug => $dependencyVersionWithOperator) {
  728. if (in_array($dependency_slug, $packages)) {
  729. unset($dependencies[$dependency_slug]);
  730. continue;
  731. }
  732. // Check PHP version
  733. if ($dependency_slug == 'php') {
  734. $current_php_version = phpversion();
  735. if (version_compare($this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator),
  736. $current_php_version) === 1
  737. ) {
  738. //Needs a Grav update first
  739. throw new \Exception("<red>One of the packages require PHP " . $dependencies['php'] . ". Please update PHP to resolve this");
  740. } else {
  741. unset($dependencies[$dependency_slug]);
  742. continue;
  743. }
  744. }
  745. //First, check for Grav dependency. If a dependency requires Grav > the current version, abort and tell.
  746. if ($dependency_slug == 'grav') {
  747. if (version_compare($this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator),
  748. GRAV_VERSION) === 1
  749. ) {
  750. //Needs a Grav update first
  751. throw new \Exception("<red>One of the packages require Grav " . $dependencies['grav'] . ". Please update Grav to the latest release.");
  752. } else {
  753. unset($dependencies[$dependency_slug]);
  754. continue;
  755. }
  756. }
  757. if ($this->isPluginInstalled($dependency_slug)) {
  758. if ($this->isPluginInstalledAsSymlink($dependency_slug)) {
  759. unset($dependencies[$dependency_slug]);
  760. continue;
  761. }
  762. $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);
  763. // get currently installed version
  764. $locator = Grav::instance()['locator'];
  765. $blueprints_path = $locator->findResource('plugins://' . $dependency_slug . DS . 'blueprints.yaml');
  766. $file = YamlFile::instance($blueprints_path);
  767. $package_yaml = $file->content();
  768. $file->free();
  769. $currentlyInstalledVersion = $package_yaml['version'];
  770. // if requirement is next significant release, check is compatible with currently installed version, might not be
  771. if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) {
  772. if ($this->firstVersionIsLower($dependencyVersion, $currentlyInstalledVersion)) {
  773. $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion,
  774. $currentlyInstalledVersion);
  775. if (!$compatible) {
  776. throw new \Exception('Dependency <cyan>' . $dependency_slug . '</cyan> is required in an older version than the one installed. This package must be updated. Please get in touch with its developer.',
  777. 2);
  778. }
  779. }
  780. }
  781. //if I already have the latest release, remove the dependency
  782. $latestRelease = $this->getLatestVersionOfPackage($dependency_slug);
  783. if ($this->firstVersionIsLower($latestRelease, $dependencyVersion)) {
  784. //throw an exception if a required version cannot be found in the GPM yet
  785. throw new \Exception('Dependency <cyan>' . $package_yaml['name'] . '</cyan> is required in version <cyan>' . $dependencyVersion . '</cyan> which is higher than the latest release, <cyan>' . $latestRelease . '</cyan>. Try running `bin/gpm -f index` to force a refresh of the GPM cache',
  786. 1);
  787. }
  788. if ($this->firstVersionIsLower($currentlyInstalledVersion, $dependencyVersion)) {
  789. $dependencies[$dependency_slug] = 'update';
  790. } else {
  791. if ($currentlyInstalledVersion == $latestRelease) {
  792. unset($dependencies[$dependency_slug]);
  793. } else {
  794. // an update is not strictly required mark as 'ignore'
  795. $dependencies[$dependency_slug] = 'ignore';
  796. }
  797. }
  798. } else {
  799. $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);
  800. // if requirement is next significant release, check is compatible with latest available version, might not be
  801. if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) {
  802. $latestVersionOfPackage = $this->getLatestVersionOfPackage($dependency_slug);
  803. if ($this->firstVersionIsLower($dependencyVersion, $latestVersionOfPackage)) {
  804. $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion,
  805. $latestVersionOfPackage);
  806. if (!$compatible) {
  807. throw new \Exception('Dependency <cyan>' . $dependency_slug . '</cyan> is required in an older version than the latest release available, and it cannot be installed. This package must be updated. Please get in touch with its developer.',
  808. 2);
  809. }
  810. }
  811. }
  812. $dependencies[$dependency_slug] = 'install';
  813. }
  814. }
  815. $dependencies_slugs = array_keys($dependencies);
  816. $this->checkNoOtherPackageNeedsTheseDependenciesInALowerVersion(array_merge($packages, $dependencies_slugs));
  817. return $dependencies;
  818. }
  819. public function checkNoOtherPackageNeedsTheseDependenciesInALowerVersion($dependencies_slugs)
  820. {
  821. foreach ($dependencies_slugs as $dependency_slug) {
  822. $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($dependency_slug,
  823. $this->getLatestVersionOfPackage($dependency_slug), $dependencies_slugs);
  824. }
  825. }
  826. private function firstVersionIsLower($firstVersion, $secondVersion)
  827. {
  828. return version_compare($firstVersion, $secondVersion) == -1;
  829. }
  830. /**
  831. * Calculates and merges the dependencies of a package
  832. *
  833. * @param string $packageName The package information
  834. *
  835. * @param array $dependencies The dependencies array
  836. *
  837. * @return array
  838. * @throws \Exception
  839. */
  840. private function calculateMergedDependenciesOfPackage($packageName, $dependencies)
  841. {
  842. $packageData = $this->findPackage($packageName);
  843. //Check for dependencies
  844. if (isset($packageData->dependencies)) {
  845. foreach ($packageData->dependencies as $dependency) {
  846. $current_package_name = $dependency['name'];
  847. if (isset($dependency['version'])) {
  848. $current_package_version_information = $dependency['version'];
  849. }
  850. if (!isset($dependencies[$current_package_name])) {
  851. // Dependency added for the first time
  852. if (!isset($current_package_version_information)) {
  853. $dependencies[$current_package_name] = '*';
  854. } else {
  855. $dependencies[$current_package_name] = $current_package_version_information;
  856. }
  857. //Factor in the package dependencies too
  858. $dependencies = $this->calculateMergedDependenciesOfPackage($current_package_name, $dependencies);
  859. } else {
  860. // Dependency already added by another package
  861. //if this package requires a version higher than the currently stored one, store this requirement instead
  862. if (isset($current_package_version_information) && $current_package_version_information !== '*') {
  863. $currently_stored_version_information = $dependencies[$current_package_name];
  864. $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currently_stored_version_information);
  865. $currently_stored_version_is_in_next_significant_release_format = false;
  866. if ($this->versionFormatIsNextSignificantRelease($currently_stored_version_information)) {
  867. $currently_stored_version_is_in_next_significant_release_format = true;
  868. }
  869. if (!$currently_stored_version_number) {
  870. $currently_stored_version_number = '*';
  871. }
  872. $current_package_version_number = $this->calculateVersionNumberFromDependencyVersion($current_package_version_information);
  873. if (!$current_package_version_number) {
  874. throw new \Exception('Bad format for version of dependency ' . $current_package_name . ' for package ' . $packageName,
  875. 1);
  876. }
  877. $current_package_version_is_in_next_significant_release_format = false;
  878. if ($this->versionFormatIsNextSignificantRelease($current_package_version_information)) {
  879. $current_package_version_is_in_next_significant_release_format = true;
  880. }
  881. //If I had stored '*', change right away with the more specific version required
  882. if ($currently_stored_version_number === '*') {
  883. $dependencies[$current_package_name] = $current_package_version_information;
  884. } else {
  885. if (!$currently_stored_version_is_in_next_significant_release_format && !$current_package_version_is_in_next_significant_release_format) {
  886. //Comparing versions equals or higher, a simple version_compare is enough
  887. if (version_compare($currently_stored_version_number,
  888. $current_package_version_number) == -1
  889. ) { //Current package version is higher
  890. $dependencies[$current_package_name] = $current_package_version_information;
  891. }
  892. } else {
  893. $compatible = $this->checkNextSignificantReleasesAreCompatible($currently_stored_version_number,
  894. $current_package_version_number);
  895. if (!$compatible) {
  896. throw new \Exception('Dependency ' . $current_package_name . ' is required in two incompatible versions',
  897. 2);
  898. }
  899. }
  900. }
  901. }
  902. }
  903. }
  904. }
  905. return $dependencies;
  906. }
  907. /**
  908. * Calculates and merges the dependencies of the passed packages
  909. *
  910. * @param array $packages
  911. *
  912. * @return mixed
  913. * @throws \Exception
  914. */
  915. public function calculateMergedDependenciesOfPackages($packages)
  916. {
  917. $dependencies = [];
  918. foreach ($packages as $package) {
  919. $dependencies = $this->calculateMergedDependenciesOfPackage($package, $dependencies);
  920. }
  921. return $dependencies;
  922. }
  923. /**
  924. * Returns the actual version from a dependency version string.
  925. * Examples:
  926. * $versionInformation == '~2.0' => returns '2.0'
  927. * $versionInformation == '>=2.0.2' => returns '2.0.2'
  928. * $versionInformation == '2.0.2' => returns '2.0.2'
  929. * $versionInformation == '*' => returns null
  930. * $versionInformation == '' => returns null
  931. *
  932. * @param string $version
  933. *
  934. * @return null|string
  935. */
  936. public function calculateVersionNumberFromDependencyVersion($version)
  937. {
  938. if ($version == '*') {
  939. return null;
  940. } elseif ($version == '') {
  941. return null;
  942. } elseif ($this->versionFormatIsNextSignificantRelease($version)) {
  943. return trim(substr($version, 1));
  944. } elseif ($this->versionFormatIsEqualOrHigher($version)) {
  945. return trim(substr($version, 2));
  946. } else {
  947. return $version;
  948. }
  949. }
  950. /**
  951. * Check if the passed version information contains next significant release (tilde) operator
  952. *
  953. * Example: returns true for $version: '~2.0'
  954. *
  955. * @param $version
  956. *
  957. * @return bool
  958. */
  959. public function versionFormatIsNextSignificantRelease($version)
  960. {
  961. return substr($version, 0, 1) == '~';
  962. }
  963. /**
  964. * Check if the passed version information contains equal or higher operator
  965. *
  966. * Example: returns true for $version: '>=2.0'
  967. *
  968. * @param $version
  969. *
  970. * @return bool
  971. */
  972. public function versionFormatIsEqualOrHigher($version)
  973. {
  974. return substr($version, 0, 2) == '>=';
  975. }
  976. /**
  977. * Check if two releases are compatible by next significant release
  978. *
  979. * ~1.2 is equivalent to >=1.2 <2.0.0
  980. * ~1.2.3 is equivalent to >=1.2.3 <1.3.0
  981. *
  982. * In short, allows the last digit specified to go up
  983. *
  984. * @param string $version1 the version string (e.g. '2.0.0' or '1.0')
  985. * @param string $version2 the version string (e.g. '2.0.0' or '1.0')
  986. *
  987. * @return bool
  988. */
  989. public function checkNextSignificantReleasesAreCompatible($version1, $version2)
  990. {
  991. $version1array = explode('.', $version1);
  992. $version2array = explode('.', $version2);
  993. if (count($version1array) > count($version2array)) {
  994. list($version1array, $version2array) = [$version2array, $version1array];
  995. }
  996. $i = 0;
  997. while ($i < count($version1array) - 1) {
  998. if ($version1array[$i] != $version2array[$i]) {
  999. return false;
  1000. }
  1001. $i++;
  1002. }
  1003. return true;
  1004. }
  1005. }