InstallCommand.php 24 KB

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