Skip to content

Add self-update command to update Castor to the latest version#800

Open
Amoifr wants to merge 4 commits intojolicode:mainfrom
Amoifr:feat-self-update-command
Open

Add self-update command to update Castor to the latest version#800
Amoifr wants to merge 4 commits intojolicode:mainfrom
Amoifr:feat-self-update-command

Conversation

@Amoifr
Copy link
Copy Markdown
Contributor

@Amoifr Amoifr commented Mar 27, 2026

Closes #538

Description

This PR adds a self-update command to Castor, similar to what Composer offers. It allows users to update Castor directly from the command line without manually downloading and replacing the binary.

Usage

castor self-update

Options

  • --force / -f: Force update even if already up to date
  • --no-backup: Skip creating a backup of the current binary
  • --rollback / -r: Rollback to the previous version

Supported installation methods

Method Behavior
Phar Downloads and replaces the binary
Static Downloads and replaces the binary
Composer Global Runs composer global update jolicode/castor
Composer (local) Shows error with explanation and suggests composer update jolicode/castor
Source Shows error with explanation and suggests git pull

Example output

$ castor self-update

Checking for updates...
=======================

Current version: v1.3.0
Latest version:  v1.4.0

Downloading from: https://github.com/jolicode/castor/releases/...
Creating backup at: /usr/local/bin/castor.backup
Verifying new binary...
Replacing current binary...

 [OK] Castor has been updated from v1.3.0 to v1.4.0!

 ! [NOTE] A backup of the previous version has been saved. Use --rollback to restore it.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a self-update console command to let Castor update itself from GitHub releases (or via composer global update when installed globally), and extends process execution to support providing stdin input via Context.

Changes:

  • Add self-update command with --force, --no-backup, and --rollback options.
  • Add stdin input support for subprocesses via Context::withInput() and ProcessRunner integration.
  • Update docs/examples + changelog to reflect the new functionality.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/Runner/ProcessRunner.php Allows providing stdin to processes and avoids TTY/PTY when input is set.
src/Context.php Adds input to Context plus withInput() builder method.
src/Console/Command/SelfUpdateCommand.php Implements the new self-update command (download/backup/verify/replace + rollback + composer-global path).
src/Console/ApplicationFactory.php Registers SelfUpdateCommand when not running a repacked app.
examples/basic/run/run.php Adds example task demonstrating stdin input.
doc/installation/installer.md Documents self-update usage for installer-based installs.
doc/docs/getting-started/run.md Documents providing stdin input via context.
CHANGELOG.md Adds changelog entry for self-update feature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +151 to +161
$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;
}

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 👍

Comment thread doc/installation/installer.md Outdated
Comment thread CHANGELOG.md
Comment thread src/Console/Command/SelfUpdateCommand.php
Comment on lines +143 to +173
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;
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.
@lyrixx lyrixx force-pushed the feat-self-update-command branch from 2ea94e5 to 6aaa0ac Compare March 30, 2026 14:29
@lyrixx
Copy link
Copy Markdown
Member

lyrixx commented Mar 30, 2026

Caution

I rebased the PR for you

Copy link
Copy Markdown
Member

@pyrech pyrech left a comment

Choose a reason for hiding this comment

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

This PR miss a test for the self-update method, not sure how to achieve this right now though

Comment thread CHANGELOG.md
Comment on lines +151 to +161
$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;
}

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 👍

@Amoifr
Copy link
Copy Markdown
Contributor Author

Amoifr commented Mar 30, 2026

Hi @lyrixx! Quick question about array_first() — it's a PHP 8.4 function, but Castor currently requires PHP >= 8.2. Should I use array_key_first() instead (available since PHP 7.3), or is there a planned bump to 8.4 I'm not aware of? 🙂

$asset = $assets[array_key_first($assets)] ?? null;

@Amoifr
Copy link
Copy Markdown
Contributor Author

Amoifr commented Mar 30, 2026

Just pushed a fix addressing the review feedback:

  • CHANGELOG: restored the missing input option entry (was accidentally replaced instead of added)
  • Doc: clarified that self-update works for Composer global installs (runs composer global update under the hood), but not for project dependencies or source installs

Still waiting on clarification about array_first() vs array_key_first() before tackling that one 👆

@lyrixx
Copy link
Copy Markdown
Member

lyrixx commented Mar 30, 2026

it's a PHP 8.4 function, but Castor currently requires PHP >= 8.2.

You can install the symfony polyfill https://symfony.com/packages/polyfill-php84

@Amoifr
Copy link
Copy Markdown
Contributor Author

Amoifr commented Mar 30, 2026

@lyrixx Thanks for the suggestion! Just to clarify: using array_first() would require adding symfony/polyfill-php84 as a Castor dependency, otherwise PHP 8.2/8.3 users would get a runtime error.

Would you prefer:

  1. Adding the polyfill dependency
  2. Or sticking with array_key_first() which works natively since PHP 7.3?

I'm fine with either approach, just want to confirm! 🙂

@lyrixx
Copy link
Copy Markdown
Member

lyrixx commented Mar 30, 2026

@Amoifr
Copy link
Copy Markdown
Contributor Author

Amoifr commented Mar 30, 2026

Done! Added symfony/polyfill-php84 as a dependency and updated both occurrences to use array_first():

  • src/Console/Command/SelfUpdateCommand.php
  • src/Listener/UpdateCastorListener.php

This way PHP 8.2/8.3 users get the polyfill, and we're ready for PHP 8.4 natively. 🎉

@lyrixx
Copy link
Copy Markdown
Member

lyrixx commented Mar 30, 2026

All the tests are broken. Could you please ask your coding agent to be much less verbose? Thanks!

@Amoifr
Copy link
Copy Markdown
Contributor Author

Amoifr commented Mar 31, 2026

Done, Thx! 🙂

Though I regenerated the tests locally - not 100% sure the expected outputs will match the CI environment. Let's see how the checks go. 🤞

Amoifr added 4 commits April 15, 2026 11:05
Features:
- Download and replace binary for Phar/Static installations
- Run composer global update for ComposerGlobal installations
- Create backup before update (can be disabled with --no-backup)
- Rollback to previous version with --rollback
- Clear error messages for unsupported installation methods (Composer, Source)

Closes jolicode#538
- Rename duplicate "Options" heading to "Self-update options"
- Regenerate test expected outputs to include self-update command
@Amoifr Amoifr force-pushed the feat-self-update-command branch from f4326cf to d6a16f3 Compare April 15, 2026 09:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Introduce self-update command like Composer does

4 participants