Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
Amoifr marked this conversation as resolved.

### Fixes

Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions doc/installation/installer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/Console/ApplicationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)])
Expand Down
279 changes: 279 additions & 0 deletions src/Console/Command/SelfUpdateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
<?php

namespace Castor\Console\Command;

use Castor\Console\Application;
use Castor\Helper\Installation;
use Castor\Helper\InstallationMethod;
use Castor\Http\HttpDownloader;
use JoliCode\PhpOsHelper\OsHelper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/** @internal */
#[AsCommand(
name: 'self-update',
description: 'Updates Castor to the latest version',
aliases: ['self:update'],
)]
class SelfUpdateCommand extends Command
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly HttpDownloader $httpDownloader,
private readonly Installation $installation,
private readonly Filesystem $filesystem,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->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: <info>%s</info>', $currentVersion));
$io->text(\sprintf('Latest version: <info>%s</info>', $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: <comment>%s</comment>', $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: <comment>%s</comment>', $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;
}

Comment on lines +151 to +161
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The update currently "verifies" the downloaded artifact by executing $tempFile --version. This doesn't provide integrity/authenticity guarantees (a tampered binary could still print a version) and it executes an untrusted download before it replaces the current binary. Consider adding a cryptographic verification step (e.g., compare a published SHA-256/SHA-512 checksum or signature from the release) and only fall back to executing the file once integrity is established.

Suggested change
$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('Verifying downloaded binary integrity...');
$expectedChecksum = null;
$checksumTempFile = $tempFile . '.sha256';
try {
// Try to download a sidecar SHA-256 checksum file for the binary.
$this->httpDownloader->download($downloadUrl . '.sha256', $checksumTempFile);
if (is_readable($checksumTempFile)) {
$expectedChecksum = trim((string) file_get_contents($checksumTempFile));
}
} catch (\Throwable $e) {
// If we cannot obtain a checksum file, we will continue without checksum verification.
$io->warning('Could not download checksum file for integrity verification. Proceeding without checksum verification.');
} finally {
if (isset($checksumTempFile) && file_exists($checksumTempFile)) {
$this->filesystem->remove($checksumTempFile);
}
}
if (null !== $expectedChecksum && '' !== $expectedChecksum) {
$actualChecksum = hash_file('sha256', $tempFile);
if (!hash_equals($expectedChecksum, $actualChecksum)) {
$io->error('Checksum verification failed for the downloaded binary. Update aborted.');
$this->filesystem->remove($tempFile);
return Command::FAILURE;
}
} else {
$io->warning('No checksum available to verify the downloaded binary. Skipping integrity verification.');
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@lyrixx lyrixx Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this one. But this mean the system will be more complexe. We'll need to create signature for each file generated... (see https://github.com/composer/composer/blob/main/src/Composer/Command/SelfUpdateCommand.php#L289)

Maybe we can go with this "simple" architecture cc @joelwurtz @pyrech

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go for a simple self-update command for now 👍

$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;
Comment on lines +143 to +173
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filesystem operations during update (backup copy(), chmod(), rename()) can throw IOExceptionInterface/\Throwable. Right now those exceptions will bubble up, potentially leaving the temp file behind and/or a partially-updated state without a user-friendly message. Wrap the update/replace steps in a try/catch, ensure temp file cleanup in a finally, and consider restoring from the backup if replacement fails.

Suggested change
if (!$input->getOption('no-backup')) {
$backupPath = $currentPath . '.backup';
$io->text(\sprintf('Creating backup at: <comment>%s</comment>', $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;
$backupPath = null;
try {
if (!$input->getOption('no-backup')) {
$backupPath = $currentPath . '.backup';
$io->text(\sprintf('Creating backup at: <comment>%s</comment>', $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.');
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;
} catch (\Throwable $e) {
if (null !== $backupPath && file_exists($backupPath)) {
try {
$this->filesystem->rename($backupPath, $currentPath, true);
} catch (\Throwable $restoreException) {
$io->warning(\sprintf(
'Failed to restore from backup "%s": %s',
$backupPath,
$restoreException->getMessage()
));
}
}
$io->error(\sprintf('Failed to apply update: %s', $e->getMessage()));
return Command::FAILURE;
} finally {
if (file_exists($tempFile)) {
try {
$this->filesystem->remove($tempFile);
} catch (\Throwable $cleanupException) {
$io->warning(\sprintf(
'Failed to remove temporary file "%s": %s',
$tempFile,
$cleanupException->getMessage()
));
}
}
}

Copilot uses AI. Check for mistakes.
}

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: <comment>%s</comment>', $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<string, mixed>|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<string, mixed> $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);

Comment thread
Amoifr marked this conversation as resolved.
return $asset['browser_download_url'] ?? null;
}
}
2 changes: 1 addition & 1 deletion src/Listener/UpdateCastorListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/Generated/LayoutWithFolderTest.php.output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading