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