diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b9f889ac..4090210da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. This projec ### Added - Added `--to=` option to `backup:get` to allow specifying of a local download location. (#1520) - Removed framework type check from `drush` and `wp` commands. (#1521) +- Terminus now checks for new versions after every command run. (#1523) ## 1.0.0-beta.2 - 2017-01-10 ### Fixed diff --git a/src/Exceptions/TerminusException.php b/src/Exceptions/TerminusException.php index 3d4f168a6..c199455c0 100644 --- a/src/Exceptions/TerminusException.php +++ b/src/Exceptions/TerminusException.php @@ -8,11 +8,14 @@ */ class TerminusException extends \Exception { - /** - * @var array - */ + /** + * @var array + */ private $replacements; + /** + * @var null|string + */ private $raw_message; /** @@ -24,7 +27,7 @@ class TerminusException extends \Exception */ public function __construct( $message = null, - $replacements = array(), + array $replacements = [], $code = 0 ) { $this->replacements = $replacements; @@ -33,33 +36,33 @@ public function __construct( parent::__construct($this->interpolateString($message, $replacements), $code); } - /** - * Returns the replacements context array - * - * @return array $this->replacements The replacement variables. - */ - public function getReplacements() + /** + * Returns the replacements context array + * + * @return string $this->replacements + */ + public function getRawMessage() { - return $this->replacements; + return $this->raw_message; } - /** - * Returns the replacements context array - * - * @return string $this->replacements - */ - public function getRawMessage() + /** + * Returns the replacements context array + * + * @return array $this->replacements The replacement variables. + */ + public function getReplacements() { - return $this->raw_message; + return $this->replacements; } - /** - * Replace the variables into the message string. - * - * @param string $message The raw, uninterpolated message string - * @param array $replacements The values to replace into the message - * @return string - */ + /** + * Replace the variables into the message string. + * + * @param string $message The raw, uninterpolated message string + * @param array $replacements The values to replace into the message + * @return string + */ protected function interpolateString($message, $replacements) { $tr = []; diff --git a/src/Authorizer.php b/src/Hooks/Authorizer.php similarity index 85% rename from src/Authorizer.php rename to src/Hooks/Authorizer.php index 157895003..2e8166051 100644 --- a/src/Authorizer.php +++ b/src/Hooks/Authorizer.php @@ -1,6 +1,6 @@ get('version')); $container = Robo::createDefaultContainer($input, $output, $application, $config); + $this->setContainer($container); $this->addDefaultArgumentsAndOptions($application); - $this->configureContainer($container); + $this->configureContainer(); $this->addBuiltInCommandsAndHooks(); - $this->addPluginsCommandsAndHooks($container); + $this->addPluginsCommandsAndHooks(); $this->runner = new RoboRunner(); $this->runner->setContainer($container); + $this->setLogger($container->get('logger')); + date_default_timezone_set($config->get('time_zone')); } @@ -138,44 +147,51 @@ public function run(InputInterface $input, OutputInterface $output) $status_code = $this->runner->run($input, $output, null, $this->commands); if (!empty($cassette) && !empty($mode)) { $this->stopVCR(); + } else { + $this->runUpdateChecker(); } return $status_code; } /** - * Discovers command classes using CommandFileDiscovery + * Add the commands and hooks which are shipped with core Terminus + */ + private function addBuiltInCommandsAndHooks() + { + $commands = $this->getCommands([ + 'path' => __DIR__ . '/Commands', + 'namespace' => 'Pantheon\Terminus\Commands', + ]); + $hooks = [ + 'Pantheon\Terminus\Hooks\Authorizer', + ]; + $this->commands = array_merge($commands, $hooks); + } + + /** + * Add any global arguments or options that apply to all commands. * - * @param string[] $options Elements as follow - * string path The full path to the directory to search for commands - * string namespace The full namespace associated with given the command directory - * @return TerminusCommand[] An array of TerminusCommand instances + * @param \Symfony\Component\Console\Application $app */ - private function getCommands(array $options = ['path' => null, 'namespace' => null,]) + private function addDefaultArgumentsAndOptions(Application $app) { - $discovery = new CommandFileDiscovery(); - $discovery->setSearchPattern('*Command.php')->setSearchLocations([]); - return $discovery->discover($options['path'], $options['namespace']); + $app->getDefinition()->addOption(new InputOption('--yes', '-y', InputOption::VALUE_NONE, 'Answer all confirmations with "yes"')); } /** * Discovers command classes using CommandFileDiscovery - * - * @param string[] $options Elements as follow - * string path The full path to the directory to search for commands - * string namespace The full namespace associated with given the command directory - * @return TerminusCommand[] An array of TerminusCommand instances */ - private function addPluginsCommandsAndHooks($container) + private function addPluginsCommandsAndHooks() { // Rudimentary plugin loading. - $discovery = $container->get(PluginDiscovery::class, [$this->getConfig()->get('plugins_dir')]); + $discovery = $this->getContainer()->get(PluginDiscovery::class, [$this->getConfig()->get('plugins_dir')]); $plugins = $discovery->discover(); $version = $this->config->get('version'); foreach ($plugins as $plugin) { if (Semver::satisfies($version, $plugin->getCompatibleTerminusVersion())) { $this->commands += $plugin->getCommandsAndHooks(); } else { - $container->get('logger')->warning( + $this->logger->warning( "Could not load plugin {plugin} because it is not compatible with this version of Terminus.", ['plugin' => $plugin->getName()] ); @@ -183,38 +199,29 @@ private function addPluginsCommandsAndHooks($container) } } - /** - * Add the commands and hooks which are shipped with core Terminus - */ - private function addBuiltInCommandsAndHooks() - { - // Add the built in commands. - $commands_directory = __DIR__ . '/Commands'; - $top_namespace = 'Pantheon\Terminus\Commands'; - $this->commands = $this->getCommands(['path' => $commands_directory, 'namespace' => $top_namespace,]); - $this->commands[] = 'Pantheon\\Terminus\\Authorizer'; - } - /** * Register the necessary classes for Terminus - * - * @param \League\Container\ContainerInterface $container */ - private function configureContainer(ContainerInterface $container) + private function configureContainer() { - // Add the services. + $container = $this->getContainer(); + + // Add the services + // Request $container->add(Client::class); $container->add(HttpRequest::class); $container->share('request', Request::class); $container->inflector(RequestAwareInterface::class) ->invokeMethod('setRequest', ['request']); + // Session $session_store = new FileStore($this->getConfig()->get('cache_dir')); $session = new Session($session_store); $container->share('session', $session); $container->inflector(SessionAwareInterface::class) ->invokeMethod('setSession', ['session']); + // Saved tokens $token_store = new FileStore($this->getConfig()->get('tokens_dir')); $container->inflector(SavedTokens::class) ->invokeMethod('setDataStore', [$token_store]); @@ -270,14 +277,17 @@ private function configureContainer(ContainerInterface $container) $container->add(Tags::class); $container->add(Tag::class); - // Add Helpers + // Helpers $container->add(LocalMachineHelper::class); - // Plugin handlers $container->add(PluginDiscovery::class); $container->add(PluginInfo::class); + // Update checker + $container->add(LatestRelease::class); + $container->add(UpdateChecker::class); + $container->share('sites', Sites::class); $container->inflector(SiteAwareInterface::class) ->invokeMethod('setSites', ['sites']); @@ -288,13 +298,27 @@ private function configureContainer(ContainerInterface $container) } /** - * Add any global arguments or options that apply to all commands. + * Discovers command classes using CommandFileDiscovery * - * @param \Symfony\Component\Console\Application $app + * @param string[] $options Elements as follow + * string path The full path to the directory to search for commands + * string namespace The full namespace associated with given the command directory + * @return TerminusCommand[] An array of TerminusCommand instances */ - private function addDefaultArgumentsAndOptions(Application $app) + private function getCommands(array $options = ['path' => null, 'namespace' => null,]) { - $app->getDefinition()->addOption(new InputOption('--yes', '-y', InputOption::VALUE_NONE, 'Answer all confirmations with "yes"')); + $discovery = new CommandFileDiscovery(); + $discovery->setSearchPattern('*Command.php')->setSearchLocations([]); + return $discovery->discover($options['path'], $options['namespace']); + } + + /** + * Runs the UpdateChecker to check for new Terminus versions + */ + private function runUpdateChecker() + { + $file_store = new FileStore($this->getConfig()->get('cache_dir')); + $this->runner->getContainer()->get(UpdateChecker::class, [$file_store,])->run(); } /** diff --git a/src/Update/LatestRelease.php b/src/Update/LatestRelease.php new file mode 100644 index 000000000..069aec919 --- /dev/null +++ b/src/Update/LatestRelease.php @@ -0,0 +1,120 @@ +setDataStore($data_store); + } + + /** + * @param string $id Key of the attribute to retrieve + * @return string + * @throws TerminusNotFoundException + */ + public function get($id) + { + $attributes = $this->getAttributes(); + if (isset($attributes->$id)) { + return $attributes->$id; + } + throw new TerminusNotFoundException('There is no attribute called {id}.', compact('id')); + } + + /** + * Retrieves release data. If it is time to check for an update, it will do that. + */ + private function fetch() + { + $saved_data = $this->getSavedReleaseFromFile(); + + if (!isset($saved_data->check_date) + || (int)$saved_data->check_date < strtotime('-' . self::TIME_BETWEEN_CHECKS) + ) { + try { + $this->attributes = $this->getLatestReleaseFromGithub(); + $this->saveReleaseData($this->attributes); + } catch (\Exception $e) { + $this->logger->debug( + "Terminus was unable to check the latest release version number.\n{message}", + ['message' => $e->getMessage(),] + ); + } + } + if (empty($this->attributes)) { + $this->attributes = $saved_data; + } + } + + /** + * @return object + */ + private function getAttributes() + { + if (empty($this->attributes)) { + $this->fetch(); + } + return $this->attributes; + } + + /** + * @return object + */ + private function getLatestReleaseFromGithub() + { + return (object)[ + 'version' => $this->request()->request(self::UPDATE_URL)['data']->name, + 'check_date' => time(), + ]; + } + + /** + * @return object + */ + private function getSavedReleaseFromFile() + { + return $this->getDataStore()->get(self::SAVE_FILE); + } + + /** + * @param object $data + */ + private function saveReleaseData($data) + { + $this->getDataStore()->set(self::SAVE_FILE, $data); + } +} diff --git a/src/Update/UpdateChecker.php b/src/Update/UpdateChecker.php new file mode 100644 index 000000000..748d6b81c --- /dev/null +++ b/src/Update/UpdateChecker.php @@ -0,0 +1,85 @@ +setDataStore($data_store); + } + + public function run() + { + $running_version = $this->getRunningVersion(); + try { + $latest_version = $this->getContainer()->get(LatestRelease::class, [$this->getDataStore(),])->get('version'); + } catch (TerminusNotFoundException $e) { + $this->logger->debug('Terminus has no saved release information.'); + return; + } + + if (version_compare($latest_version, $running_version, '>')) { + $this->logger->notice($this->getUpdateNotice(), [ + 'latest_version' => $latest_version, + 'running_version' => $running_version, + 'update_command' => self::UPDATE_COMMAND, + ]); + } + } + + /** + * Retrieves the version number of the running Terminus instance + * + * @return string + */ + private function getRunningVersion() + { + return $this->getConfig()->get('version'); + } + + /** + * Returns a colorized update notice + * + * @return string + */ + private function getUpdateNotice() + { + return self::UPDATE_NOTICE_COLOR + . str_replace('}', '}' . self::UPDATE_NOTICE_COLOR, self::UPDATE_NOTICE) + . self::DEFAULT_COLOR; + } +} diff --git a/tests/unit_tests/Exceptions/TerminusExceptionTest.php b/tests/unit_tests/Exceptions/TerminusExceptionTest.php new file mode 100644 index 000000000..b7b9351f5 --- /dev/null +++ b/tests/unit_tests/Exceptions/TerminusExceptionTest.php @@ -0,0 +1,51 @@ +getRawMessage(); + $this->assertEquals($out, $raw_message); + } + + /** + * Tests the getReplacements function + */ + public function testGetReplacements() + { + $replacements = ['key' => 'value',]; + $exception = new TerminusException(null, $replacements); + + $out = $exception->getReplacements(); + $this->assertEquals($out, $replacements); + } + + /** + * Indirectly tests the interpolateString function + */ + public function testInterpolateString() + { + $raw_message = '{key} is a key'; + $replacements = ['key' => 'value',]; + $expected_message = 'value is a key'; + $exception = new TerminusException($raw_message, $replacements); + + $out = $exception->getMessage(); + $this->assertEquals($out, $expected_message); + } +} diff --git a/tests/unit_tests/Update/LatestReleaseTest.php b/tests/unit_tests/Update/LatestReleaseTest.php new file mode 100644 index 000000000..e39c91046 --- /dev/null +++ b/tests/unit_tests/Update/LatestReleaseTest.php @@ -0,0 +1,167 @@ +container = $this->getMockBuilder(Container::class) + ->disableOriginalConstructor() + ->getMock(); + $this->data_store = $this->getMockBuilder(DataStoreInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->logger = $this->getMockBuilder(Logger::class) + ->disableOriginalConstructor() + ->getMock(); + $this->request = $this->getMockBuilder(Request::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->latest_release = new LatestRelease($this->data_store); + $this->latest_release->setContainer($this->container); + $this->latest_release->setLogger($this->logger); + $this->latest_release->setRequest($this->request); + } + + /** + * Tests running get($string) when there isn't a saved version file + */ + public function testFirstTime() + { + $version = '1.0.0-beta.2'; + + $this->data_store->expects($this->once()) + ->method('get') + ->with($this->equalTo(LatestRelease::SAVE_FILE)) + ->willReturn(null); + $this->request->expects($this->once()) + ->method('request') + ->with($this->equalTo(LatestRelease::UPDATE_URL)) + ->willReturn(['data' => (object)['name' => $version,],]); + $this->data_store->expects($this->once()) + ->method('set'); + $this->logger->expects($this->never()) + ->method('debug'); + + $out = $this->latest_release->get('version'); + $this->assertEquals($out, $version); + } + + /** + * Tests running get($string) when unable to check for new versions + */ + public function testCannotCheckGithub() + { + $version = '1.0.0-beta.2'; + $check_date = strtotime('-' . LatestRelease::TIME_BETWEEN_CHECKS) - 999999; + $data = (object)['version' => $version, 'check_date' => $check_date,]; + $message = 'exception message'; + + $this->data_store->expects($this->once()) + ->method('get') + ->with($this->equalTo(LatestRelease::SAVE_FILE)) + ->willReturn($data); + $this->request->expects($this->once()) + ->method('request') + ->with($this->equalTo(LatestRelease::UPDATE_URL)) + ->will($this->throwException(new \Exception($message))); + $this->data_store->expects($this->never()) + ->method('set'); + $this->logger->expects($this->once()) + ->method('debug') + ->with( + $this->equalTo("Terminus was unable to check the latest release version number.\n{message}"), + $this->equalTo(compact('message')) + ); + + $out = $this->latest_release->get('version'); + $this->assertEquals($out, $version); + } + + /** + * Tests running get($string) when the version was already checked recently + */ + public function testCheckedRecently() + { + $version = '1.0.0-beta.2'; + $check_date = time(); + $data = (object)['version' => $version, 'check_date' => $check_date,]; + + $this->data_store->expects($this->once()) + ->method('get') + ->with($this->equalTo(LatestRelease::SAVE_FILE)) + ->willReturn($data); + $this->request->expects($this->never()) + ->method('request'); + $this->data_store->expects($this->never()) + ->method('set'); + $this->logger->expects($this->never()) + ->method('debug'); + + $out = $this->latest_release->get('version'); + $this->assertEquals($out, $version); + } + + /** + * Tests when trying to retrieve an attribute that doesn't exist + */ + public function testGetInvalidAttribute() + { + $version = '1.0.0-beta.2'; + $check_date = time(); + $data = (object)['version' => $version, 'check_date' => $check_date,]; + + $this->data_store->expects($this->once()) + ->method('get') + ->with($this->equalTo(LatestRelease::SAVE_FILE)) + ->willReturn($data); + $this->request->expects($this->never()) + ->method('request'); + $this->data_store->expects($this->never()) + ->method('set'); + $this->logger->expects($this->never()) + ->method('debug'); + + $this->setExpectedException(TerminusNotFoundException::class, 'There is no attribute called invalid.'); + + $out = $this->latest_release->get('invalid'); + $this->assertNull($out); + } +} diff --git a/tests/unit_tests/Update/UpdateCheckerTest.php b/tests/unit_tests/Update/UpdateCheckerTest.php new file mode 100644 index 000000000..6486b56e4 --- /dev/null +++ b/tests/unit_tests/Update/UpdateCheckerTest.php @@ -0,0 +1,171 @@ +config = $this->getMockBuilder(TerminusConfig::class) + ->disableOriginalConstructor() + ->getMock(); + $this->container = $this->getMockBuilder(Container::class) + ->disableOriginalConstructor() + ->getMock(); + $this->data_store = $this->getMockBuilder(DataStoreInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->latest_release = $this->getMockBuilder(LatestRelease::class) + ->disableOriginalConstructor() + ->getMock(); + $this->logger = $this->getMockBuilder(Logger::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->update_checker = new UpdateChecker($this->data_store); + $this->update_checker->setConfig($this->config); + $this->update_checker->setContainer($this->container); + $this->update_checker->setLogger($this->logger); + } + + /** + * Tests the run function when the client is up-to-date + */ + public function testClientIsUpToDate() + { + $running_version_num = '1.0.0-beta.2'; + $latest_version_num = '1.0.0-beta.2'; + + $this->config->expects($this->once()) + ->method('get') + ->with($this->equalTo('version')) + ->willReturn($running_version_num); + $this->container->expects($this->once()) + ->method('get') + ->with( + $this->equalTo(LatestRelease::class), + $this->equalTo([$this->data_store,]) + ) + ->willReturn($this->latest_release); + $this->latest_release->expects($this->once()) + ->method('get') + ->with($this->equalTo('version')) + ->willReturn($latest_version_num); + $this->logger->expects($this->never()) + ->method('notice'); + $this->logger->expects($this->never()) + ->method('debug'); + + $out = $this->update_checker->run(); + $this->assertNull($out); + } + + /** + * Tests the run function when the client is out-of-date + */ + public function testClientIsOutOfDate() + { + $running_version_num = '1.0.0-beta.1'; + $latest_version_num = '1.0.0-beta.2'; + + $this->config->expects($this->once()) + ->method('get') + ->with($this->equalTo('version')) + ->willReturn($running_version_num); + $this->container->expects($this->once()) + ->method('get') + ->with( + $this->equalTo(LatestRelease::class), + $this->equalTo([$this->data_store,]) + ) + ->willReturn($this->latest_release); + $this->latest_release->expects($this->once()) + ->method('get') + ->with($this->equalTo('version')) + ->willReturn($latest_version_num); + $this->logger->expects($this->once()) + ->method('notice'); + $this->logger->expects($this->never()) + ->method('debug'); + + $out = $this->update_checker->run(); + $this->assertNull($out); + } + + /** + * Tests the run function when Github release data is unavailable + */ + public function testCannotCheckVersion() + { + $running_version_num = '1.0.0-beta.2'; + + $this->config->expects($this->once()) + ->method('get') + ->with($this->equalTo('version')) + ->willReturn($running_version_num); + $this->container->expects($this->once()) + ->method('get') + ->with( + $this->equalTo(LatestRelease::class), + $this->equalTo([$this->data_store,]) + ) + ->willReturn($this->latest_release); + $this->latest_release->expects($this->once()) + ->method('get') + ->with($this->equalTo('version')) + ->will($this->throwException(new TerminusNotFoundException())); + $this->logger->expects($this->never()) + ->method('notice'); + $this->logger->expects($this->once()) + ->method('debug') + ->with( + $this->equalTo('Terminus has no saved release information.') + ); + + $out = $this->update_checker->run(); + $this->assertNull($out); + } +}