InstallCommand.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. <?php
  2. /**
  3. * @package Grav.Console
  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\Console\Gpm;
  9. use Grav\Common\Filesystem\Folder;
  10. use Grav\Common\GPM\GPM;
  11. use Grav\Common\GPM\Installer;
  12. use Grav\Common\GPM\Licenses;
  13. use Grav\Common\GPM\Response;
  14. use Grav\Common\GPM\Remote\Package as Package;
  15. use Grav\Common\Grav;
  16. use Grav\Common\Utils;
  17. use Grav\Console\ConsoleCommand;
  18. use Symfony\Component\Console\Input\InputArgument;
  19. use Symfony\Component\Console\Input\InputOption;
  20. use Symfony\Component\Console\Question\ConfirmationQuestion;
  21. define('GIT_REGEX', '/http[s]?:\/\/(?:.*@)?(github|bitbucket)(?:.org|.com)\/.*\/(.*)/');
  22. class InstallCommand extends ConsoleCommand
  23. {
  24. /** @var */
  25. protected $data;
  26. /** @var GPM */
  27. protected $gpm;
  28. /** @var */
  29. protected $destination;
  30. /** @var */
  31. protected $file;
  32. /** @var */
  33. protected $tmp;
  34. /** @var */
  35. protected $local_config;
  36. /** @var bool */
  37. protected $use_symlinks;
  38. /** @var array */
  39. protected $demo_processing = [];
  40. protected $all_yes;
  41. /**
  42. *
  43. */
  44. protected function configure()
  45. {
  46. $this
  47. ->setName("install")
  48. ->addOption(
  49. 'force',
  50. 'f',
  51. InputOption::VALUE_NONE,
  52. 'Force re-fetching the data from remote'
  53. )
  54. ->addOption(
  55. 'all-yes',
  56. 'y',
  57. InputOption::VALUE_NONE,
  58. 'Assumes yes (or best approach) instead of prompting'
  59. )
  60. ->addOption(
  61. 'destination',
  62. 'd',
  63. InputOption::VALUE_OPTIONAL,
  64. 'The destination where the package should be installed at. By default this would be where the grav instance has been launched from',
  65. GRAV_ROOT
  66. )
  67. ->addArgument(
  68. 'package',
  69. InputArgument::IS_ARRAY | InputArgument::REQUIRED,
  70. 'Package(s) to install. Use "bin/gpm index" to list packages. Use "bin/gpm direct-install" to install a specific version'
  71. )
  72. ->setDescription("Performs the installation of plugins and themes")
  73. ->setHelp('The <info>install</info> command allows to install plugins and themes');
  74. }
  75. /**
  76. * Allows to set the GPM object, used for testing the class
  77. *
  78. * @param $gpm
  79. */
  80. public function setGpm($gpm)
  81. {
  82. $this->gpm = $gpm;
  83. }
  84. /**
  85. * @return bool
  86. */
  87. protected function serve()
  88. {
  89. $this->gpm = new GPM($this->input->getOption('force'));
  90. $this->all_yes = $this->input->getOption('all-yes');
  91. $this->displayGPMRelease();
  92. $this->destination = realpath($this->input->getOption('destination'));
  93. $packages = array_map('strtolower', $this->input->getArgument('package'));
  94. $this->data = $this->gpm->findPackages($packages);
  95. $this->loadLocalConfig();
  96. if (
  97. !Installer::isGravInstance($this->destination) ||
  98. !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK])
  99. ) {
  100. $this->output->writeln("<red>ERROR</red>: " . Installer::lastErrorMsg());
  101. exit;
  102. }
  103. $this->output->writeln('');
  104. if (!$this->data['total']) {
  105. $this->output->writeln("Nothing to install.");
  106. $this->output->writeln('');
  107. exit;
  108. }
  109. if (count($this->data['not_found'])) {
  110. $this->output->writeln("These packages were not found on Grav: <red>" . implode('</red>, <red>',
  111. array_keys($this->data['not_found'])) . "</red>");
  112. }
  113. unset($this->data['not_found']);
  114. unset($this->data['total']);
  115. if (isset($this->local_config)) {
  116. // Symlinks available, ask if Grav should use them
  117. $this->use_symlinks = false;
  118. $helper = $this->getHelper('question');
  119. $question = new ConfirmationQuestion('Should Grav use the symlinks if available? [y|N] ', false);
  120. $answer = $this->all_yes ? false : $helper->ask($this->input, $this->output, $question);
  121. if ($answer) {
  122. $this->use_symlinks = true;
  123. }
  124. }
  125. $this->output->writeln('');
  126. try {
  127. $dependencies = $this->gpm->getDependencies($packages);
  128. } catch (\Exception $e) {
  129. //Error out if there are incompatible packages requirements and tell which ones, and what to do
  130. //Error out if there is any error in parsing the dependencies and their versions, and tell which one is broken
  131. $this->output->writeln("<red>" . $e->getMessage() . "</red>");
  132. return false;
  133. }
  134. if ($dependencies) {
  135. try {
  136. $this->installDependencies($dependencies, 'install', "The following dependencies need to be installed...");
  137. $this->installDependencies($dependencies, 'update', "The following dependencies need to be updated...");
  138. $this->installDependencies($dependencies, 'ignore', "The following dependencies can be updated as there is a newer version, but it's not mandatory...", false);
  139. } catch (\Exception $e) {
  140. $this->output->writeln("<red>Installation aborted</red>");
  141. return false;
  142. }
  143. $this->output->writeln("<green>Dependencies are OK</green>");
  144. $this->output->writeln("");
  145. }
  146. //We're done installing dependencies. Install the actual packages
  147. foreach ($this->data as $data) {
  148. foreach ($data as $package_name => $package) {
  149. if (array_key_exists($package_name, $dependencies)) {
  150. $this->output->writeln("<green>Package " . $package_name . " already installed as dependency</green>");
  151. } else {
  152. $is_valid_destination = Installer::isValidDestination($this->destination . DS . $package->install_path);
  153. if ($is_valid_destination || Installer::lastErrorCode() == Installer::NOT_FOUND) {
  154. $this->processPackage($package, false);
  155. } else {
  156. if (Installer::lastErrorCode() == Installer::EXISTS) {
  157. try {
  158. $this->askConfirmationIfMajorVersionUpdated($package);
  159. $this->gpm->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package->slug, $package->available, array_keys($data));
  160. } catch (\Exception $e) {
  161. $this->output->writeln("<red>" . $e->getMessage() . "</red>");
  162. return false;
  163. }
  164. $helper = $this->getHelper('question');
  165. $question = new ConfirmationQuestion("The package <cyan>$package_name</cyan> is already installed, overwrite? [y|N] ", false);
  166. $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
  167. if ($answer) {
  168. $is_update = true;
  169. $this->processPackage($package, $is_update);
  170. } else {
  171. $this->output->writeln("<yellow>Package " . $package_name . " not overwritten</yellow>");
  172. }
  173. } else {
  174. if (Installer::lastErrorCode() == Installer::IS_LINK) {
  175. $this->output->writeln("<red>Cannot overwrite existing symlink for </red><cyan>$package_name</cyan>");
  176. $this->output->writeln("");
  177. }
  178. }
  179. }
  180. }
  181. }
  182. }
  183. if (count($this->demo_processing) > 0) {
  184. foreach ($this->demo_processing as $package) {
  185. $this->installDemoContent($package);
  186. }
  187. }
  188. // clear cache after successful upgrade
  189. $this->clearCache();
  190. return true;
  191. }
  192. /**
  193. * If the package is updated from an older major release, show warning and ask confirmation
  194. *
  195. * @param $package
  196. */
  197. public function askConfirmationIfMajorVersionUpdated($package)
  198. {
  199. $helper = $this->getHelper('question');
  200. $package_name = $package->name;
  201. $new_version = $package->available ? $package->available : $this->gpm->getLatestVersionOfPackage($package->slug);
  202. $old_version = $package->version;
  203. $major_version_changed = explode('.', $new_version)[0] !== explode('.', $old_version)[0];
  204. if ($major_version_changed) {
  205. if ($this->all_yes) {
  206. $this->output->writeln("The package <cyan>$package_name</cyan> will be updated to a new major version <green>$new_version</green>, from <magenta>$old_version</magenta>");
  207. return;
  208. }
  209. $question = new ConfirmationQuestion("The package <cyan>$package_name</cyan> will be updated to a new major version <green>$new_version</green>, from <magenta>$old_version</magenta>. Be sure to read what changed with the new major release. Continue? [y|N] ", false);
  210. if (!$helper->ask($this->input, $this->output, $question)) {
  211. $this->output->writeln("<yellow>Package " . $package_name . " not updated</yellow>");
  212. exit;
  213. }
  214. }
  215. }
  216. /**
  217. * Given a $dependencies list, filters their type according to $type and
  218. * shows $message prior to listing them to the user. Then asks the user a confirmation prior
  219. * to installing them.
  220. *
  221. * @param array $dependencies The dependencies array
  222. * @param string $type The type of dependency to show: install, update, ignore
  223. * @param string $message A message to be shown prior to listing the dependencies
  224. * @param bool $required A flag that determines if the installation is required or optional
  225. *
  226. * @throws \Exception
  227. */
  228. public function installDependencies($dependencies, $type, $message, $required = true)
  229. {
  230. $packages = array_filter($dependencies, function ($action) use ($type) { return $action === $type; });
  231. if (count($packages) > 0) {
  232. $this->output->writeln($message);
  233. foreach ($packages as $dependencyName => $dependencyVersion) {
  234. $this->output->writeln(" |- Package <cyan>" . $dependencyName . "</cyan>");
  235. }
  236. $this->output->writeln("");
  237. $helper = $this->getHelper('question');
  238. if ($type == 'install') {
  239. $questionAction = 'Install';
  240. } else {
  241. $questionAction = 'Update';
  242. }
  243. if (count($packages) == 1) {
  244. $questionArticle = 'this';
  245. } else {
  246. $questionArticle = 'these';
  247. }
  248. if (count($packages) == 1) {
  249. $questionNoun = 'package';
  250. } else {
  251. $questionNoun = 'packages';
  252. }
  253. $question = new ConfirmationQuestion("$questionAction $questionArticle $questionNoun? [Y|n] ", true);
  254. $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
  255. if ($answer) {
  256. foreach ($packages as $dependencyName => $dependencyVersion) {
  257. $package = $this->gpm->findPackage($dependencyName);
  258. $this->processPackage($package, ($type == 'update') ? true : false);
  259. }
  260. $this->output->writeln('');
  261. } else {
  262. if ($required) {
  263. throw new \Exception();
  264. }
  265. }
  266. }
  267. }
  268. /**
  269. * @param $package
  270. * @param bool $is_update True if the package is an update
  271. */
  272. private function processPackage($package, $is_update = false)
  273. {
  274. if (!$package) {
  275. $this->output->writeln("<red>Package not found on the GPM!</red> ");
  276. $this->output->writeln('');
  277. return;
  278. }
  279. $symlink = false;
  280. if ($this->use_symlinks) {
  281. if ($this->getSymlinkSource($package) || !isset($package->version)) {
  282. $symlink = true;
  283. }
  284. }
  285. $symlink ? $this->processSymlink($package) : $this->processGpm($package, $is_update);
  286. $this->processDemo($package);
  287. }
  288. /**
  289. * Add package to the queue to process the demo content, if demo content exists
  290. *
  291. * @param $package
  292. */
  293. private function processDemo($package)
  294. {
  295. $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo';
  296. if (file_exists($demo_dir)) {
  297. $this->demo_processing[] = $package;
  298. }
  299. }
  300. /**
  301. * Prompt to install the demo content of a package
  302. *
  303. * @param $package
  304. */
  305. private function installDemoContent($package)
  306. {
  307. $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo';
  308. if (file_exists($demo_dir)) {
  309. $dest_dir = $this->destination . DS . 'user';
  310. $pages_dir = $dest_dir . DS . 'pages';
  311. // Demo content exists, prompt to install it.
  312. $this->output->writeln("<white>Attention: </white><cyan>" . $package->name . "</cyan> contains demo content");
  313. $helper = $this->getHelper('question');
  314. $question = new ConfirmationQuestion('Do you wish to install this demo content? [y|N] ', false);
  315. $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
  316. if (!$answer) {
  317. $this->output->writeln(" '- <red>Skipped!</red> ");
  318. $this->output->writeln('');
  319. return;
  320. }
  321. // if pages folder exists in demo
  322. if (file_exists($demo_dir . DS . 'pages')) {
  323. $pages_backup = 'pages.' . date('m-d-Y-H-i-s');
  324. $question = new ConfirmationQuestion('This will backup your current `user/pages` folder to `user/' . $pages_backup . '`, continue? [y|N]', false);
  325. $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
  326. if (!$answer) {
  327. $this->output->writeln(" '- <red>Skipped!</red> ");
  328. $this->output->writeln('');
  329. return;
  330. }
  331. // backup current pages folder
  332. if (file_exists($dest_dir)) {
  333. if (rename($pages_dir, $dest_dir . DS . $pages_backup)) {
  334. $this->output->writeln(" |- Backing up pages... <green>ok</green>");
  335. } else {
  336. $this->output->writeln(" |- Backing up pages... <red>failed</red>");
  337. }
  338. }
  339. }
  340. // Confirmation received, copy over the data
  341. $this->output->writeln(" |- Installing demo content... <green>ok</green> ");
  342. Folder::rcopy($demo_dir, $dest_dir);
  343. $this->output->writeln(" '- <green>Success!</green> ");
  344. $this->output->writeln('');
  345. }
  346. }
  347. /**
  348. * @param $package
  349. *
  350. * @return array|bool
  351. */
  352. private function getGitRegexMatches($package)
  353. {
  354. if (isset($package->repository)) {
  355. $repository = $package->repository;
  356. } else {
  357. return false;
  358. }
  359. preg_match(GIT_REGEX, $repository, $matches);
  360. return $matches;
  361. }
  362. /**
  363. * @param $package
  364. *
  365. * @return bool|string
  366. */
  367. private function getSymlinkSource($package)
  368. {
  369. $matches = $this->getGitRegexMatches($package);
  370. foreach ($this->local_config as $paths) {
  371. if (Utils::endsWith($matches[2], '.git')) {
  372. $repo_dir = preg_replace('/\.git$/', '', $matches[2]);
  373. } else {
  374. $repo_dir = $matches[2];
  375. }
  376. $paths = (array) $paths;
  377. foreach ($paths as $repo) {
  378. $path = rtrim($repo, '/') . '/' . $repo_dir;
  379. if (file_exists($path)) {
  380. return $path;
  381. }
  382. }
  383. }
  384. return false;
  385. }
  386. /**
  387. * @param $package
  388. */
  389. private function processSymlink($package)
  390. {
  391. exec('cd ' . $this->destination);
  392. $to = $this->destination . DS . $package->install_path;
  393. $from = $this->getSymlinkSource($package);
  394. $this->output->writeln("Preparing to Symlink <cyan>" . $package->name . "</cyan>");
  395. $this->output->write(" |- Checking source... ");
  396. if (file_exists($from)) {
  397. $this->output->writeln("<green>ok</green>");
  398. $this->output->write(" |- Checking destination... ");
  399. $checks = $this->checkDestination($package);
  400. if (!$checks) {
  401. $this->output->writeln(" '- <red>Installation failed or aborted.</red>");
  402. $this->output->writeln('');
  403. } else {
  404. if (file_exists($to)) {
  405. $this->output->writeln(" '- <red>Symlink cannot overwrite an existing package, please remove first</red>");
  406. $this->output->writeln('');
  407. } else {
  408. symlink($from, $to);
  409. // extra white spaces to clear out the buffer properly
  410. $this->output->writeln(" |- Symlinking package... <green>ok</green> ");
  411. $this->output->writeln(" '- <green>Success!</green> ");
  412. $this->output->writeln('');
  413. }
  414. }
  415. return;
  416. }
  417. $this->output->writeln("<red>not found!</red>");
  418. $this->output->writeln(" '- <red>Installation failed or aborted.</red>");
  419. }
  420. /**
  421. * @param $package
  422. * @param bool $is_update
  423. *
  424. * @return bool
  425. */
  426. private function processGpm($package, $is_update = false)
  427. {
  428. $version = isset($package->available) ? $package->available : $package->version;
  429. $license = Licenses::get($package->slug);
  430. $this->output->writeln("Preparing to install <cyan>" . $package->name . "</cyan> [v" . $version . "]");
  431. $this->output->write(" |- Downloading package... 0%");
  432. $this->file = $this->downloadPackage($package, $license);
  433. if (!$this->file) {
  434. $this->output->writeln(" '- <red>Installation failed or aborted.</red>");
  435. $this->output->writeln('');
  436. return false;
  437. }
  438. $this->output->write(" |- Checking destination... ");
  439. $checks = $this->checkDestination($package);
  440. if (!$checks) {
  441. $this->output->writeln(" '- <red>Installation failed or aborted.</red>");
  442. $this->output->writeln('');
  443. } else {
  444. $this->output->write(" |- Installing package... ");
  445. $installation = $this->installPackage($package, $is_update);
  446. if (!$installation) {
  447. $this->output->writeln(" '- <red>Installation failed or aborted.</red>");
  448. $this->output->writeln('');
  449. } else {
  450. $this->output->writeln(" '- <green>Success!</green> ");
  451. $this->output->writeln('');
  452. return true;
  453. }
  454. }
  455. return false;
  456. }
  457. /**
  458. * @param Package $package
  459. *
  460. * @param string $license
  461. *
  462. * @return string
  463. */
  464. private function downloadPackage($package, $license = null)
  465. {
  466. $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);
  467. $this->tmp = $tmp_dir . '/Grav-' . uniqid();
  468. $filename = $package->slug . basename($package->zipball_url);
  469. $filename = preg_replace('/[\\\\\/:"*?&<>|]+/mi', '-', $filename);
  470. $query = '';
  471. if ($package->premium) {
  472. $query = \json_encode(array_merge(
  473. $package->premium,
  474. [
  475. 'slug' => $package->slug,
  476. 'filename' => $package->premium['filename'],
  477. 'license_key' => $license
  478. ]
  479. ));
  480. $query = '?d=' . base64_encode($query);
  481. }
  482. try {
  483. $output = Response::get($package->zipball_url . $query, [], [$this, 'progress']);
  484. } catch (\Exception $e) {
  485. $error = str_replace("\n", "\n | '- ", $e->getMessage());
  486. $this->output->write("\x0D");
  487. // extra white spaces to clear out the buffer properly
  488. $this->output->writeln(" |- Downloading package... <red>error</red> ");
  489. $this->output->writeln(" | '- " . $error);
  490. return false;
  491. }
  492. Folder::mkdir($this->tmp);
  493. $this->output->write("\x0D");
  494. $this->output->write(" |- Downloading package... 100%");
  495. $this->output->writeln('');
  496. file_put_contents($this->tmp . DS . $filename, $output);
  497. return $this->tmp . DS . $filename;
  498. }
  499. /**
  500. * @param $package
  501. *
  502. * @return bool
  503. */
  504. private function checkDestination($package)
  505. {
  506. $question_helper = $this->getHelper('question');
  507. Installer::isValidDestination($this->destination . DS . $package->install_path);
  508. if (Installer::lastErrorCode() == Installer::IS_LINK) {
  509. $this->output->write("\x0D");
  510. $this->output->writeln(" |- Checking destination... <yellow>symbolic link</yellow>");
  511. if ($this->all_yes) {
  512. $this->output->writeln(" | '- <yellow>Skipped automatically.</yellow>");
  513. return false;
  514. }
  515. $question = new ConfirmationQuestion(" | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ",
  516. false);
  517. $answer = $question_helper->ask($this->input, $this->output, $question);
  518. if (!$answer) {
  519. $this->output->writeln(" | '- <red>You decided to not delete the symlink automatically.</red>");
  520. return false;
  521. } else {
  522. unlink($this->destination . DS . $package->install_path);
  523. }
  524. }
  525. $this->output->write("\x0D");
  526. $this->output->writeln(" |- Checking destination... <green>ok</green>");
  527. return true;
  528. }
  529. /**
  530. * Install a package
  531. *
  532. * @param Package $package
  533. * @param bool $is_update True if it's an update. False if it's an install
  534. *
  535. * @return bool
  536. */
  537. private function installPackage($package, $is_update = false)
  538. {
  539. $type = $package->package_type;
  540. Installer::install($this->file, $this->destination, ['install_path' => $package->install_path, 'theme' => (($type == 'themes')), 'is_update' => $is_update]);
  541. $error_code = Installer::lastErrorCode();
  542. Folder::delete($this->tmp);
  543. if ($error_code) {
  544. $this->output->write("\x0D");
  545. // extra white spaces to clear out the buffer properly
  546. $this->output->writeln(" |- Installing package... <red>error</red> ");
  547. $this->output->writeln(" | '- " . Installer::lastErrorMsg());
  548. return false;
  549. }
  550. $message = Installer::getMessage();
  551. if ($message) {
  552. $this->output->write("\x0D");
  553. // extra white spaces to clear out the buffer properly
  554. $this->output->writeln(" |- " . $message);
  555. }
  556. $this->output->write("\x0D");
  557. // extra white spaces to clear out the buffer properly
  558. $this->output->writeln(" |- Installing package... <green>ok</green> ");
  559. return true;
  560. }
  561. /**
  562. * @param $progress
  563. */
  564. public function progress($progress)
  565. {
  566. $this->output->write("\x0D");
  567. $this->output->write(" |- Downloading package... " . str_pad($progress['percent'], 5, " ",
  568. STR_PAD_LEFT) . '%');
  569. }
  570. }