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."
}
}