diff --git a/app/Commands/SelfUpdateCommand.php b/app/Commands/SelfUpdateCommand.php new file mode 100644 index 00000000..a642e3f0 --- /dev/null +++ b/app/Commands/SelfUpdateCommand.php @@ -0,0 +1,348 @@ +> The latest release information from the GitHub API */ + protected array $release; + + public function handle(): int + { + try { + $this->output->title('Checking for a new version...'); + + $applicationPath = $this->findApplicationPath(); + $this->debug("Application path: $applicationPath"); + + $strategy = $this->determineUpdateStrategy($applicationPath); + $this->debug('Update strategy: '.($strategy === self::STRATEGY_COMPOSER ? 'Composer' : 'Direct download')); + + $currentVersion = $this->parseVersion(Application::APP_VERSION); + $this->debug('Current version: v'.implode('.', $currentVersion)); + + $latestVersion = $this->parseVersion($this->getLatestReleaseVersion()); + $this->debug('Latest version: v'.implode('.', $latestVersion)); + + // Add a newline for better readability + $this->debug(); + + $state = $this->compareVersions($currentVersion, $latestVersion); + $this->printVersionStateInformation($state); + + if ($state !== self::STATE_BEHIND) { + return Command::SUCCESS; + } + + $this->output->title('Updating to the latest version...'); + + $this->updateApplication($strategy); + + // Add a newline for better readability + $this->debug(); + + $this->info('The application has been updated successfully.'); + + return Command::SUCCESS; + } catch (Throwable $exception) { + $this->output->error('Something went wrong while updating the application!'); + + $this->line(" {$exception->getMessage()} on line {$exception->getLine()} in file {$exception->getFile()}"); + + if (! $this->output->isVerbose()) { + $this->line(' For more information, run the command again with the `-v` option to throw the exception.'); + } + + $this->newLine(); + $this->warn('As the self-update command is experimental, this may be a bug within the command itself.'); + + $this->line(sprintf('%s %s', 'Please report this issue on GitHub so we can fix it!', + $this->buildUrl('https://github.com/hydephp/cli/issues/new', [ + 'title' => 'Error while self-updating the application', + 'body' => $this->stripPersonalInformation($this->getIssueMarkdown($exception)) + ]), 'https://github.com/hydephp/cli/issues/new?title=Error+while+self-updating+the+application' + )); + + if ($this->output->isVerbose()) { + throw $exception; + } + + return Command::FAILURE; + } + } + + protected function getLatestReleaseVersion(): string + { + $this->getLatestReleaseInformation(); + + return $this->release['tag_name']; + } + + protected function getLatestReleaseInformation(): void + { + $data = json_decode($this->makeGitHubApiResponse(), true); + + assert($data !== null); + assert(isset($data['tag_name'])); + assert(isset($data['assets'])); + assert(isset($data['assets'][0])); + assert(isset($data['assets'][0]['browser_download_url'])); + assert(isset($data['assets'][0]['name']) && $data['assets'][0]['name'] === 'hyde'); + + $this->release = $data; + } + + protected function makeGitHubApiResponse(): string + { + // Set the user agent as required by the GitHub API + ini_set('user_agent', $this->getUserAgent()); + + return file_get_contents('https://api.github.com/repos/hydephp/cli/releases/latest'); + } + + protected function getUserAgent(): string + { + return sprintf('HydePHP CLI updater v%s (github.com/hydephp/cli)', Application::APP_VERSION); + } + + /** @return array{major: int, minor: int, patch: int} */ + protected function parseVersion(string $semver): array + { + return array_combine(['major', 'minor', 'patch'], + array_map('intval', explode('.', $semver)) + ); + } + + /** @return self::STATE_* */ + protected function compareVersions(array $currentVersion, array $latestVersion): int + { + if ($currentVersion === $latestVersion) { + return self::STATE_UP_TO_DATE; + } + + if ($currentVersion < $latestVersion) { + return self::STATE_BEHIND; + } + + return self::STATE_AHEAD; + } + + protected function findApplicationPath(): string + { + // Get the full path to the application executable + // Generally /user/bin/hyde, /usr/local/bin/hyde, or C:\Users\User\AppData\Roaming\Composer\vendor\bin\hyde + + return get_included_files()[0]; + } + + /** @param self::STATE_* $state */ + protected function printVersionStateInformation(int $state): void + { + match ($state) { + self::STATE_BEHIND => $this->info('A new version is available.'), + self::STATE_UP_TO_DATE => $this->info('You are already using the latest version.'), + self::STATE_AHEAD => $this->info('You are using a development version.'), + }; + } + + /** @param self::STRATEGY_* $strategy */ + protected function updateApplication(string $strategy): void + { + $this->output->writeln('Updating the application...'); + + match ($strategy) { + self::STRATEGY_DIRECT => $this->updateDirectly(), + self::STRATEGY_COMPOSER => $this->updateViaComposer(), + }; + } + + /** @return self::STRATEGY_* */ + protected function determineUpdateStrategy(string $applicationPath): string + { + // Check if the application is installed via Composer + if (Str::contains($applicationPath, 'composer', true)) { + return self::STRATEGY_COMPOSER; + } + + // Check that the executable path is writable + if (! is_writable($applicationPath)) { + throw new RuntimeException('The application path is not writable. Please rerun the command with elevated privileges.'); + } + + // Check that the Curl extension is available + if (! extension_loaded('curl')) { + throw new RuntimeException('The Curl extension is required to use the self-update command.'); + } + + return self::STRATEGY_DIRECT; + } + + protected function updateDirectly(): void + { + $this->output->writeln('Downloading the latest version...'); + + // Download the latest release from GitHub + $downloadUrl = $this->release['assets'][0]['browser_download_url']; + $downloadedFile = tempnam(sys_get_temp_dir(), 'hyde'); + $this->downloadFile($downloadUrl, $downloadedFile); + + // Replace the current application with the downloaded one + $this->replaceApplication($downloadedFile); + } + + protected function downloadFile(string $url, string $destination): void + { + $this->debug("Downloading $url to $destination"); + + $file = fopen($destination, 'wb'); + $ch = curl_init($url); + + curl_setopt($ch, CURLOPT_FILE, $file); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_exec($ch); + + curl_close($ch); + fclose($file); + } + + protected function replaceApplication(string $downloadedFile): void + { + $applicationPath = $this->findApplicationPath(); + + $this->debug("Moving file $downloadedFile to $applicationPath"); + + // Replace the current application with the downloaded one + rename($downloadedFile, $applicationPath); + } + + protected function updateViaComposer(): void + { + $this->output->writeln('Updating via Composer...'); + + // Invoke the Composer command to update the application + passthru('composer global update hyde/hyde'); + } + + protected function debug(string $message = ''): void + { + if ($this->output->isVerbose()) { + $this->output->writeln($message); + } + } + + /** @param array $params */ + private function buildUrl(string $url, array $params): string + { + return sprintf("$url?%s", implode('&', array_map(function (string $key, string $value): string { + return sprintf('%s=%s', $key, urlencode($value)); + }, array_keys($params), $params))); + } + + private function getDebugEnvironment(): string + { + return implode("\n", [ + 'Application version: v'.Application::APP_VERSION, + 'PHP version: v'.PHP_VERSION, + 'Operating system: '.PHP_OS, + ]); + } + + private function getIssueMarkdown(Throwable $exception): string + { + return <<getMessage()} on line {$exception->getLine()} in file {$exception->getFile()} + ``` + + ### Stack trace + + ``` + {$exception->getTraceAsString()} + ``` + + ### Environment + + ``` + {$this->getDebugEnvironment()} + ``` + + ### Context + + - Add any additional context here that may be relevant to the issue. + + MARKDOWN; + } + + private function stripPersonalInformation(string $markdown): string + { + // As the stacktrace may contain the user's name, we remove it to protect their privacy + $markdown = str_replace(getenv('USER') ?: getenv('USERNAME'), '', $markdown); + + // We also convert absolute paths to relative paths to avoid leaking the user's directory structure + $markdown = str_replace(base_path().DIRECTORY_SEPARATOR, ''.DIRECTORY_SEPARATOR, $markdown); + + return ($markdown); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 35264a0c..c21870d1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ use App\Commands\Internal\Describer; use App\Commands\NewProjectCommand; +use App\Commands\SelfUpdateCommand; use App\Commands\ServeCommand; use App\Commands\VendorPublishCommand; use Illuminate\Support\ServiceProvider; @@ -18,6 +19,7 @@ public function register(): void { $this->commands([ NewProjectCommand::class, + SelfUpdateCommand::class, ]); } diff --git a/composer.json b/composer.json index 910d6780..ff073a5c 100644 --- a/composer.json +++ b/composer.json @@ -62,5 +62,8 @@ "laravel-zero/framework": "^10.0", "mockery/mockery": "^1.6", "pestphp/pest": "^2.26" + }, + "suggest": { + "ext-curl": "Required for using the self-update feature when not installing through Composer." } }