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.
+