Skip to content

Commit 7e5a3c2

Browse files
authored
Merge pull request #55 from hydephp/add-self-update-feature
Add an experimental self update feature
2 parents a89539a + 84ceb56 commit 7e5a3c2

3 files changed

Lines changed: 353 additions & 0 deletions

File tree

app/Commands/SelfUpdateCommand.php

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
<?php
2+
3+
/** @noinspection PhpComposerExtensionStubsInspection as we have our own extension check */
4+
5+
declare(strict_types=1);
6+
7+
namespace App\Commands;
8+
9+
use Throwable;
10+
use App\Application;
11+
use RuntimeException;
12+
use Illuminate\Support\Str;
13+
use Illuminate\Console\Command;
14+
15+
use function fopen;
16+
use function assert;
17+
use function fclose;
18+
use function rename;
19+
use function getenv;
20+
use function explode;
21+
use function ini_set;
22+
use function sprintf;
23+
use function implode;
24+
use function tempnam;
25+
use function passthru;
26+
use function array_map;
27+
use function curl_init;
28+
use function curl_exec;
29+
use function urlencode;
30+
use function base_path;
31+
use function curl_close;
32+
use function array_keys;
33+
use function json_decode;
34+
use function is_writable;
35+
use function curl_setopt;
36+
use function str_replace;
37+
use function array_combine;
38+
use function sys_get_temp_dir;
39+
use function extension_loaded;
40+
use function file_get_contents;
41+
use function get_included_files;
42+
43+
/**
44+
* @experimental This command is highly experimental and may contain bugs.
45+
*
46+
* @internal This command should not be accessed from the code as it may change significantly.
47+
*/
48+
class SelfUpdateCommand extends Command
49+
{
50+
/** @var string */
51+
protected $signature = 'self-update';
52+
53+
/** @var string */
54+
protected $description = 'Update the standalone application to the latest version.';
55+
56+
protected const STATE_BEHIND = 1;
57+
protected const STATE_UP_TO_DATE = 2;
58+
protected const STATE_AHEAD = 3;
59+
60+
protected const STRATEGY_DIRECT = 'direct';
61+
protected const STRATEGY_COMPOSER = 'composer';
62+
63+
/** @var array<string, string|array<string>> The latest release information from the GitHub API */
64+
protected array $release;
65+
66+
public function handle(): int
67+
{
68+
try {
69+
$this->output->title('Checking for a new version...');
70+
71+
$applicationPath = $this->findApplicationPath();
72+
$this->debug("Application path: $applicationPath");
73+
74+
$strategy = $this->determineUpdateStrategy($applicationPath);
75+
$this->debug('Update strategy: '.($strategy === self::STRATEGY_COMPOSER ? 'Composer' : 'Direct download'));
76+
77+
$currentVersion = $this->parseVersion(Application::APP_VERSION);
78+
$this->debug('Current version: v'.implode('.', $currentVersion));
79+
80+
$latestVersion = $this->parseVersion($this->getLatestReleaseVersion());
81+
$this->debug('Latest version: v'.implode('.', $latestVersion));
82+
83+
// Add a newline for better readability
84+
$this->debug();
85+
86+
$state = $this->compareVersions($currentVersion, $latestVersion);
87+
$this->printVersionStateInformation($state);
88+
89+
if ($state !== self::STATE_BEHIND) {
90+
return Command::SUCCESS;
91+
}
92+
93+
$this->output->title('Updating to the latest version...');
94+
95+
$this->updateApplication($strategy);
96+
97+
// Add a newline for better readability
98+
$this->debug();
99+
100+
$this->info('The application has been updated successfully.');
101+
102+
return Command::SUCCESS;
103+
} catch (Throwable $exception) {
104+
$this->output->error('Something went wrong while updating the application!');
105+
106+
$this->line(" <error>{$exception->getMessage()}</error> on line <comment>{$exception->getLine()}</comment> in file <comment>{$exception->getFile()}</comment>");
107+
108+
if (! $this->output->isVerbose()) {
109+
$this->line(' <fg=gray>For more information, run the command again with the `-v` option to throw the exception.</>');
110+
}
111+
112+
$this->newLine();
113+
$this->warn('As the self-update command is experimental, this may be a bug within the command itself.');
114+
115+
$this->line(sprintf('<info>%s</info> <href=%s>%s</>', 'Please report this issue on GitHub so we can fix it!',
116+
$this->buildUrl('https://github.com/hydephp/cli/issues/new', [
117+
'title' => 'Error while self-updating the application',
118+
'body' => $this->stripPersonalInformation($this->getIssueMarkdown($exception))
119+
]), 'https://github.com/hydephp/cli/issues/new?title=Error+while+self-updating+the+application'
120+
));
121+
122+
if ($this->output->isVerbose()) {
123+
throw $exception;
124+
}
125+
126+
return Command::FAILURE;
127+
}
128+
}
129+
130+
protected function getLatestReleaseVersion(): string
131+
{
132+
$this->getLatestReleaseInformation();
133+
134+
return $this->release['tag_name'];
135+
}
136+
137+
protected function getLatestReleaseInformation(): void
138+
{
139+
$data = json_decode($this->makeGitHubApiResponse(), true);
140+
141+
assert($data !== null);
142+
assert(isset($data['tag_name']));
143+
assert(isset($data['assets']));
144+
assert(isset($data['assets'][0]));
145+
assert(isset($data['assets'][0]['browser_download_url']));
146+
assert(isset($data['assets'][0]['name']) && $data['assets'][0]['name'] === 'hyde');
147+
148+
$this->release = $data;
149+
}
150+
151+
protected function makeGitHubApiResponse(): string
152+
{
153+
// Set the user agent as required by the GitHub API
154+
ini_set('user_agent', $this->getUserAgent());
155+
156+
return file_get_contents('https://api.github.com/repos/hydephp/cli/releases/latest');
157+
}
158+
159+
protected function getUserAgent(): string
160+
{
161+
return sprintf('HydePHP CLI updater v%s (github.com/hydephp/cli)', Application::APP_VERSION);
162+
}
163+
164+
/** @return array{major: int, minor: int, patch: int} */
165+
protected function parseVersion(string $semver): array
166+
{
167+
return array_combine(['major', 'minor', 'patch'],
168+
array_map('intval', explode('.', $semver))
169+
);
170+
}
171+
172+
/** @return self::STATE_* */
173+
protected function compareVersions(array $currentVersion, array $latestVersion): int
174+
{
175+
if ($currentVersion === $latestVersion) {
176+
return self::STATE_UP_TO_DATE;
177+
}
178+
179+
if ($currentVersion < $latestVersion) {
180+
return self::STATE_BEHIND;
181+
}
182+
183+
return self::STATE_AHEAD;
184+
}
185+
186+
protected function findApplicationPath(): string
187+
{
188+
// Get the full path to the application executable
189+
// Generally /user/bin/hyde, /usr/local/bin/hyde, or C:\Users\User\AppData\Roaming\Composer\vendor\bin\hyde
190+
191+
return get_included_files()[0];
192+
}
193+
194+
/** @param self::STATE_* $state */
195+
protected function printVersionStateInformation(int $state): void
196+
{
197+
match ($state) {
198+
self::STATE_BEHIND => $this->info('A new version is available.'),
199+
self::STATE_UP_TO_DATE => $this->info('You are already using the latest version.'),
200+
self::STATE_AHEAD => $this->info('You are using a development version.'),
201+
};
202+
}
203+
204+
/** @param self::STRATEGY_* $strategy */
205+
protected function updateApplication(string $strategy): void
206+
{
207+
$this->output->writeln('Updating the application...');
208+
209+
match ($strategy) {
210+
self::STRATEGY_DIRECT => $this->updateDirectly(),
211+
self::STRATEGY_COMPOSER => $this->updateViaComposer(),
212+
};
213+
}
214+
215+
/** @return self::STRATEGY_* */
216+
protected function determineUpdateStrategy(string $applicationPath): string
217+
{
218+
// Check if the application is installed via Composer
219+
if (Str::contains($applicationPath, 'composer', true)) {
220+
return self::STRATEGY_COMPOSER;
221+
}
222+
223+
// Check that the executable path is writable
224+
if (! is_writable($applicationPath)) {
225+
throw new RuntimeException('The application path is not writable. Please rerun the command with elevated privileges.');
226+
}
227+
228+
// Check that the Curl extension is available
229+
if (! extension_loaded('curl')) {
230+
throw new RuntimeException('The Curl extension is required to use the self-update command.');
231+
}
232+
233+
return self::STRATEGY_DIRECT;
234+
}
235+
236+
protected function updateDirectly(): void
237+
{
238+
$this->output->writeln('Downloading the latest version...');
239+
240+
// Download the latest release from GitHub
241+
$downloadUrl = $this->release['assets'][0]['browser_download_url'];
242+
$downloadedFile = tempnam(sys_get_temp_dir(), 'hyde');
243+
$this->downloadFile($downloadUrl, $downloadedFile);
244+
245+
// Replace the current application with the downloaded one
246+
$this->replaceApplication($downloadedFile);
247+
}
248+
249+
protected function downloadFile(string $url, string $destination): void
250+
{
251+
$this->debug("Downloading $url to $destination");
252+
253+
$file = fopen($destination, 'wb');
254+
$ch = curl_init($url);
255+
256+
curl_setopt($ch, CURLOPT_FILE, $file);
257+
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
258+
curl_exec($ch);
259+
260+
curl_close($ch);
261+
fclose($file);
262+
}
263+
264+
protected function replaceApplication(string $downloadedFile): void
265+
{
266+
$applicationPath = $this->findApplicationPath();
267+
268+
$this->debug("Moving file $downloadedFile to $applicationPath");
269+
270+
// Replace the current application with the downloaded one
271+
rename($downloadedFile, $applicationPath);
272+
}
273+
274+
protected function updateViaComposer(): void
275+
{
276+
$this->output->writeln('Updating via Composer...');
277+
278+
// Invoke the Composer command to update the application
279+
passthru('composer global update hyde/hyde');
280+
}
281+
282+
protected function debug(string $message = ''): void
283+
{
284+
if ($this->output->isVerbose()) {
285+
$this->output->writeln($message);
286+
}
287+
}
288+
289+
/** @param array<string, string> $params */
290+
private function buildUrl(string $url, array $params): string
291+
{
292+
return sprintf("$url?%s", implode('&', array_map(function (string $key, string $value): string {
293+
return sprintf('%s=%s', $key, urlencode($value));
294+
}, array_keys($params), $params)));
295+
}
296+
297+
private function getDebugEnvironment(): string
298+
{
299+
return implode("\n", [
300+
'Application version: v'.Application::APP_VERSION,
301+
'PHP version: v'.PHP_VERSION,
302+
'Operating system: '.PHP_OS,
303+
]);
304+
}
305+
306+
private function getIssueMarkdown(Throwable $exception): string
307+
{
308+
return <<<MARKDOWN
309+
### Description
310+
311+
A fatal error occurred while trying to update the application using the self-update command.
312+
313+
### Error message
314+
315+
```
316+
{$exception->getMessage()} on line {$exception->getLine()} in file {$exception->getFile()}
317+
```
318+
319+
### Stack trace
320+
321+
```
322+
{$exception->getTraceAsString()}
323+
```
324+
325+
### Environment
326+
327+
```
328+
{$this->getDebugEnvironment()}
329+
```
330+
331+
### Context
332+
333+
- Add any additional context here that may be relevant to the issue.
334+
335+
MARKDOWN;
336+
}
337+
338+
private function stripPersonalInformation(string $markdown): string
339+
{
340+
// As the stacktrace may contain the user's name, we remove it to protect their privacy
341+
$markdown = str_replace(getenv('USER') ?: getenv('USERNAME'), '<USERNAME>', $markdown);
342+
343+
// We also convert absolute paths to relative paths to avoid leaking the user's directory structure
344+
$markdown = str_replace(base_path().DIRECTORY_SEPARATOR, '<project>'.DIRECTORY_SEPARATOR, $markdown);
345+
346+
return ($markdown);
347+
}
348+
}

app/Providers/AppServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Commands\Internal\Describer;
66
use App\Commands\NewProjectCommand;
7+
use App\Commands\SelfUpdateCommand;
78
use App\Commands\ServeCommand;
89
use App\Commands\VendorPublishCommand;
910
use Illuminate\Support\ServiceProvider;
@@ -18,6 +19,7 @@ public function register(): void
1819
{
1920
$this->commands([
2021
NewProjectCommand::class,
22+
SelfUpdateCommand::class,
2123
]);
2224
}
2325

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,8 @@
6262
"laravel-zero/framework": "^10.0",
6363
"mockery/mockery": "^1.6",
6464
"pestphp/pest": "^2.26"
65+
},
66+
"suggest": {
67+
"ext-curl": "Required for using the self-update feature when not installing through Composer."
6568
}
6669
}

0 commit comments

Comments
 (0)