Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an experimental self update feature #55

Merged
merged 84 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
0fecef5
Install the self update component
caendesilva Dec 12, 2023
5301e9f
Proof of concept
caendesilva Dec 12, 2023
4e2817a
Temporarily try out development self-update version
caendesilva Dec 13, 2023
fbb8c70
Revert "Proof of concept"
caendesilva Dec 13, 2023
925f00b
Test with Composer.json
caendesilva Dec 13, 2023
b135972
Revert "Test with Composer.json"
caendesilva Dec 13, 2023
1523d97
Create SelfUpdateCommand.php
caendesilva Apr 16, 2024
03282ef
Configure signature and description
caendesilva Apr 16, 2024
074cd20
Add base handle method
caendesilva Apr 16, 2024
a618c8a
Parse version
caendesilva Apr 16, 2024
69b4f2b
Add helper to make a user agent string
caendesilva Apr 16, 2024
065894c
Create base release API response call
caendesilva Apr 16, 2024
2a6123a
Make the request
caendesilva Apr 16, 2024
6d85e2d
Return the parsed version
caendesilva Apr 16, 2024
ae68a0b
Add helper method types
caendesilva Apr 16, 2024
035a013
Extract helper method
caendesilva Apr 16, 2024
9cd8465
Import used functions
caendesilva Apr 16, 2024
3c2bdbc
Revert "Temporarily try out development self-update version"
caendesilva Apr 16, 2024
7fab14c
Revert "Install the self update component"
caendesilva Apr 16, 2024
284eaff
Change property to local variable
caendesilva Apr 16, 2024
fbdac67
Refactor data flow
caendesilva Apr 16, 2024
3268776
Formatting
caendesilva Apr 16, 2024
8464561
Revert "Formatting"
caendesilva Apr 16, 2024
d720f61
Add debug output
caendesilva Apr 16, 2024
24d9165
Compare the version states
caendesilva Apr 16, 2024
62c7460
Move output to handle method instead of state in check
caendesilva Apr 16, 2024
e988d79
Change void return to integer
caendesilva Apr 16, 2024
2af3860
Return when no operation is needed
caendesilva Apr 16, 2024
abe637a
Sketch out update logic
caendesilva Apr 16, 2024
2d36f83
Register the self update command
caendesilva Apr 16, 2024
2b950d5
Find the application path
caendesilva Apr 16, 2024
7d36039
Comment possible values
caendesilva Apr 16, 2024
3cb7b70
Add debug info
caendesilva Apr 16, 2024
4f6d754
Extract helper method
caendesilva Apr 16, 2024
4ba0706
Remove unnecessary quotes
caendesilva Apr 16, 2024
d327901
Add debug newline
caendesilva Apr 16, 2024
dc04698
Reorder helper methods
caendesilva Apr 16, 2024
7a5e2c2
Introduce local variable
caendesilva Apr 16, 2024
0a4f4cd
Refactor data state handling
caendesilva Apr 16, 2024
8fc616c
Extract helper method
caendesilva Apr 16, 2024
4c1c5fe
Assert the data is valid
caendesilva Apr 16, 2024
7cf62fa
Document reasoning
caendesilva Apr 16, 2024
8fcb3fa
Add strategy constants
caendesilva Apr 16, 2024
b05648f
Determine the update strategy
caendesilva Apr 16, 2024
d39fb30
Check and print the update strategy
caendesilva Apr 16, 2024
1c9c61a
Ignore case for comparison
caendesilva Apr 16, 2024
b898647
Check that the executable path is writable
caendesilva Apr 16, 2024
25fdcec
Print the progress state
caendesilva Apr 16, 2024
29b0c64
Match the update strategies
caendesilva Apr 16, 2024
0266483
Sketch out update methods
caendesilva Apr 16, 2024
7ad6851
Add a newline for better readability
caendesilva Apr 16, 2024
68647ba
Implement the direct download logic
caendesilva Apr 16, 2024
815e514
Implement the Composer update logic
caendesilva Apr 16, 2024
969e072
Import used functions
caendesilva Apr 16, 2024
7144296
Check that the Curl extension is available
caendesilva Apr 16, 2024
f6110f1
Suggest the Curl extension
caendesilva Apr 16, 2024
b9dcba7
Disable extension inspection
caendesilva Apr 16, 2024
7d7b1e1
Formatting
caendesilva Apr 16, 2024
29368b8
Scope down generics to ones we care about
caendesilva Apr 16, 2024
2c6b067
Mark SelfUpdateCommand as experimental
caendesilva Apr 16, 2024
bcbcb7d
Add helper to assemble URLs
caendesilva Apr 16, 2024
990b7fc
Catch exceptions in handle method
caendesilva Apr 16, 2024
3c9500c
Print a notice with link to report issues upon exceptions
caendesilva Apr 16, 2024
310b0ae
Update helper to support string lists
caendesilva Apr 16, 2024
335d15a
Revert "Update helper to support string lists"
caendesilva Apr 16, 2024
26660b9
Create a full Markdown issue body
caendesilva Apr 16, 2024
83d9a34
Import used function
caendesilva Apr 16, 2024
7afef37
Dynamic throwing based on verbosity
caendesilva Apr 16, 2024
210942b
Always throw the exception
caendesilva Apr 16, 2024
5742ba9
Add warning
caendesilva Apr 16, 2024
73f5842
Create more dynamic and detailed output
caendesilva Apr 16, 2024
22d8c24
Formatting
caendesilva Apr 16, 2024
4c7fe89
Revert "Always throw the exception"
caendesilva Apr 16, 2024
971edaf
Revert "Add warning"
caendesilva Apr 16, 2024
a4f0c60
Restructure exception output
caendesilva Apr 16, 2024
78261f1
Cleanup formatting
caendesilva Apr 16, 2024
fa557d4
Refactor exception formatting
caendesilva Apr 16, 2024
a575884
Print which line it is
caendesilva Apr 16, 2024
a067c81
Fix indentation
caendesilva Apr 16, 2024
b61aa29
Redact personal information
caendesilva Apr 16, 2024
f530495
Make helper private
caendesilva Apr 16, 2024
b86051b
Mark experimental class as internal
caendesilva Apr 16, 2024
a55c431
Sort import
caendesilva Apr 16, 2024
84ceb56
Fix formatting
caendesilva Apr 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 348 additions & 0 deletions app/Commands/SelfUpdateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
<?php

