diff --git a/CHANGELOG.md b/CHANGELOG.md index 657f8636..585d33a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Add `wait_for_docker_container()` function to wait for a docker container to be ready * Add `AsSymfonyTask` attribute to map Symfony Command * Add a `debug` command +* Add a `compile` command that puts together a customizable PHP binary with a repacked castor app into one executable file * Add `Context->name` property (automatically set by the application) * Edited the duration of update check from `60 days` to `24 hours` * Revise the usage of the terms `command` and `task` for consistency through code and docs. diff --git a/doc/going-further/compile.md b/doc/going-further/compile.md new file mode 100644 index 00000000..c9ff8222 --- /dev/null +++ b/doc/going-further/compile.md @@ -0,0 +1,49 @@ +# Compiling your application into a standalone binary + +[Putting your Castor application into a PHAR archive](repack.md) can be a good way to easily share and use it in various environments. + +However, you need to ensure that PHP is installed and configured correctly in all the environments where you want to use your Castor app. +This can be a hassle, especially if you don't have control over the environments. + +To simplify things, Castor's `compile` command goes further and puts together a customizable PHP binary with your PHAR archive to make one executable file that can be used in any setting. + +## Pre-requisites + +Follow the `repack` command [pre-requistes](repack.md#pre-requisites) instructions + +> [!WARNING] +> Compiling is not supported yet on Windows. + +## Running the Compile Command + +To compile your Castor application, navigate to your project directory and run: + +```bash +vendor/bin/castor compile +``` + +### Options + +Make sure to show the command description to see all the available options: +```bash +vendor/bin/castor compile -h +`````` +### Behavior + +The `compile` command performs several steps: + +1. Executes the `repack` command to build your PHAR archive. +2. Downloads or uses an existing Static PHP CLI tool to compile PHP and the PHAR archive into a binary. +3. If required, it automatically installs dependencies and compiles PHP with the specified extensions. +4. Combines the compiled PHP and your PHAR file into a single executable. + +## Post-Compilation + +Once the compilation is finished, your Castor application is transformed into a standalone binary called `my-app` by default (you can use the `--app-name=` option to change it). +This binary is now ready to be distributed and run in environments that do not have PHP installed. + +You can simply run it like any other executable: + +```bash +./my-app +``` diff --git a/doc/going-further/repack.md b/doc/going-further/repack.md index 79648ff2..313c059d 100644 --- a/doc/going-further/repack.md +++ b/doc/going-further/repack.md @@ -3,6 +3,8 @@ You have created a Castor application, with many tasks, and you want to distribute it as a single phar file? Castor can help you with that. +## Pre-requisites + In your project, install Castor as a dependency: ```bash @@ -18,6 +20,8 @@ configuration. See the [PHP documentation](https://www.php.net/manual/en/phar.configuration.php#ini.phar.readonly) to disabled `phar.readonly`. +## Running the Repack Command + Then, run the repack command to create the new phar: ``` diff --git a/src/Console/Command/CompileCommand.php b/src/Console/Command/CompileCommand.php index 3648e1f6..da2c8d83 100644 --- a/src/Console/Command/CompileCommand.php +++ b/src/Console/Command/CompileCommand.php @@ -3,6 +3,7 @@ namespace Castor\Console\Command; use Castor\PathHelper; +use Castor\PlatformUtil; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\ArrayInput; @@ -20,15 +21,13 @@ */ class CompileCommand extends Command { - private HttpClientInterface $httpClient; - private Filesystem $fs; - - public function __construct() - { + public function __construct( + private HttpClientInterface $httpClient, + readonly private Filesystem $fs = new Filesystem() + ) { parent::__construct(); $this->httpClient = HttpClient::create(); - $this->fs = new Filesystem(); } protected function configure(): void @@ -49,12 +48,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $os = $input->getOption('os'); if ('windows' === $os) { - throw new \RuntimeException('Standalone binary compilation is not yet supported for Windows.'); + throw new \InvalidArgumentException('Standalone binary compilation is not yet supported for Windows.'); } $arch = $input->getOption('arch'); if (!\in_array($arch, ['x86_64', 'aarch64'])) { - throw new \RuntimeException('Target architecture must be one of x86_64 or aarch64'); + throw new \InvalidArgumentException('Target architecture must be one of "x86_64" or "aarch64"'); } $io = new SymfonyStyle($input, $output); @@ -64,64 +63,41 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->section('Compiling PHP and your Castor app PHAR archive into a standalone binary'); - $spcBinaryPath = sys_get_temp_dir() . '/castor-php-static-compiler/spc'; + $spcBinaryPath = PlatformUtil::getCacheDirectory() . '/castor-php-static-compiler/spc'; $spcBinaryDir = \dirname($spcBinaryPath); - if (!$this->fs->exists($spcBinaryDir)) { - $io->text(sprintf('Creating directory "%s" as it does not exist.', $spcBinaryDir)); - $this->fs->mkdir($spcBinaryDir, 0o755); - } - - if (!$this->fs->exists($spcBinaryPath)) { - $spcSourceUrl = sprintf('https://dl.static-php.dev/static-php-cli/spc-bin/nightly/spc-%s-%s', $os, $arch); - $io->text(sprintf('Downloading the static-php-cli (spc) tool from "%s" to "%s"', $spcSourceUrl, $spcBinaryPath)); - $this->downloadSPC($spcSourceUrl, $spcBinaryPath, $output); - $io->newLine(2); - } else { - $io->text(sprintf('Using the static-php-cli (spc) tool from "%s"', $spcBinaryPath)); - } + $this->setupSPC( + $spcBinaryDir, + $spcBinaryPath, + $io, + $os, + $arch, + $output + ); $forcePHPRebuild = $input->getOption('php-rebuild'); if (!$this->fs->exists($spcBinaryDir . '/buildroot/bin/micro.sfx') || $forcePHPRebuild) { - $installSPCDepsProcess = new Process( - command: [ - $spcBinaryPath, 'doctor', - '--auto-fix', - ], - cwd: $spcBinaryDir, - timeout: 5 * 60 - ); - $io->text('Running command: ' . $installSPCDepsProcess->getCommandLine()); - $installSPCDepsProcess->mustRun(fn ($type, $buffer) => print ($buffer)); + $this->installPHPBuildTools($spcBinaryPath, $spcBinaryDir, $io); $phpVersion = $input->getOption('php-version'); $phpExtensions = $input->getOption('php-extensions'); - $downloadProcess = new Process( - command: [ - $spcBinaryPath, 'download', - '--for-extensions=' . $phpExtensions, - '--with-php=' . $phpVersion, - ], - cwd: $spcBinaryDir, - timeout: 5 * 60 + $this->downloadPHPSourceDeps( + $spcBinaryPath, + $phpExtensions, + $phpVersion, + $spcBinaryDir, + $io ); - $io->text('Running command: ' . $downloadProcess->getCommandLine()); - $downloadProcess->mustRun(fn ($type, $buffer) => print ($buffer)); - - $buildProcess = new Process( - command: [ - $spcBinaryPath, 'build', $phpExtensions, - '--build-micro', - '--arch=' . $arch, - '-r', - ], - cwd: $spcBinaryDir, - timeout: 60 * 60 + + $this->buildPHP( + $spcBinaryPath, + $phpExtensions, + $arch, + $spcBinaryDir, + $io ); - $io->text('Running command: ' . $buildProcess->getCommandLine()); - $buildProcess->mustRun(fn ($type, $buffer) => print ($buffer)); } // __DIR__ will be castor-app/vendor/jolicode/castor/src/Console/Command when running castor-app/vendor/bin/castor @@ -129,18 +105,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $appName = $input->getOption('app-name'); $pharFilePath = sprintf('%s/%s.%s.phar', $castorAppSourceDir, $appName, $os); - $buildProcess = new Process( - [ - $spcBinaryPath, - 'micro:combine', $pharFilePath, - '--output=' . $castorAppSourceDir . '/' . $appName, - ], - cwd: $spcBinaryDir + $this->mergePHPandPHARIntoSingleExecutable( + $spcBinaryPath, + $pharFilePath, + $castorAppSourceDir . '/' . $appName, + $spcBinaryDir, + $io ); - $io->text('Running command: ' . $buildProcess->getCommandLine()); - $buildProcess->mustRun(fn ($type, $buffer) => print ($buffer)); - return Command::SUCCESS; } @@ -186,4 +158,78 @@ private function downloadSPC(string $spcSourceUrl, string $spcBinaryDestination, $progressBar->finish(); } + + private function installPHPBuildTools(string $spcBinaryPath, string $spcBinaryDir, SymfonyStyle $io): void + { + $installSPCDepsProcess = new Process( + command: [ + $spcBinaryPath, 'doctor', + '--auto-fix', + ], + cwd: $spcBinaryDir, + timeout: 5 * 60 + ); + $io->text('Running command: ' . $installSPCDepsProcess->getCommandLine()); + $installSPCDepsProcess->mustRun(fn ($type, $buffer) => print ($buffer)); + } + + private function downloadPHPSourceDeps(string $spcBinaryPath, mixed $phpExtensions, mixed $phpVersion, string $spcBinaryDir, SymfonyStyle $io): void + { + $downloadProcess = new Process( + command: [ + $spcBinaryPath, 'download', + '--for-extensions=' . $phpExtensions, + '--with-php=' . $phpVersion, + ], + cwd: $spcBinaryDir, + timeout: 5 * 60 + ); + $io->text('Running command: ' . $downloadProcess->getCommandLine()); + $downloadProcess->mustRun(fn ($type, $buffer) => print ($buffer)); + } + + private function buildPHP(string $spcBinaryPath, mixed $phpExtensions, mixed $arch, string $spcBinaryDir, SymfonyStyle $io): void + { + $buildProcess = new Process( + command: [ + $spcBinaryPath, 'build', $phpExtensions, + '--build-micro', + '--arch=' . $arch, + '-r', + ], + cwd: $spcBinaryDir, + timeout: 60 * 60 + ); + $io->text('Running command: ' . $buildProcess->getCommandLine()); + $buildProcess->mustRun(fn ($type, $buffer) => print ($buffer)); + } + + private function mergePHPandPHARIntoSingleExecutable(string $spcBinaryPath, string $pharFilePath, string $appBinaryFilePath, string $spcBinaryDir, SymfonyStyle $io): void + { + $mergePHPandPHARProcess = new Process( + [ + $spcBinaryPath, + 'micro:combine', $pharFilePath, + '--output=' . $appBinaryFilePath, + ], + cwd: $spcBinaryDir + ); + + $io->text('Running command: ' . $mergePHPandPHARProcess->getCommandLine()); + $mergePHPandPHARProcess->mustRun(fn ($type, $buffer) => print ($buffer)); + } + + private function setupSPC(string $spcBinaryDir, string $spcBinaryPath, SymfonyStyle $io, mixed $os, mixed $arch, OutputInterface $output): void + { + $this->fs->mkdir($spcBinaryDir, 0o755); + + if ($this->fs->exists($spcBinaryPath)) { + $io->text(sprintf('Using the static-php-cli (spc) tool from "%s"', $spcBinaryPath)); + } else { + $spcSourceUrl = sprintf('https://dl.static-php.dev/static-php-cli/spc-bin/nightly/spc-%s-%s', $os, $arch); + $io->text(sprintf('Downloading the static-php-cli (spc) tool from "%s" to "%s"', $spcSourceUrl, $spcBinaryPath)); + $this->downloadSPC($spcSourceUrl, $spcBinaryPath, $output); + $io->newLine(2); + } + } } diff --git a/src/Console/Command/RepackCommand.php b/src/Console/Command/RepackCommand.php index 37fe89e1..29ae1dd6 100644 --- a/src/Console/Command/RepackCommand.php +++ b/src/Console/Command/RepackCommand.php @@ -48,7 +48,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $os = $input->getOption('os'); if (!\in_array($os, ['linux', 'macos', 'windows'])) { - throw new \RuntimeException('The os option must be one of linux, macos or windows.'); + throw new \InvalidArgumentException('The os option must be one of linux, macos or windows.'); } $finder = new ExecutableFinder();