InstallCommand.php 24 KB

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