1<?php 2// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project 3// 4// All Rights Reserved. See copyright.txt for details and a complete list of authors. 5// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details. 6// $Id$ 7 8namespace Tiki\Package; 9 10use Symfony\Component\Process\Exception\ExceptionInterface as ProcessExceptionInterface; 11use Symfony\Component\Process\Process; 12 13/** 14 * Wrapper to composer.phar to allow installation of packages from the admin interface 15 */ 16class ComposerCli 17{ 18 19 const COMPOSER_URL = 'https://getcomposer.org/installer'; 20 const COMPOSER_SETUP = 'temp/composer-setup.php'; 21 const COMPOSER_PHAR = 'temp/composer.phar'; 22 const COMPOSER_CONFIG = 'composer.json'; 23 const COMPOSER_LOCK = 'composer.lock'; 24 const COMPOSER_HOME = 'temp/composer'; 25 const PHP_COMMAND_NAMES = [ 26 'php', 27 // TODO: Dynamically build version part from running PHP version 28 'php56', 29 'php5.6', 30 'php5.6-cli', 31 ]; 32 const PHP_MIN_VERSION = '7.2.0'; 33 34 const FALLBACK_COMPOSER_JSON = '{"minimum-stability": "stable","config": {"process-timeout": 5000,"bin-dir": "bin","component-dir": "vendor/components"}, "repositories": [{"type": "composer","url": "https://composer.tiki.org"}]}'; 35 36 /** 37 * @var string path to the base folder from tiki 38 */ 39 protected $basePath = ''; 40 41 /** 42 * @var string path to the folder that will be used 43 */ 44 protected $workingPath = ''; 45 46 /** 47 * @var string|null Will hold the php bin detected 48 */ 49 protected $phpCli = null; 50 51 /** 52 * @var int timeout in seconds waiting for composer commands to execute, default 5 min (300s) 53 */ 54 protected $timeout = 300; 55 56 /** 57 * @var null|array Result from last execution null if never executed, else an array with command, output, errors and code 58 */ 59 protected $lastResult = null; 60 61 /** 62 * ComposerCli constructor. 63 * @param string $basePath 64 * @param string $workingPath 65 */ 66 public function __construct($basePath, $workingPath = null) 67 { 68 $basePath = rtrim($basePath, '/'); 69 if ($basePath) { 70 $this->basePath = $basePath . '/'; 71 } 72 73 if (is_null($workingPath)) { 74 $this->workingPath = $this->basePath; 75 } else { 76 $workingPath = rtrim($workingPath, '/'); 77 if ($workingPath) { 78 $this->workingPath = $workingPath . '/'; 79 } 80 } 81 } 82 83 /** 84 * Returns the the current working path location 85 * @return string 86 */ 87 public function getWorkingPath() 88 { 89 return $this->workingPath; 90 } 91 92 /** 93 * Sets the current working path location 94 * @return string 95 */ 96 public function setWorkingPath($path) 97 { 98 $this->workingPath = $path; 99 } 100 101 /** 102 * Returns the location of the composer.json file 103 * @return string 104 */ 105 public function getComposerConfigFilePath() 106 { 107 return $this->workingPath . self::COMPOSER_CONFIG; 108 } 109 110 /** 111 * Returns the location of the composer.lock file 112 * @return string 113 */ 114 public function getComposerLockFilePath() 115 { 116 return $this->workingPath . self::COMPOSER_LOCK; 117 } 118 119 /** 120 * Return the composer.json parsed as array, false if the file can not be processed 121 * @return bool|array 122 */ 123 protected function getComposerConfig() 124 { 125 if (! $this->checkConfigExists()) { 126 return false; 127 } 128 $content = json_decode(file_get_contents($this->getComposerConfigFilePath()), true); 129 130 return $content; 131 } 132 133 /** 134 * Return the composer.json parsed as array, or a default version for the composer.json if do not exists 135 * First try to load the dist version, if not use a hardcoded version with the minimal setup 136 * @return array|bool 137 */ 138 public function getComposerConfigOrDefault() 139 { 140 $content = $this->getComposerConfig(); 141 if (! is_array($content)) { 142 $content = []; 143 } 144 145 $distFile = $this->workingPath . self::COMPOSER_CONFIG . '.dist'; 146 $distContent = []; 147 if (file_exists($distFile)) { 148 $distContent = json_decode(file_get_contents($distFile), true); 149 if (! is_array($distContent)) { 150 $distContent = []; 151 } 152 } 153 154 if (empty($distContent)) { 155 $distContent = json_decode(self::FALLBACK_COMPOSER_JSON, true); 156 } 157 158 return array_merge($distContent, $content); 159 } 160 161 /** 162 * Return the location of the composer.phar file (in the temp folder, as downloaded by setup.sh) 163 * @return string 164 */ 165 public function getComposerPharPath() 166 { 167 return $this->basePath . self::COMPOSER_PHAR; 168 } 169 170 /** 171 * Check the version of the command line version of PHP 172 * 173 * @param $php 174 * @return string 175 */ 176 protected function getPhpVersion($php) 177 { 178 $process = new Process([$php, '--version'], null, ['HTTP_ACCEPT_ENCODING' => '']); 179 $process->inheritEnvironmentVariables(); 180 $process->run(); 181 foreach (explode("\n", $process->getOutput()) as $line) { 182 $parts = explode(' ', $line); 183 if ($parts[0] === 'PHP') { 184 return $parts[1]; 185 } 186 } 187 188 return ''; 189 } 190 191 /** 192 * Attempts to resolve the location of the PHP binary 193 * 194 * @return null|bool|string 195 */ 196 protected function getPhpPath() 197 { 198 if (! is_null($this->phpCli)) { 199 return $this->phpCli; 200 } 201 202 $this->phpCli = false; 203 204 // try to check the PHP binary path using operating system resolution mechanisms 205 foreach (self::PHP_COMMAND_NAMES as $cli) { 206 $possibleCli = $cli; 207 $prefix = 'command'; 208 if (\TikiInit::isWindows()) { 209 $possibleCli .= '.exe'; 210 $prefix = 'where'; 211 } 212 $process = new Process([$prefix, $possibleCli], null, ['HTTP_ACCEPT_ENCODING' => '']); 213 $process->inheritEnvironmentVariables(); 214 $process->setTimeout($this->timeout); 215 $process->run(); 216 $output = $process->getOutput(); 217 if ($output) { 218 $this->phpCli = trim($output); 219 return $this->phpCli; 220 } 221 } 222 223 // Fall back to path search 224 foreach (explode(PATH_SEPARATOR, $_SERVER['PATH']) as $path) { 225 foreach (self::PHP_COMMAND_NAMES as $cli) { 226 $possibleCli = $path . DIRECTORY_SEPARATOR . $cli; 227 if (\TikiInit::isWindows()) { 228 $possibleCli .= '.exe'; 229 } 230 if (file_exists($possibleCli) && is_executable($possibleCli)) { 231 $version = $this->getPhpVersion($possibleCli); 232 if (version_compare($version, self::PHP_MIN_VERSION, '<')) { 233 continue; 234 } 235 $this->phpCli = $possibleCli; 236 237 return $this->phpCli; 238 } 239 } 240 } 241 242 return $this->phpCli; 243 } 244 245 /** 246 * Evaluates if composer can be executed 247 * 248 * @return bool 249 */ 250 public function canExecuteComposer() 251 { 252 static $canExecute = null; 253 if (! is_null($canExecute)) { 254 return $canExecute; 255 } 256 257 $canExecute = false; 258 259 if ($this->composerPharExists()) { 260 list($output) = $this->execComposer(['--no-ansi', '--version']); 261 if (strncmp($output, 'Composer', 8) == 0) { 262 $canExecute = true; 263 } 264 } 265 266 return $canExecute; 267 } 268 269 /** 270 * Check if composer.phar exists 271 * 272 * @return bool 273 */ 274 public function composerPharExists() 275 { 276 return file_exists($this->getComposerPharPath()); 277 } 278 279 /** 280 * Execute Composer 281 * 282 * @param $args 283 * @return array 284 */ 285 protected function execComposer($args) 286 { 287 if (! is_array($args)) { 288 $args = [$args]; 289 } 290 291 $command = $output = $errors = ''; 292 293 try { 294 $composerPath = $this->getComposerPharPath(); 295 array_unshift($args, $composerPath); 296 297 $cmd = $this->getPhpPath(); 298 if ($cmd) { 299 array_unshift($args, $cmd); 300 } 301 302 if (! getenv('COMPOSER_HOME')) { 303 $env['COMPOSER_HOME'] = $this->basePath . self::COMPOSER_HOME; 304 } 305 // HTTP_ACCEPT_ENCODING interfere with the composer output, so set it to know value 306 $env['HTTP_ACCEPT_ENCODING'] = ''; 307 308 $process = new Process($args, null, $env); 309 $process->inheritEnvironmentVariables(); 310 $command = $process->getCommandLine(); 311 $process->setTimeout($this->timeout); 312 $process->run(); 313 314 $code = $process->getExitCode(); 315 316 $output = $process->getOutput(); 317 $errors = $process->getErrorOutput(); 318 } catch (ProcessExceptionInterface $e) { 319 $errors .= $e->getMessage(); 320 $code = 1; 321 } 322 323 $this->lastResult = [ 324 'command' => $command, 325 'output' => $output, 326 'errors' => $errors, 327 'code' => $code 328 ]; 329 330 return [$output, $errors, $code]; 331 } 332 333 /** 334 * Execute show command 335 * 336 * @return array 337 */ 338 protected function execShow() 339 { 340 if (! $this->canExecuteComposer()) { 341 return []; 342 } 343 list($result) = $this->execComposer(['--format=json', 'show', '-d', $this->workingPath]); 344 $json = json_decode($result, true); 345 346 return $json; 347 } 348 349 /** 350 * Execute Clear-Cache command 351 * 352 * @return array 353 */ 354 public function execClearCache() 355 { 356 if (! $this->canExecuteComposer()) { 357 return []; 358 } 359 list(, $errors, ) = $this->execComposer(['clear-cache']); 360 361 return $errors; 362 } 363 364 365 /** 366 * Check if the composer.json file exists 367 * 368 * @return bool 369 */ 370 public function checkConfigExists() 371 { 372 return file_exists($this->getComposerConfigFilePath()); 373 } 374 375 /** 376 * Retrieve list of packages in composer.json 377 * 378 * @return array|bool 379 */ 380 public function getListOfPackagesFromConfig() 381 { 382 if (! $this->checkConfigExists()) { 383 return false; 384 } 385 386 $content = json_decode(file_get_contents($this->getComposerConfigFilePath()), true); 387 $composerShow = $this->execShow(); 388 389 $installedPackages = []; 390 if (isset($composerShow['installed']) && is_array($composerShow['installed'])) { 391 foreach ($composerShow['installed'] as $package) { 392 $installedPackages[$this->normalizePackageName($package['name'])] = $package; 393 } 394 } 395 396 $result = []; 397 if (isset($content['require']) && is_array($content['require'])) { 398 foreach ($content['require'] as $name => $version) { 399 if (isset($installedPackages[$this->normalizePackageName($name)])) { 400 $result[] = [ 401 'name' => $name, 402 'status' => ComposerManager::STATUS_INSTALLED, 403 'required' => $version, 404 'installed' => $installedPackages[$name]['version'], 405 ]; 406 } else { 407 $result[] = [ 408 'name' => $name, 409 'status' => ComposerManager::STATUS_MISSING, 410 'required' => $version, 411 'installed' => '', 412 ]; 413 } 414 } 415 } 416 417 return $result; 418 } 419 420 /** 421 * Get list of packages from the composer.lock file 422 * @return array|bool 423 */ 424 public function getListOfPackagesFromLock() 425 { 426 if (! $this->checkConfigExists()) { 427 return false; 428 } 429 430 $content = json_decode(file_get_contents($this->getComposerLockFilePath()), true); 431 $packagesFromConfig = json_decode(file_get_contents($this->getComposerConfigFilePath()), true); 432 433 if (empty($content['packages']) || empty($packagesFromConfig)) { 434 return []; 435 } 436 437 // We will create a map with the required values to prevent extra logic afterwards 438 $configRequiredMap = []; 439 foreach ($packagesFromConfig['require'] as $packageName => $packageVersion) { 440 $configRequiredMap[$packageName] = $packageVersion; 441 } 442 443 $result = []; 444 foreach ($content['packages'] as $package) { 445 if (! isset($configRequiredMap[$package['name']])) { 446 continue; 447 } 448 449 $result[$package['name']] = [ 450 'name' => $package['name'], 451 'status' => ComposerManager::STATUS_INSTALLED, 452 'required' => $configRequiredMap[$package['name']], 453 'installed' => $package['version'], 454 ]; 455 } 456 457 return $result; 458 } 459 460 /** 461 * Ensure packages configured in composer.json are installed 462 * 463 * @return bool 464 */ 465 public function installMissingPackages() 466 { 467 global $tikipath; 468 if (! $this->checkConfigExists() || ! $this->canExecuteComposer()) { 469 return false; 470 } 471 472 $exe = ['--no-ansi', '--no-dev', '--prefer-dist', 'update', '-d', $this->workingPath, 'nothing']; 473 if (is_dir($tikipath . 'vendor_bundled/vendor/phpunit')) { 474 $exe = ['--no-ansi', '--prefer-dist', 'update', '-d', $this->workingPath, 'nothing']; 475 } 476 477 list($output, $errors) = $this->execComposer($exe); 478 479 return $this->glueOutputAndErrors($output, $errors); 480 } 481 482 /** 483 * Execute the diagnostic command 484 * 485 * @return array|bool 486 */ 487 public function execDiagnose() 488 { 489 if (! $this->canExecuteComposer()) { 490 return false; 491 } 492 493 list($output, $errors) = $this->execComposer(['--no-ansi', 'diagnose', '-d', $this->workingPath]); 494 495 return $this->glueOutputAndErrors($output, $errors); 496 } 497 498 /** 499 * Install a package (from the package definition) 500 * 501 * @param ComposerPackage $package 502 * @return bool|string 503 */ 504 public function installPackage(ComposerPackage $package) 505 { 506 if (! $this->canExecuteComposer()) { 507 return false; 508 } 509 510 $composerJson = $this->getComposerConfigOrDefault(); 511 $composerJson = $this->addComposerPackageToJson( 512 $composerJson, 513 $package->getName(), 514 $package->getRequiredVersion(), 515 $package->getScripts() 516 ); 517 $fileContent = json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 518 file_put_contents($this->getComposerConfigFilePath(), $fileContent); 519 520 $commandOutput = $this->installMissingPackages(); 521 522 return tr('= New composer.json file content') . ":\n\n" 523 . $fileContent . "\n\n" 524 . tr('= Composer execution output') . ":\n\n" 525 . $commandOutput; 526 } 527 528 /** 529 * Update a package required version (from the package definition) 530 * 531 * @param ComposerPackage $package 532 * @return bool 533 */ 534 public function updatePackage(ComposerPackage $package) 535 { 536 537 if (! $this->canExecuteComposer() || ! $this->checkConfigExists()) { 538 return false; 539 } 540 541 list($commandOutput, $errors) = $this->execComposer( 542 ['require', $package->getName() . ':' . $package->getRequiredVersion(), '--update-no-dev', '-d', $this->workingPath, '--no-ansi', '--no-interaction'] 543 ); 544 545 $fileContent = file_get_contents($this->getComposerConfigFilePath()); 546 547 return tr('= New composer.json file content') . ":\n\n" 548 . $fileContent . "\n\n" 549 . tr('= Composer execution output') . ":\n\n" 550 . $this->glueOutputAndErrors($commandOutput, $errors); 551 } 552 553 /** 554 * Remove a package (from the package definition) 555 * 556 * @param ComposerPackage $package 557 * @return bool|string 558 */ 559 public function removePackage(ComposerPackage $package) 560 { 561 if (! $this->canExecuteComposer() || ! $this->checkConfigExists()) { 562 return false; 563 } 564 565 list($commandOutput, $errors) = $this->execComposer( 566 ['remove', $package->getName(), '--update-no-dev', '-d', $this->workingPath, '--no-ansi', '--no-interaction'] 567 ); 568 569 $fileContent = file_get_contents($this->getComposerConfigFilePath()); 570 571 return tr('= New composer.json file content') . ":\n\n" 572 . $fileContent . "\n\n" 573 . tr('= Composer execution output') . ":\n\n" 574 . $this->glueOutputAndErrors($commandOutput, $errors); 575 } 576 577 578 /** 579 * Append a package to composer.json 580 * 581 * @param $composerJson 582 * @param $package 583 * @param $version 584 * @param array $scripts 585 * @return array 586 */ 587 public function addComposerPackageToJson($composerJson, $package, $version, $scripts = []) 588 { 589 590 $scriptsKeys = [ 591 'pre-install-cmd', 592 'post-install-cmd', 593 'pre-update-cmd', 594 'post-update-cmd', 595 ]; 596 597 if (! is_array($composerJson)) { 598 $composerJson = []; 599 } 600 // require 601 if (! isset($composerJson['require'])) { 602 $composerJson['require'] = []; 603 } 604 if (! isset($composerJson['require'][$package])) { 605 $composerJson['require'][$package] = $version; 606 } 607 608 // scripts 609 if (is_array($scripts) && count($scripts)) { 610 if (! isset($composerJson['scripts'])) { 611 $composerJson['scripts'] = []; 612 } 613 foreach ($scriptsKeys as $type) { 614 if (! isset($scripts[$type])) { 615 continue; 616 } 617 $scriptList = $scripts[$type]; 618 if (is_string($scriptList)) { 619 $scriptList = [$scriptList]; 620 } 621 if (! count($scriptList)) { 622 continue; 623 } 624 if (! isset($composerJson['scripts'][$type])) { 625 $composerJson['scripts'][$type] = []; 626 } 627 foreach ($scriptList as $scriptString) { 628 $composerJson['scripts'][$type][] = $scriptString; 629 } 630 $composerJson['scripts'][$type] = array_unique($composerJson['scripts'][$type]); 631 } 632 } 633 634 return $composerJson; 635 } 636 637 /** 638 * Normalize the package name 639 * 640 * @param string $packageName 641 * @return string 642 */ 643 public function normalizePackageName($packageName) 644 { 645 return strtolower($packageName); 646 } 647 648 /** 649 * Sets the execution timeout for composer 650 * 651 * @param int $timeout max amount of seconds waiting for a composer command to finish 652 */ 653 public function setTimeout($timeout) 654 { 655 $this->timeout = (int)$timeout; 656 } 657 658 /** 659 * Retrieves the execution timeout for composer 660 * 661 * @return int return the value of timeout in seconds 662 */ 663 public function getTimeout() 664 { 665 return $this->timeout; 666 } 667 668 /** 669 * Returns the result of the last composer command executed 670 * 671 * @return array|null last result, null for never executed, array(command, output, error, code) if executed 672 */ 673 public function getLastResult() 674 { 675 return $this->lastResult; 676 } 677 678 /** 679 * Clear the information about the last execution result 680 */ 681 public function clearLastResult() 682 { 683 $this->lastResult = null; 684 } 685 686 /** 687 * Glue both output ans errors, Checking if the different parts are not empty 688 * @param $output 689 * @param $errors 690 * @return string 691 */ 692 protected function glueOutputAndErrors($output, $errors) 693 { 694 $string = $output; 695 696 if (! empty($errors)) { 697 if (! empty($string)) { 698 $string .= "\n"; 699 } 700 $string .= tr('Errors:') . "\n" . $errors; 701 } 702 return $string; 703 } 704 705 /** 706 * Add composer.phar to temp/ folder 707 */ 708 public function installComposer() 709 { 710 $expectedSig = trim(file_get_contents('https://composer.github.io/installer.sig')); 711 712 if (! copy(self::COMPOSER_URL, self::COMPOSER_SETUP)) { 713 return [false, tr('Unable to download composer installer from %0', self::COMPOSER_URL)]; 714 } 715 716 $actualSig = hash_file('SHA384', self::COMPOSER_SETUP); 717 718 if ($expectedSig !== $actualSig) { 719 unlink(self::COMPOSER_SETUP); 720 return [false, tr('Invalid composer installer signature.')]; 721 } 722 723 $env = null; 724 if (! getenv('COMPOSER_HOME')) { 725 $env['COMPOSER_HOME'] = $this->basePath . self::COMPOSER_HOME; 726 } 727 $env['HTTP_ACCEPT_ENCODING'] = ''; 728 729 $command = [$this->getPhpPath(), self::COMPOSER_SETUP, '--quiet', '--install-dir=temp']; 730 $process = new Process($command, null, $env); 731 $process->inheritEnvironmentVariables(); 732 $process->run(); 733 734 $output = $process->getOutput(); 735 $result = $process->isSuccessful(); 736 737 if ($result) { 738 $message = tr('composer.phar installed in temp folder.'); 739 } else { 740 $message = tr('There was a problem when installing Composer.'); 741 } 742 743 if (! empty($output)) { 744 $message .= '<br>' . str_replace("\n", '<br>', $output); 745 } 746 747 unlink(self::COMPOSER_SETUP); 748 749 return [$result, $message]; 750 } 751 752 /** 753 * Add composer.phar to temp/ folder 754 */ 755 public function updateComposer() 756 { 757 $env = null; 758 if (! getenv('COMPOSER_HOME')) { 759 $env['COMPOSER_HOME'] = $this->basePath . self::COMPOSER_HOME; 760 } 761 $env['HTTP_ACCEPT_ENCODING'] = ''; 762 763 $command = [$this->getComposerPharPath(), 'self-update', '--no-progress']; 764 $process = new Process($command, null, $env); 765 $process->inheritEnvironmentVariables(); 766 $process->start(); 767 $output = ''; 768 foreach ($process as $type => $data) { 769 $output .= $data; 770 } 771 772 $result = $process->isSuccessful(); 773 $message = str_replace("\n", '<br>', trim($output)); 774 775 return [$result, $message]; 776 } 777} 778