diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -397,6 +397,9 @@ 'ArcanistPHPShortTagXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistPHPShortTagXHPASTLinterRuleTestCase.php', 'ArcanistPaamayimNekudotayimSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPaamayimNekudotayimSpacingXHPASTLinterRule.php', 'ArcanistPaamayimNekudotayimSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistPaamayimNekudotayimSpacingXHPASTLinterRuleTestCase.php', + 'ArcanistPackageDescriptor' => 'packages/ArcanistPackageDescriptor.php', + 'ArcanistPackagesLoader' => 'packages/ArcanistPackagesLoader.php', + 'ArcanistPackagesWorkflow' => 'workflow/ArcanistPackagesWorkflow.php', 'ArcanistParentMemberReferenceXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistParentMemberReferenceXHPASTLinterRule.php', 'ArcanistParentMemberReferenceXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistParentMemberReferenceXHPASTLinterRuleTestCase.php', 'ArcanistParenthesesSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistParenthesesSpacingXHPASTLinterRule.php', @@ -1454,6 +1457,9 @@ 'ArcanistPHPShortTagXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistPaamayimNekudotayimSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPaamayimNekudotayimSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', + 'ArcanistPackageDescriptor' => 'Phobject', + 'ArcanistPackagesLoader' => 'Phobject', + 'ArcanistPackagesWorkflow' => 'ArcanistWorkflow', 'ArcanistParentMemberReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistParentMemberReferenceXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistParenthesesSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', diff --git a/src/packages/ArcanistPackageDescriptor.php b/src/packages/ArcanistPackageDescriptor.php new file mode 100644 --- /dev/null +++ b/src/packages/ArcanistPackageDescriptor.php @@ -0,0 +1,98 @@ +getSourceDisplayName())); + } + + return id(new self()) + ->setIdentifier($ident) + ->setVersion($version) + ->setConfigSource($config_source); + } + + public static function fromDirectory($directory) { + $filename = $directory.'/.arcpackage'; + if (!Filesystem::pathExists($filename)) { + return null; + } + $manifest = phutil_json_decode(Filesystem::readFile($filename)); + return self::fromManifest($manifest); + } + + /** + * "Manifest" refers to the .arcpackage file. + */ + public static function fromManifest(array $manifest) { + $ident = idx($manifest, 'identifier'); + $version = idx($manifest, 'version'); + if (!strlen($ident) || !strlen($version)) { + throw new ArcanistUsageException(pht('Bad manifest file!')); + } + return id(new self()) + ->setIdentifier($ident) + ->setVersion($version) + ->setDescription(idx($manifest, 'description')); + } + + public function __toString() { + return pht('Package %s (ver %s)', $this->identifier, $this->version); + } + + public function getDirectoryName() { + return $this->identifier.'-'.$this->version; + } + + public function setConfigSource(ArcanistConfigurationSource $config_source) { + $this->configSource = $config_source; + return $this; + } + + public function getConfigSource() { + return $this->configSource; + } + + public function setDescription($description) { + $this->description = $description; + return $this; + } + + public function getDescription() { + return $this->description; + } + + public function setIdentifier(string $identifier) { + $this->identifier = $identifier; + return $this; + } + + public function getIdentifier() { + return $this->identifier; + } + + public function setVersion(string $version) { + $this->version = $version; + return $this; + } + + public function getVersion() { + return $this->version; + } +} diff --git a/src/packages/ArcanistPackagesLoader.php b/src/packages/ArcanistPackagesLoader.php new file mode 100644 --- /dev/null +++ b/src/packages/ArcanistPackagesLoader.php @@ -0,0 +1,152 @@ +configurationSourceList = $sources; + return $this; + } + + public function loadPackages() { + $log = new ArcanistLogEngine(); + $requested = $this->listRequestedPackages(); + + $this->checkForConflicts($requested); + + $missing = array(); + + foreach ($requested as $package) { + $package_dir = $this->resolvePath($package); + $found = ArcanistPackageDescriptor::fromDirectory($package_dir); + if ($found != null) { + if ($found->getIdentifier() != $package->getIdentifier() || + $found->getVersion() != $package->getVersion()) { + $log->writeError( + pht('PACKAGE LOAD ERROR'), + pht( + 'Expected to find package %s in location %s, found %s instead', + $package->getIdentifier(), + $package_dir, + $found->getIdentifier())); + $missing[] = $package; + continue; + } + + try { + $this->loadPackage($found); + } catch (PhutilBootloaderException $ex) { + $log->writeError( + pht('LOAD ERROR'), + pht( + 'Failed to load package "%s". This package is '. + 'specified by "%s"', + $package->getIdentifier(), + $package->getConfigurationSource()->getSourceDisplayName())); + + $prompt = pht('Continue loading without this package?'); + if (!phutil_console_confirm($prompt)) { + throw $ex; + } + } + } else { + $missing[] = $package; + } + } + + if ($missing) { + $log->writeError( + pht('MISSING PACKAGES'), + pht( + 'The following packages were requested but not found: %s.', + implode(', ', $missing))); + + // TODO try to install; use `prompts`? + $prompt = pht('Continue without these packages?'); + if (!phutil_console_confirm($prompt)) { + throw new ArcanistUserAbortException('Requested package not found.'); + } + } + } + + private function checkForConflicts($requested) { + $log = new ArcanistLogEngine(); + $packages = mgroup($requested, 'getIdentifier', 'getVersion'); + + foreach ($packages as $identifier => $versions) { + if (count($versions) > 1) { + $requests = array(); + foreach ($versions as $version => $list) { + $package = head($list); + $requests[] = pht( + 'v%s - Requested from %s', + $package->getVersion(), + $package->getConfigSource()->getSourceDisplayName()); + } + + $log->writeError( + pht('CONFLICTING PACKAGE REQUESTED'), + pht( + 'The package "%s" has conflicting requirements: %s.', + $identifier, + implode('; ', $requests))); + + throw new ArcanistUsageException( + pht('Conflicting requests for package %s', $identifier)); + } + } + } + + private function loadPackage(ArcanistPackageDescriptor $package) { + phutil_load_library($this->resolvePath($package).'/src'); + } + + public function listRequestedPackages() { + $requested = array(); + $items = $this->configurationSourceList->getStorageValueList('packages'); + + foreach ($items as $item) { + foreach ($item->getValue() as $package_item) { + $requested[] = ArcanistPackageDescriptor::fromConfig( + $package_item, + $item->getConfigurationSource()); + } + } + return $requested; + } + + /** + * This method may be slow, because it looks for and loads lots of files. + */ + public function listInstalledPackages() { + $packages_root = $this->getPackagesDirectory(); + + if (!Filesystem::pathExists($packages_root)) { + return array(); + } + + $dirs = Filesystem::listDirectory($packages_root); + + $packages = array(); + foreach ($dirs as $dir) { + $packages[$dir] = + ArcanistPackageDescriptor::fromDirectory($packages_root.'/'.$dir); + } + return array_filter($packages); + } + + private function resolvePath(ArcanistPackageDescriptor $package) { + return $this->getPackagesDirectory().'/'.$package->getDirectoryName(); + } + + private function getPackagesDirectory() { + if (phutil_is_windows()) { + return getenv('APPDATA').'/.cache/arcanist-packages'; + } + return getenv('HOME').'/.cache/arcanist-packages'; + } +} diff --git a/src/runtime/ArcanistRuntime.php b/src/runtime/ArcanistRuntime.php --- a/src/runtime/ArcanistRuntime.php +++ b/src/runtime/ArcanistRuntime.php @@ -102,6 +102,7 @@ $config = $config_engine->newConfigurationSourceList(); $this->loadLibraries($config_engine, $config, $args); + $this->loadPackages($config_engine, $config, $args); // Now that we've loaded libraries, we can validate configuration. // Do this before continuing since configuration can impact other @@ -371,11 +372,11 @@ break; } - $this->loadLibrary($engine, $library_source, $description); + $this->loadLegacyLibrary($engine, $library_source, $description); } } - private function loadLibrary( + private function loadLegacyLibrary( ArcanistConfigurationEngine $engine, $location, $description) { @@ -512,6 +513,16 @@ } } + private function loadPackages( + ArcanistConfigurationEngine $engine, + ArcanistConfigurationSourceList $config, + PhutilArgumentParser $args) { + + $loader = id(new ArcanistPackagesLoader()) + ->setConfigurationSourceList($config); + $loader->loadPackages(); + } + private function newToolset(array $argv) { $binary = basename($argv[0]); diff --git a/src/workflow/ArcanistPackagesWorkflow.php b/src/workflow/ArcanistPackagesWorkflow.php new file mode 100644 --- /dev/null +++ b/src/workflow/ArcanistPackagesWorkflow.php @@ -0,0 +1,193 @@ +newWorkflowInformation() + ->setSynopsis(pht('Manage Extension Packages')) + ->addExample(pht('**packages**')) + ->addExample(pht('**packages** --install-from-tgz __file__')) + ->setHelp($help); + } + + public function getWorkflowArguments() { + return array( + $this->newWorkflowArgument('install-from-tgz') + ->setParameter('package_archive') + ->setHelp(pht('UNSTABLE: Install package from an archive')), + ); + } + + public function runWorkflow() { + $fromtar = $this->getArgument('install-from-tgz'); + if ($fromtar !== null) { + $this->runInstallFromTar($fromtar); + return; + } + + $this->runListPackagesWorkflow(); + } + + private function runInstallFromTar($tar_file) { + Filesystem::assertReadable($tar_file); + Filesystem::assertIsFile($tar_file); + + list($stdout, $stderr) = execx( + 'tar -x -z -O -f %s ./.arcpackage', + $tar_file); + $manifest = phutil_json_decode($stdout); + $descriptor = ArcanistPackageDescriptor::fromManifest($manifest); + + $dir = $this->resolvePath($descriptor); + if (Filesystem::pathExists($dir)) { + throw new ArcanistUsageException( + pht('this package already installed at %s', $dir)); + } + + Filesystem::createDirectory($dir, 0755, true); + execx('tar -x -z -f %s -C %s', $tar_file, $dir); + } + + private function runListPackagesWorkflow() { + $console = PhutilConsole::getConsole(); + + $loader = id(new ArcanistPackagesLoader()) + ->setConfigurationSourceList($this->getConfigurationSourceList()); + + $configured = $loader->listRequestedPackages(); + $configured = mpull($configured, null, 'getDirectoryName'); + + $installed = $loader->listInstalledPackages(); + $installed = mpull($installed, null, 'getDirectoryName'); + + $report = array(); + + foreach ($configured as $key => $descriptor) { + $report[$key] = array( + 'descriptor' => $descriptor, + 'status' => 'requested', + ); + } + + foreach ($installed as $key => $descriptor) { + if (array_key_exists($key, $report)) { + $report[$key]['status'] = 'loaded'; + $report[$key]['descriptor']->setDescription( + $descriptor->getDescription()); + } else { + $report[$key] = array( + 'descriptor' => $descriptor, + 'status' => 'installed', + ); + } + } + + $color_map = array( + 'loaded' => 'green', + 'installed' => 'yellow', + 'requested' => 'red', + ); + $text_map = $this->getPackageStatusMap(); + + ksort($report); + + foreach ($report as $ident => $package) { + $status = $package['status']; + $color = $color_map[$status]; + $text = $text_map[$status]; + + $descriptor = $package['descriptor']; + + $description = $descriptor->getDescription(); + if (!strlen($description)) { + $description = pht('Mystery package'); + } + $config_text = null; + $config_source = $descriptor->getConfigSource(); + if ($config_source) { + $config_text = pht( + '(Requested at %s)', + $config_source->getSourceDisplayName()); + } + $console->writeOut( + "** %s ** **%s** %s (%s) %s\n", + $text, + $descriptor->getIdentifier(), + $descriptor->getVersion(), + $description, + $config_text); + } + } + + private function loadConfiguredPackages() { + $items = $this->getConfigurationSourceList() + ->getStorageValueList('packages'); + + $packages = array(); + foreach ($items as $item) { + foreach ($item->getValue() as $package_item) { + $packages[] = $package_item + array( + 'config-source' => $item->getConfigurationSource(), + ); + } + } + return $packages; + } + + /** + * Get human-readable statuses, padded to fixed width. + * + * @return map Human-readable status names. + */ + // TODO this function is copied from Linters workflow + private function getPackageStatusMap() { + $text_map = array( + 'loaded' => pht('LOADED'), + 'installed' => pht('AVAILABLE'), + 'requested' => pht('MISSING'), + ); + + $sizes = array(); + foreach ($text_map as $key => $string) { + $sizes[$key] = phutil_utf8_console_strlen($string); + } + + $longest = max($sizes); + foreach ($text_map as $key => $string) { + if ($sizes[$key] < $longest) { + $text_map[$key] .= str_repeat(' ', $longest - $sizes[$key]); + } + } + + $text_map['padding'] = str_repeat(' ', $longest); + + return $text_map; + } + + // TODO these 2 are copy-pasted from ArcanistPackagesLoader + private function resolvePath(ArcanistPackageDescriptor $package) { + return $this->getPackagesDirectory().'/'.$package->getDirectoryName(); + } + + private function getPackagesDirectory() { + if (phutil_is_windows()) { + return getenv('APPDATA').'/.cache/arcanist-packages'; + } + return getenv('HOME').'/.cache/arcanist-packages'; + } +}