diff --git a/CHANGELOG.md b/CHANGELOG.md index 2926ad65..9b73a7c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Add `directory` and `filter` parameters to `#[AsPathArgument]` and `#[AsPathOption]` attributes to improve autocomplete * Add `input` option to Context to pass data to process stdin (useful for sensitive data like passwords) +* Add `self-update` command to update Castor to the latest version ### Fixes diff --git a/composer.json b/composer.json index dbc79be2..85992dc1 100644 --- a/composer.json +++ b/composer.json @@ -54,6 +54,7 @@ "symfony/http-client": "^7.4.5", "symfony/mime": "^7.4.5", "symfony/monolog-bridge": "^7.4.4", + "symfony/polyfill-php84": "^1.33", "symfony/process": "^7.4.5", "symfony/string": "^7.4.4", "symfony/translation-contracts": "^3.6.1", diff --git a/composer.lock b/composer.lock index 922f299c..11169394 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5a8eb64cb903f6f43adadfdaf276d91c", + "content-hash": "ae5dec5cb4c8fa064dae8a2576e1fd69", "packages": [ { "name": "composer/ca-bundle", @@ -6407,15 +6407,15 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.2" }, - "platform-dev": {}, + "platform-dev": [], "platform-overrides": { "php": "8.2.27" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/doc/installation/installer.md b/doc/installation/installer.md index 78565bf2..8f4dc8af 100644 --- a/doc/installation/installer.md +++ b/doc/installation/installer.md @@ -57,6 +57,27 @@ You can install a specific version of Castor by using the `--version` option: curl "https://castor.jolicode.com/install" | bash -s -- --version=v1.0.0 ``` +## Updating Castor + +If you installed Castor using the installer (phar or static binary), you can +update it to the latest version using the `self-update` command: + +```bash +castor self-update +``` + +### Self-update options + +- `--force` or `-f`: Force update even if already up to date +- `--no-backup`: Skip creating a backup of the current binary +- `--rollback` or `-r`: Rollback to the previous version + +> [!NOTE] +> The `self-update` command is not available for source installations or Composer +> project dependencies. For global Composer installs (`composer global require`), +> `self-update` runs `composer global update` under the hood. For project +> dependencies, use `composer update jolicode/castor` instead. + ## Other installation methods If you cannot use the installer, see diff --git a/src/Console/ApplicationFactory.php b/src/Console/ApplicationFactory.php index dca3d04a..c3e9f13b 100644 --- a/src/Console/ApplicationFactory.php +++ b/src/Console/ApplicationFactory.php @@ -8,6 +8,7 @@ use Castor\Console\Command\ExecuteCommand; use Castor\Console\Command\InitCommand; use Castor\Console\Command\RepackCommand; +use Castor\Console\Command\SelfUpdateCommand; use Castor\Container; use Castor\Helper\PathHelper; use Castor\Helper\PlatformHelper; @@ -235,6 +236,9 @@ private static function configureContainer(ContainerConfigurator $c, bool $repac ->call('setDispatcher', [service(EventDispatcherInterface::class)]) ->call('setCatchErrors', [true]) ; + if (!$repacked) { + $app->call('addCommand', [service(SelfUpdateCommand::class)]); + } if (!$repacked && $hasCastorFile) { $app ->call('addCommand', [service(ComposerCommand::class)]) diff --git a/src/Console/Command/SelfUpdateCommand.php b/src/Console/Command/SelfUpdateCommand.php new file mode 100644 index 00000000..bcfda56a --- /dev/null +++ b/src/Console/Command/SelfUpdateCommand.php @@ -0,0 +1,279 @@ +addOption('force', 'f', InputOption::VALUE_NONE, 'Force update even if already up to date') + ->addOption('no-backup', null, InputOption::VALUE_NONE, 'Skip creating a backup of the current binary') + ->addOption('rollback', 'r', InputOption::VALUE_NONE, 'Rollback to the previous version') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $installationMethod = $this->installation->getMethod(); + $currentPath = $this->installation->getPath(); + + if ($input->getOption('rollback')) { + return $this->rollback($io, $currentPath); + } + + if (!\in_array($installationMethod, [InstallationMethod::Phar, InstallationMethod::Static, InstallationMethod::ComposerGlobal], true)) { + return $this->handleUnsupportedInstallationMethod($io, $installationMethod); + } + + if (InstallationMethod::ComposerGlobal === $installationMethod) { + return $this->updateViaComposer($io); + } + + return $this->updateBinary($io, $input, $installationMethod, $currentPath); + } + + private function updateViaComposer(SymfonyStyle $io): int + { + $io->section('Updating Castor via Composer...'); + + $process = new Process(['composer', 'global', 'update', 'jolicode/castor']); + $process->setTimeout(300); + $process->run(static function (string $type, string $buffer) use ($io): void { + $io->write($buffer); + }); + + if (!$process->isSuccessful()) { + $io->error('Failed to update Castor via Composer.'); + + return Command::FAILURE; + } + + $io->success('Castor has been updated successfully!'); + + return Command::SUCCESS; + } + + private function updateBinary(SymfonyStyle $io, InputInterface $input, InstallationMethod $installationMethod, string $currentPath): int + { + $io->section('Checking for updates...'); + + $latestVersion = $this->fetchLatestVersion(); + + if (null === $latestVersion) { + $io->error('Failed to fetch latest version information from GitHub.'); + + return Command::FAILURE; + } + + $latestTag = $latestVersion['tag_name']; + $currentVersion = Application::VERSION; + + $io->text(\sprintf('Current version: %s', $currentVersion)); + $io->text(\sprintf('Latest version: %s', $latestTag)); + $io->newLine(); + + if (!$input->getOption('force') && version_compare($latestTag, $currentVersion, '<=')) { + $io->success('You are already using the latest version of Castor.'); + + return Command::SUCCESS; + } + + $downloadUrl = $this->getDownloadUrl($latestVersion, $installationMethod); + + if (null === $downloadUrl) { + $io->error('Could not find a suitable download for your platform.'); + + return Command::FAILURE; + } + + if (!is_writable(\dirname($currentPath))) { + $io->error(\sprintf( + 'Cannot update: directory "%s" is not writable. Try running with elevated privileges.', + \dirname($currentPath) + )); + + return Command::FAILURE; + } + + $io->text(\sprintf('Downloading from: %s', $downloadUrl)); + + $tempFile = sys_get_temp_dir() . '/castor-update-' . uniqid(); + + try { + $this->httpDownloader->download($downloadUrl, $tempFile); + } catch (\Throwable $e) { + $io->error(\sprintf('Failed to download update: %s', $e->getMessage())); + + return Command::FAILURE; + } + + if (!$input->getOption('no-backup')) { + $backupPath = $currentPath . '.backup'; + $io->text(\sprintf('Creating backup at: %s', $backupPath)); + $this->filesystem->copy($currentPath, $backupPath, true); + } + + $this->filesystem->chmod($tempFile, 0o755); + + $io->text('Verifying new binary...'); + $verifyProcess = new Process([$tempFile, '--version']); + $verifyProcess->run(); + + if (!$verifyProcess->isSuccessful()) { + $io->error('The downloaded binary appears to be corrupted. Update aborted.'); + $this->filesystem->remove($tempFile); + + return Command::FAILURE; + } + + $io->text('Replacing current binary...'); + $this->filesystem->rename($tempFile, $currentPath, true); + $this->filesystem->chmod($currentPath, 0o755); + + $io->newLine(); + $io->success(\sprintf('Castor has been updated from %s to %s!', $currentVersion, $latestTag)); + + if (!$input->getOption('no-backup')) { + $io->note('A backup of the previous version has been saved. Use --rollback to restore it.'); + } + + return Command::SUCCESS; + } + + private function handleUnsupportedInstallationMethod(SymfonyStyle $io, InstallationMethod $installationMethod): int + { + $io->error(\sprintf( + 'Self-update is not supported for "%s" installation method.', + $installationMethod->value + )); + + match ($installationMethod) { + InstallationMethod::Composer => $io->block( + 'Castor is installed as a project dependency via Composer. ' . + 'Updating it manually would break the consistency with your composer.lock file.', + 'WHY?', + 'fg=yellow', + ' ', + ), + InstallationMethod::Source => $io->block( + 'Castor is running from source (Git checkout). ' . + 'Replacing files would break your Git repository.', + 'WHY?', + 'fg=yellow', + ' ', + ), + default => null, + }; + + $updateCommand = match ($installationMethod) { + InstallationMethod::Composer => 'composer update jolicode/castor', + InstallationMethod::Source => 'git pull', + default => null, + }; + + if ($updateCommand) { + $io->block(\sprintf('To update, run: %s', $updateCommand), 'TIP', 'fg=green', ' ', escape: false); + } + + return Command::FAILURE; + } + + private function rollback(SymfonyStyle $io, string $currentPath): int + { + $backupPath = $currentPath . '.backup'; + + if (!file_exists($backupPath)) { + $io->error('No backup found. Cannot rollback.'); + + return Command::FAILURE; + } + + $io->section('Rolling back to previous version...'); + + $this->filesystem->rename($backupPath, $currentPath, true); + $this->filesystem->chmod($currentPath, 0o755); + + $io->success('Successfully rolled back to the previous version.'); + + return Command::SUCCESS; + } + + /** + * @return array|null + */ + private function fetchLatestVersion(): ?array + { + try { + return $this + ->httpClient + ->request('GET', 'https://api.github.com/repos/jolicode/castor/releases/latest', [ + 'timeout' => 10, + ]) + ->toArray() + ; + } catch (\Throwable) { + return null; + } + } + + /** + * @param array $latestVersion + */ + private function getDownloadUrl(array $latestVersion, InstallationMethod $installationMethod): ?string + { + $assets = $latestVersion['assets'] ?? []; + + $assets = match (true) { + OsHelper::isWindows() || OsHelper::isWindowsSubsystemForLinux() => array_filter($assets, static fn (array $asset): bool => str_contains((string) $asset['name'], 'windows')), + OsHelper::isMacOS() => array_filter($assets, static fn (array $asset): bool => str_contains((string) $asset['name'], 'darwin')), + OsHelper::isUnix() => array_filter($assets, static fn (array $asset): bool => str_contains((string) $asset['name'], 'linux')), + default => [], + }; + + $architecture = $this->installation->getArchitecture(); + $assets = array_filter($assets, static fn (array $asset): bool => str_contains((string) $asset['name'], $architecture->value)); + + if (InstallationMethod::Static === $installationMethod) { + $assets = array_filter($assets, static fn (array $asset): bool => !str_ends_with((string) $asset['name'], '.phar')); + } else { + $assets = array_filter($assets, static fn (array $asset): bool => str_ends_with((string) $asset['name'], '.phar')); + } + + $asset = array_first($assets); + + return $asset['browser_download_url'] ?? null; + } +} diff --git a/src/Listener/UpdateCastorListener.php b/src/Listener/UpdateCastorListener.php index 074100e5..3514815f 100644 --- a/src/Listener/UpdateCastorListener.php +++ b/src/Listener/UpdateCastorListener.php @@ -148,7 +148,7 @@ private function displayUpdateWarningIfNeeded(InputInterface $input, OutputInter $assets = array_filter($assets, static fn (array $asset): bool => str_ends_with((string) $asset['name'], '.phar')); } - $latestReleaseUrl = reset($assets)['browser_download_url'] ?? null; + $latestReleaseUrl = array_first($assets)['browser_download_url'] ?? null; if (!$latestReleaseUrl) { $this->logger->info('Failed to fetch latest artefact URL.'); diff --git a/tests/Generated/ImportSamePackageWithDefaultVersionTest.php.output.txt b/tests/Generated/ImportSamePackageWithDefaultVersionTest.php.output.txt index 37004fef..901d82a1 100644 --- a/tests/Generated/ImportSamePackageWithDefaultVersionTest.php.output.txt +++ b/tests/Generated/ImportSamePackageWithDefaultVersionTest.php.output.txt @@ -32,6 +32,7 @@ Available commands: completion Dump the shell completion script help Display help for a command list List commands + self-update [self:update] Updates Castor to the latest version castor castor:composer [composer] Interact with built-in Composer for castor castor:execute [execute] Execute a remote task from a packagist directory diff --git a/tests/Generated/LayoutWithFolderTest.php.output.txt b/tests/Generated/LayoutWithFolderTest.php.output.txt index ac648943..ee60d57b 100644 --- a/tests/Generated/LayoutWithFolderTest.php.output.txt +++ b/tests/Generated/LayoutWithFolderTest.php.output.txt @@ -29,6 +29,7 @@ Available commands: hello help Display help for a command list List commands + self-update [self:update] Updates Castor to the latest version castor castor:composer [composer] Interact with built-in Composer for castor castor:execute [execute] Execute a remote task from a packagist directory diff --git a/tests/Generated/ListTest.php.output.txt b/tests/Generated/ListTest.php.output.txt index bea6d6b7..8269e7df 100644 --- a/tests/Generated/ListTest.php.output.txt +++ b/tests/Generated/ListTest.php.output.txt @@ -3,6 +3,7 @@ hello help Display help for a command list List commands no-namespace Task without a namespace +self-update Updates Castor to the latest version shebang-task update Update all dependencies archive:zip Compress files into a zip archive using native binary or fallback to ZipArchive php class diff --git a/tests/Generated/SelfUpdateTest.php b/tests/Generated/SelfUpdateTest.php new file mode 100644 index 00000000..0aa04d03 --- /dev/null +++ b/tests/Generated/SelfUpdateTest.php @@ -0,0 +1,22 @@ +runTask(['self-update', '--force', '--no-backup', '--rollback']); + + if (1 !== $process->getExitCode()) { + throw new ProcessFailedException($process); + } + + $this->assertStringEqualsFileWithCleaning(__FILE__ . '.output.txt', $process->getOutput()); + $this->assertSame('', $process->getErrorOutput()); + } +} diff --git a/tests/Generated/SelfUpdateTest.php.output.txt b/tests/Generated/SelfUpdateTest.php.output.txt new file mode 100644 index 00000000..6e924cf9 --- /dev/null +++ b/tests/Generated/SelfUpdateTest.php.output.txt @@ -0,0 +1,2 @@ + [ERROR] No backup found. Cannot rollback. +