/** @noinspection PhpComposerExtensionStubsInspection as we have our own extension check */

declare(strict_types=1);

namespace App\Commands;

use Throwable;
use App\Application;
use RuntimeException;
use Illuminate\Support\Str;
use Illuminate\Console\Command;

use function fopen;
use function assert;
use function fclose;
use function rename;
use function getenv;
use function explode;
use function ini_set;
use function sprintf;
use function implode;
use function tempnam;
use function passthru;
use function array_map;
use function curl_init;
use function curl_exec;
use function urlencode;
use function base_path;
use function curl_close;
use function array_keys;
use function json_decode;
use function is_writable;
use function curl_setopt;
use function str_replace;
use function array_combine;
use function sys_get_temp_dir;
use function extension_loaded;
use function file_get_contents;
use function get_included_files;

/**
* @experimental This command is highly experimental and may contain bugs.
*
* @internal This command should not be accessed from the code as it may change significantly.
*/
class SelfUpdateCommand extends Command
{
/** @var string */
protected $signature = 'self-update';

/** @var string */
protected $description = 'Update the standalone application to the latest version.';

protected const STATE_BEHIND = 1;
protected const STATE_UP_TO_DATE = 2;
protected const STATE_AHEAD = 3;

protected const STRATEGY_DIRECT = 'direct';
protected const STRATEGY_COMPOSER = 'composer';

/** @var array<string, string|array<string>> 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(" <error>{$exception->getMessage()}</error> on line <comment>{$exception->getLine()}</comment> in file <comment>{$exception->getFile()}</comment>");

if (! $this->output->isVerbose()) {
$this->line(' <fg=gray>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('<info>%s</info> <href=%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<string, string> $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 <<<MARKDOWN
### Description

A fatal error occurred while trying to update the application using the self-update command.

### Error message

```
{$exception->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'), '<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, '<project>'.DIRECTORY_SEPARATOR, $markdown);

return ($markdown);
}
}
2 changes: 2 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +19,7 @@ public function register(): void
{
$this->commands([
NewProjectCommand::class,
SelfUpdateCommand::class,
]);
}

Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
Loading