diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 327e219e..22414ed8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,14 @@ jobs: - name: Install PHPUnit run: vendor/bin/simple-phpunit install + # Speed up the CompileCommand test + - name: Cache PHP static building artifacts + uses: actions/cache@v4 + with: + path: | + /tmp/castor-php-static-compiler + key: php-static-build-cache-${{ hashFiles('src/Console/Command/CompileCommand.php', 'tests/CompileCommandTest.php') }} + - name: Run tests run: vendor/bin/simple-phpunit diff --git a/src/Console/ApplicationFactory.php b/src/Console/ApplicationFactory.php index ffb242c4..eec642e4 100644 --- a/src/Console/ApplicationFactory.php +++ b/src/Console/ApplicationFactory.php @@ -2,6 +2,7 @@ namespace Castor\Console; +use Castor\Console\Command\CompileCommand; use Castor\Console\Command\DebugCommand; use Castor\Console\Command\RepackCommand; use Castor\ContextRegistry; @@ -67,6 +68,7 @@ public static function create(): SymfonyApplication if (!class_exists(\RepackedApplication::class)) { $application->add(new RepackCommand()); + $application->add(new CompileCommand()); } return $application; diff --git a/src/Console/Command/CompileCommand.php b/src/Console/Command/CompileCommand.php new file mode 100644 index 00000000..3648e1f6 --- /dev/null +++ b/src/Console/Command/CompileCommand.php @@ -0,0 +1,189 @@ +httpClient = HttpClient::create(); + $this->fs = new Filesystem(); + } + + protected function configure(): void + { + $this->getDefinition()->addOptions(RepackCommand::getOptions()); + + $this + ->setName('compile') + ->addOption('arch', null, InputOption::VALUE_REQUIRED, 'Target architecture for PHP compilation', 'x86_64', ['x86_64', 'aarch64']) + ->addOption('php-version', null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format', '8.2') + ->addOption('php-extensions', null, InputOption::VALUE_REQUIRED, 'PHP extensions required, in a comma-separated format. Defaults are the minimum required to run a basic "Hello World" task in Castor.', 'mbstring,phar,posix,tokenizer') + ->addOption('php-rebuild', null, InputOption::VALUE_NONE, 'Force PHP build compilation.') + ->setHidden(true) + ; + } + + 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.'); + } + + $arch = $input->getOption('arch'); + if (!\in_array($arch, ['x86_64', 'aarch64'])) { + throw new \RuntimeException('Target architecture must be one of x86_64 or aarch64'); + } + + $io = new SymfonyStyle($input, $output); + + $io->section('Executing the repack command to build your Castor app as a PHAR archive.'); + $this->runRepackCommand($input, $output); + + $io->section('Compiling PHP and your Castor app PHAR archive into a standalone binary'); + + $spcBinaryPath = sys_get_temp_dir() . '/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)); + } + + $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)); + + $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 + ); + $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 + ); + $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 + $castorAppSourceDir = PathHelper::realpath(__DIR__ . '/../../../../../../'); + $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 + ); + + $io->text('Running command: ' . $buildProcess->getCommandLine()); + $buildProcess->mustRun(fn ($type, $buffer) => print ($buffer)); + + return Command::SUCCESS; + } + + private function runRepackCommand(InputInterface $input, OutputInterface $output): void + { + $arguments = [ + 'command' => RepackCommand::COMMAND_NAME, + ]; + + foreach (RepackCommand::getOptions() as $option) { + $arguments['--' . $option->getName()] = $input->getOption($option->getName()); + } + + $commandInput = new ArrayInput($arguments); + + if (null === $this->getApplication()) { + throw new \RuntimeException(sprintf('"%s::$application" must be set before running %s', Command::class, __FUNCTION__)); + } + + $command = $this->getApplication()->find(RepackCommand::COMMAND_NAME); + $command->run($commandInput, $output); + } + + private function downloadSPC(string $spcSourceUrl, string $spcBinaryDestination, OutputInterface $output): void + { + $response = $this->httpClient->request('GET', $spcSourceUrl); + $contentLength = $response->getHeaders()['content-length'][0] ?? 0; + + $outputStream = fopen($spcBinaryDestination, 'w'); + $progressBar = new ProgressBar($output, (int) $contentLength); + + if (false === $outputStream) { + throw new \RuntimeException(sprintf('Failed to open file "%s" for writing', $spcBinaryDestination)); + } + + foreach ($this->httpClient->stream($response) as $chunk) { + fwrite($outputStream, $chunk->getContent()); + $progressBar->advance(\strlen($chunk->getContent())); + } + + fclose($outputStream); + chmod($spcBinaryDestination, 0o755); + + $progressBar->finish(); + } +} diff --git a/src/Console/Command/RepackCommand.php b/src/Console/Command/RepackCommand.php index 1cbef599..37fe89e1 100644 --- a/src/Console/Command/RepackCommand.php +++ b/src/Console/Command/RepackCommand.php @@ -16,15 +16,28 @@ */ class RepackCommand extends Command { + public const COMMAND_NAME = 'repack'; + + /** + * @return array + */ + public static function getOptions(): array + { + return [ + new InputOption('app-name', null, InputOption::VALUE_REQUIRED, 'The name of the phar application', 'my-app'), + new InputOption('app-version', null, InputOption::VALUE_REQUIRED, 'The version of the phar application', '1.0.0'), + new InputOption('os', null, InputOption::VALUE_REQUIRED, 'The targeted OS', 'linux', ['linux', 'macos', 'windows']), + ]; + } + protected function configure(): void { $this - ->setName('repack') - ->addOption('app-name', null, InputOption::VALUE_REQUIRED, 'The name of the phar application', 'my-app') - ->addOption('app-version', null, InputOption::VALUE_REQUIRED, 'The version of the phar application', '1.0.0') - ->addOption('os', null, InputOption::VALUE_REQUIRED, 'The targeted OS', 'linux', ['linux', 'macos', 'windows']) + ->setName(self::COMMAND_NAME) ->setHidden(true) ; + + $this->getDefinition()->addOptions(self::getOptions()); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -89,7 +102,7 @@ class RepackedApplication extends Application ), ]; } - // add all files from the FunctionFinder, this is usefull if the file + // add all files from the FunctionFinder, this is useful if the file // are in a hidden directory, because it's not included by default by // box $boxConfig['files'] = [ diff --git a/tests/CompileCommandTest.php b/tests/CompileCommandTest.php new file mode 100644 index 00000000..f1492550 --- /dev/null +++ b/tests/CompileCommandTest.php @@ -0,0 +1,40 @@ +mustRun(); + + $binary = $castorAppDirPath . '/' . $castAppName; + $this->assertFileExists($binary); + + (new Process([$binary], cwd: $castorAppDirPath))->mustRun(); + + $p = (new Process([$binary, 'hello'], cwd: $castorAppDirPath))->mustRun(); + $this->assertSame('hello', $p->getOutput()); + + // Twice, because we want to be sure the phar is not corrupted after a + // run + $p = (new Process([$binary, 'hello'], cwd: $castorAppDirPath))->mustRun(); + $this->assertSame('hello', $p->getOutput()); + } +} diff --git a/tests/RepackCommandTest.php b/tests/RepackCommandTest.php index 474afc53..76cc2ad3 100644 --- a/tests/RepackCommandTest.php +++ b/tests/RepackCommandTest.php @@ -10,12 +10,36 @@ class RepackCommandTest extends TestCase { public function test() { - $tmp = sys_get_temp_dir() . '/castor-test-repack'; + $castorAppDirPath = self::setupRepackedCastorApp('castor-test-repack'); + + (new Process([ + 'vendor/jolicode/castor/bin/castor', + 'repack', + '--os', 'linux', + ], cwd: $castorAppDirPath))->mustRun(); + + $phar = $castorAppDirPath . '/my-app.linux.phar'; + $this->assertFileExists($phar); + + (new Process([$phar], cwd: $castorAppDirPath))->mustRun(); + + $p = (new Process([$phar, 'hello'], cwd: $castorAppDirPath))->mustRun(); + $this->assertSame('hello', $p->getOutput()); + + // Twice, because we want to be sure the phar is not corrupted after a + // run + $p = (new Process([$phar, 'hello'], cwd: $castorAppDirPath))->mustRun(); + $this->assertSame('hello', $p->getOutput()); + } + + public static function setupRepackedCastorApp(string $castorAppDirName): string + { + $castorAppDirPath = sys_get_temp_dir() . '/' . $castorAppDirName; $fs = new Filesystem(); - $fs->remove($tmp); - $fs->mkdir($tmp); - $fs->dumpFile($tmp . '/castor.php', <<<'PHP' + $fs->remove($castorAppDirPath); + $fs->mkdir($castorAppDirPath); + $fs->dumpFile($castorAppDirPath . '/castor.php', <<<'PHP' dumpFile($tmp . '/composer.json', json_encode([ + $fs->dumpFile($castorAppDirPath . '/composer.json', json_encode([ 'repositories' => [ [ 'type' => 'path', @@ -41,27 +65,10 @@ function hello(): void ])); (new Process(['composer', 'install'], - cwd: $tmp, + cwd: $castorAppDirPath, env: ['COMPOSER_MIRROR_PATH_REPOS' => '1'], ))->mustRun(); - (new Process([ - 'vendor/jolicode/castor/bin/castor', - 'repack', - '--os', 'linux', - ], cwd: $tmp))->mustRun(); - - $phar = $tmp . '/my-app.linux.phar'; - $this->assertFileExists($phar); - - (new Process([$phar], cwd: $tmp))->mustRun(); - - $p = (new Process([$phar, 'hello'], cwd: $tmp))->mustRun(); - $this->assertSame('hello', $p->getOutput()); - - // Twice, because we want to be sure the phar is not corrupted after a - // run - $p = (new Process([$phar, 'hello'], cwd: $tmp))->mustRun(); - $this->assertSame('hello', $p->getOutput()); + return $castorAppDirPath; } }