diff --git a/Makefile b/Makefile index 2310ebfdf..0372aee9e 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ SCOPED_BOX_BIN = bin/box.phar TMP_SCOPED_BOX_BIN = bin/_box.phar SCOPED_BOX = $(SCOPED_BOX_BIN) SCOPED_BOX_DEPS = bin/box bin/box.bat $(shell find src res) box.json.dist scoper.inc.php vendor +BOX_EXPECTED_REQUIREMENTS = tests/Build/expected-box-requirements.txt DEFAULT_STUB = dist/default_stub.php @@ -503,6 +504,8 @@ $(SCOPED_BOX_BIN): $(SCOPED_BOX_DEPS) @# Use parallelization $(BOX) compile --ansi + $(BOX) check:requirements bin/box.phar $(BOX_EXPECTED_REQUIREMENTS) + rm $(TMP_SCOPED_BOX_BIN) || true mv -v bin/box.phar $(TMP_SCOPED_BOX_BIN) diff --git a/src/Composer/Artifact/ComposerLock.php b/src/Composer/Artifact/ComposerLock.php index 803e718a2..700ed9e05 100644 --- a/src/Composer/Artifact/ComposerLock.php +++ b/src/Composer/Artifact/ComposerLock.php @@ -20,6 +20,7 @@ use function array_map; /** + * TODO: move it under the Composer namespace. * @private */ final readonly class ComposerLock diff --git a/src/Console/Application.php b/src/Console/Application.php index 92043fe81..9b957a772 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -15,13 +15,15 @@ namespace KevinGH\Box\Console; use Fidry\Console\Application\Application as FidryApplication; +use KevinGH\Box\Console\Command\Check\Requirements as CheckRequirements; use KevinGH\Box\Console\Command\Check\Signature as CheckSignature; use KevinGH\Box\Console\Command\Compile; use KevinGH\Box\Console\Command\Composer\ComposerCheckVersion; use KevinGH\Box\Console\Command\Composer\ComposerVendorDir; use KevinGH\Box\Console\Command\Diff; use KevinGH\Box\Console\Command\Extract; -use KevinGH\Box\Console\Command\GenerateDockerFile; +use KevinGH\Box\Console\Command\Generate\DockerFile as GenerateDockerFile; +use KevinGH\Box\Console\Command\Generate\Requirements as GenerateRequirements; use KevinGH\Box\Console\Command\Info; use KevinGH\Box\Console\Command\Info\Signature as InfoSignature; use KevinGH\Box\Console\Command\Namespace_; @@ -99,11 +101,14 @@ public function getCommands(): array new Info('info:general'), new InfoSignature(), new CheckSignature(), + new CheckRequirements(), new Process(), new Extract(), new Validate(), new Verify(), new GenerateDockerFile(), + new GenerateDockerFile('generate:docker'), + new GenerateRequirements(), new Namespace_(), ]; } diff --git a/src/Console/Command/Check/Requirements.php b/src/Console/Command/Check/Requirements.php new file mode 100644 index 000000000..24c894ad0 --- /dev/null +++ b/src/Console/Command/Check/Requirements.php @@ -0,0 +1,107 @@ + + * Théo Fidry + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace KevinGH\Box\Console\Command\Check; + +use Fidry\Console\Command\Command; +use Fidry\Console\Command\Configuration; +use Fidry\Console\ExitCode; +use Fidry\Console\IO; +use Fidry\FileSystem\FS; +use KevinGH\Box\Phar\PharInfo; +use KevinGH\Box\RequirementChecker\SuccinctRequirementListFactory; +use SebastianBergmann\Diff\Differ; +use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Filesystem\Path; +use function implode; +use function trim; + +/** + * @private + */ +final class Requirements implements Command +{ + private const PHAR_ARG = 'phar'; + private const EXPECTED_REQUIREMENTS_ARG = 'expected-requirements'; + + public function getConfiguration(): Configuration + { + return new Configuration( + 'check:requirements', + 'Displays the hash of the signature', + <<<'HELP' + The %command.name% command will check that the requirements of the provided PHAR + matches the list of provided requirements. + The purpose of this command is to check that the PHAR ships the requirement checker and to keep + track of the extensions required. + + If what you want to do is check if the current environment satisfies the PHAR + requirements simply execute the PHAR instead. + + The requirements should be listed in the file as follows: + + ``` + PHP ^7.2 + ext-phar + ext-xml + ext-filter + ``` + HELP, + [ + new InputArgument( + self::PHAR_ARG, + InputArgument::REQUIRED, + 'The PHAR file.', + ), + new InputArgument( + self::EXPECTED_REQUIREMENTS_ARG, + InputArgument::REQUIRED, + 'Path to a file containing a line return separated list of requirements.', + ), + ], + ); + } + + public function execute(IO $io): int + { + $pharPath = $io->getTypedArgument(self::PHAR_ARG)->asNonEmptyString(); + $expectedRequirementPath = $io->getTypedArgument(self::EXPECTED_REQUIREMENTS_ARG)->asNonEmptyString(); + + $pharPath = Path::canonicalize($pharPath); + + $pharInfo = new PharInfo($pharPath); + + $actualRequirements = trim( + implode( + "\n", + SuccinctRequirementListFactory::create($pharInfo->getRequirements()), + ), + ); + $expectedRequirements = trim( + FS::getFileContents($expectedRequirementPath), + ); + + if ($expectedRequirements === $actualRequirements) { + return ExitCode::SUCCESS; + } + + $differ = new Differ(new UnifiedDiffOutputBuilder()); + $result = $differ->diff($expectedRequirements, $actualRequirements); + + $io->writeln($result); + + return ExitCode::FAILURE; + } +} diff --git a/src/Console/Command/Check/Signature.php b/src/Console/Command/Check/Signature.php index 3bbbfab0f..09ebd42a8 100644 --- a/src/Console/Command/Check/Signature.php +++ b/src/Console/Command/Check/Signature.php @@ -29,7 +29,7 @@ final class Signature implements Command { private const PHAR_ARG = 'phar'; - private const HASH = 'hash'; + private const HASH = 'expected-hash'; public function getConfiguration(): Configuration { diff --git a/src/Console/Command/Compile.php b/src/Console/Command/Compile.php index 73af2b6ee..ee76c396e 100644 --- a/src/Console/Command/Compile.php +++ b/src/Console/Command/Compile.php @@ -33,6 +33,7 @@ use KevinGH\Box\Composer\ComposerProcessFactory; use KevinGH\Box\Composer\Throwable\IncompatibleComposerVersion; use KevinGH\Box\Configuration\Configuration; +use KevinGH\Box\Console\Command\Generate\DockerFile; use KevinGH\Box\Console\Logger\CompilerLogger; use KevinGH\Box\Console\Logger\CompilerPsrLogger; use KevinGH\Box\Console\MessageRenderer; @@ -986,6 +987,6 @@ private function generateDockerFile(IO $io): int private function getDockerCommand(): Command { - return $this->getCommandRegistry()->findCommand(GenerateDockerFile::NAME); + return $this->getCommandRegistry()->findCommand(DockerFile::NAME); } } diff --git a/src/Console/Command/GenerateDockerFile.php b/src/Console/Command/Generate/DockerFile.php similarity index 93% rename from src/Console/Command/GenerateDockerFile.php rename to src/Console/Command/Generate/DockerFile.php index 58a64cd9b..b082a1e3d 100644 --- a/src/Console/Command/GenerateDockerFile.php +++ b/src/Console/Command/Generate/DockerFile.php @@ -12,7 +12,7 @@ * with this source code in the file LICENSE. */ -namespace KevinGH\Box\Console\Command; +namespace KevinGH\Box\Console\Command\Generate; use Fidry\Console\Command\CommandAware; use Fidry\Console\Command\CommandAwareness; @@ -20,7 +20,10 @@ use Fidry\Console\ExitCode; use Fidry\Console\IO; use Fidry\FileSystem\FS; +use KevinGH\Box\Console\Command\Compile; +use KevinGH\Box\Console\Command\ConfigOption; use KevinGH\Box\DockerFileGenerator; +use KevinGH\Box\Phar\PharInfo; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\StringInput; @@ -35,7 +38,7 @@ /** * @private */ -final class GenerateDockerFile implements CommandAware +final class DockerFile implements CommandAware { use CommandAwareness; @@ -44,10 +47,14 @@ final class GenerateDockerFile implements CommandAware private const PHAR_ARG = 'phar'; private const DOCKER_FILE_NAME = 'Dockerfile'; + public function __construct(private readonly string $commandName = self::NAME) + { + } + public function getConfiguration(): Configuration { return new Configuration( - 'docker', + $this->commandName, '🐳 Generates a Dockerfile for the given PHAR', '', [ @@ -83,7 +90,7 @@ public function execute(IO $io): int ); $io->newLine(); - $requirementsFilePhar = 'phar://'.$pharFilePath.'/.box/.requirements.php'; + $requirementsFilePhar = 'phar://'.$pharFilePath.'/'.PharInfo::BOX_REQUIREMENTS; return $this->generateFile( $pharFilePath, diff --git a/src/Console/Command/Generate/Requirements.php b/src/Console/Command/Generate/Requirements.php new file mode 100644 index 000000000..ee65a4e93 --- /dev/null +++ b/src/Console/Command/Generate/Requirements.php @@ -0,0 +1,71 @@ + + * Théo Fidry + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace KevinGH\Box\Console\Command\Generate; + +use Fidry\Console\Command\Command; +use Fidry\Console\Command\Configuration; +use Fidry\Console\ExitCode; +use Fidry\Console\IO; +use KevinGH\Box\Phar\PharInfo; +use KevinGH\Box\RequirementChecker\SuccinctRequirementListFactory; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Filesystem\Path; + +/** + * @private + */ +final class Requirements implements Command +{ + private const PHAR_ARG = 'phar'; + + public function getConfiguration(): Configuration + { + return new Configuration( + 'generate:requirements', + 'Outputs a succinct list of the PHAR requirements', + <<<'HELP' + The %command.name% command will generate a succinct list of the requirements of the PHAR + if it ships the Box's RequirementChecker. + + This command is mostly to generate a list to be able to use it with check:requirements to + keep track of the PHAR requirements. + + If what you want to do is check the more detailed list of the requirements of your PHAR use the + info> command instead. + HELP, + [ + new InputArgument( + self::PHAR_ARG, + InputArgument::REQUIRED, + 'The PHAR file.', + ), + ], + ); + } + + public function execute(IO $io): int + { + $pharPath = $io->getTypedArgument(self::PHAR_ARG)->asNonEmptyString(); + + $pharPath = Path::canonicalize($pharPath); + + $pharInfo = new PharInfo($pharPath); + $requirements = $pharInfo->getRequirements(); + + $io->writeln(SuccinctRequirementListFactory::create($requirements)); + + return ExitCode::SUCCESS; + } +} diff --git a/src/Console/PharInfoRenderer.php b/src/Console/PharInfoRenderer.php index 54cacc2c4..be843909f 100644 --- a/src/Console/PharInfoRenderer.php +++ b/src/Console/PharInfoRenderer.php @@ -21,11 +21,11 @@ use KevinGH\Box\NotInstantiable; use KevinGH\Box\Phar\CompressionAlgorithm; use KevinGH\Box\Phar\PharInfo; +use KevinGH\Box\RequirementChecker\InvalidRequirements; +use KevinGH\Box\RequirementChecker\NoRequirementsFound; use KevinGH\Box\RequirementChecker\Requirement; use KevinGH\Box\RequirementChecker\Requirements; use KevinGH\Box\RequirementChecker\RequirementType; -use KevinGH\Box\RequirementChecker\Throwable\InvalidRequirements; -use KevinGH\Box\RequirementChecker\Throwable\NoRequirementsFound; use SplFileInfo; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Path; diff --git a/src/Phar/PharInfo.php b/src/Phar/PharInfo.php index 184f1deab..8f8832ae2 100644 --- a/src/Phar/PharInfo.php +++ b/src/Phar/PharInfo.php @@ -46,11 +46,10 @@ use Fidry\FileSystem\FS; use KevinGH\Box\Console\Command\Extract; use KevinGH\Box\ExecutableFinder; -use KevinGH\Box\Phar\Throwable\InvalidPhar; +use KevinGH\Box\RequirementChecker\InvalidRequirements; +use KevinGH\Box\RequirementChecker\NoRequirementsFound; use KevinGH\Box\RequirementChecker\Requirement; use KevinGH\Box\RequirementChecker\Requirements; -use KevinGH\Box\RequirementChecker\Throwable\InvalidRequirements; -use KevinGH\Box\RequirementChecker\Throwable\NoRequirementsFound; use OutOfBoundsException; use Phar; use Symfony\Component\Filesystem\Path; diff --git a/src/RequirementChecker/InvalidRequirements.php b/src/RequirementChecker/InvalidRequirements.php new file mode 100644 index 000000000..8e7e5acb0 --- /dev/null +++ b/src/RequirementChecker/InvalidRequirements.php @@ -0,0 +1,36 @@ + + * Théo Fidry + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace KevinGH\Box\RequirementChecker; + +use RuntimeException; +use function get_debug_type; +use function sprintf; + +/** + * @private + */ +final class InvalidRequirements extends RuntimeException +{ + public static function forRequirements(string $file, mixed $value): self + { + return new self( + sprintf( + 'Could not interpret Box\'s RequirementChecker shipped in "%s". Expected an array got "%s".', + $file, + get_debug_type($value), + ), + ); + } +} diff --git a/src/RequirementChecker/NoRequirementsFound.php b/src/RequirementChecker/NoRequirementsFound.php new file mode 100644 index 000000000..1a3242277 --- /dev/null +++ b/src/RequirementChecker/NoRequirementsFound.php @@ -0,0 +1,34 @@ + + * Théo Fidry + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace KevinGH\Box\RequirementChecker; + +use RuntimeException; +use function sprintf; + +/** + * @private + */ +final class NoRequirementsFound extends RuntimeException +{ + public static function forFile(string $file): self + { + return new self( + sprintf( + 'Could not find Box\'s RequirementChecker in "%s".', + $file, + ), + ); + } +} diff --git a/src/RequirementChecker/Requirement.php b/src/RequirementChecker/Requirement.php index fe1d8e3ef..0882c1684 100644 --- a/src/RequirementChecker/Requirement.php +++ b/src/RequirementChecker/Requirement.php @@ -136,4 +136,18 @@ public function toArray(): array 'helpMessage' => $this->helpMessage, ]; } + + /** + * @return string Represents what this requirement is about omitting the source and remedies. + */ + public function toSuccinctDescription(): string + { + $type = match ($this->type) { + RequirementType::PHP => 'req. PHP ', + RequirementType::EXTENSION => 'req. ext-', + RequirementType::EXTENSION_CONFLICT => 'confl. ext-', + }; + + return $type.$this->condition; + } } diff --git a/src/RequirementChecker/SuccinctRequirementListFactory.php b/src/RequirementChecker/SuccinctRequirementListFactory.php new file mode 100644 index 000000000..9f3f1456f --- /dev/null +++ b/src/RequirementChecker/SuccinctRequirementListFactory.php @@ -0,0 +1,50 @@ + + * Théo Fidry + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace KevinGH\Box\RequirementChecker; + +use KevinGH\Box\NotInstantiable; +use function iter\toArray; + +/** + * @private + */ +final class SuccinctRequirementListFactory +{ + use NotInstantiable; + + /** + * Generates a succinct, i.e. a human-readable (still) list of + * the requirements but excluding the sources. + * + * This format is more helpful when interested in the final + * requirement list without caring about the details source as + * the source of the requirement or remedies. + * + * @return list + */ + public static function create(Requirements $requirements): array + { + $succinctRequirements = array_unique( + array_map( + static fn (Requirement $requirement) => $requirement->toSuccinctDescription(), + toArray($requirements), + ), + ); + + ksort($succinctRequirements, SORT_STRING); + + return $succinctRequirements; + } +} diff --git a/tests/Build/expected-box-requirements.txt b/tests/Build/expected-box-requirements.txt new file mode 100644 index 000000000..d648e4896 --- /dev/null +++ b/tests/Build/expected-box-requirements.txt @@ -0,0 +1,7 @@ +req. PHP ^8.2 +req. ext-zlib +req. ext-phar +req. ext-filter +req. ext-openssl +req. ext-tokenizer +confl. ext-psr diff --git a/tests/Console/Command/Check/RequirementsParserTest.php b/tests/Console/Command/Check/RequirementsParserTest.php new file mode 100644 index 000000000..d80ab50aa --- /dev/null +++ b/tests/Console/Command/Check/RequirementsParserTest.php @@ -0,0 +1,29 @@ + + * Théo Fidry + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace KevinGH\Box\Console\Command\Check; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(\KevinGH\Box\Console\Command\Check\RequirementsParser::class)] +final class RequirementsParserTest extends TestCase +{ + public function test_it_can_parse_the_requirements(): void + { + } +} diff --git a/tests/Console/Command/CompileTest.php b/tests/Console/Command/CompileTest.php index 23f0d095e..b009c4e27 100644 --- a/tests/Console/Command/CompileTest.php +++ b/tests/Console/Command/CompileTest.php @@ -25,6 +25,7 @@ use InvalidArgumentException; use KevinGH\Box\Compactor\Php; use KevinGH\Box\Console\Application; +use KevinGH\Box\Console\Command\Generate\DockerFile; use KevinGH\Box\Console\DisplayNormalizer as BoxDisplayNormalizer; use KevinGH\Box\Console\MessageRenderer; use KevinGH\Box\Test\FileSystemTestCase; @@ -159,7 +160,7 @@ protected function setUp(): void $application = new SymfonyApplication(); $application->add($command); - $application->add(new SymfonyCommand(new GenerateDockerFile())); + $application->add(new SymfonyCommand(new DockerFile())); $this->commandTester = new CommandTester( $application->get( diff --git a/tests/Console/Command/GenerateDockerFileTest.php b/tests/Console/Command/Generate/DockerFileTest.php similarity index 72% rename from tests/Console/Command/GenerateDockerFileTest.php rename to tests/Console/Command/Generate/DockerFileTest.php index 68cab04b9..5d150198b 100644 --- a/tests/Console/Command/GenerateDockerFileTest.php +++ b/tests/Console/Command/Generate/DockerFileTest.php @@ -12,11 +12,10 @@ * with this source code in the file LICENSE. */ -namespace KevinGH\Box\Console\Command; +namespace KevinGH\Box\Console\Command\Generate; use Fidry\Console\Command\Command; use Fidry\Console\ExitCode; -use Fidry\FileSystem\FS; use KevinGH\Box\Test\CommandTestCase; use KevinGH\Box\Test\RequiresPharReadonlyOff; use PHPUnit\Framework\Attributes\CoversClass; @@ -25,12 +24,12 @@ /** * @internal */ -#[CoversClass(GenerateDockerFile::class)] -class GenerateDockerFileTest extends CommandTestCase +#[CoversClass(\KevinGH\Box\Console\Command\Generate\DockerFile::class)] +class DockerFileTest extends CommandTestCase { use RequiresPharReadonlyOff; - private const FIXTURES_DIR = __DIR__.'/../../../fixtures/docker'; + private const FIXTURES_DIR = __DIR__.'/../../../../fixtures/docker'; protected function setUp(): void { @@ -41,7 +40,7 @@ protected function setUp(): void protected function getCommand(): Command { - return new GenerateDockerFile(); + return new DockerFile(); } public function test_it_generates_a_dockerfile_for_a_given_phar(): void @@ -67,33 +66,6 @@ public function test_it_generates_a_dockerfile_for_a_given_phar(): void self::assertFileExists($this->tmp.'/Dockerfile'); } - public function test_it_can_generate_a_dockerfile_for_a_given_phar_from_a_different_working_directory(): void - { - $workDir = $this->tmp.'/workdir'; - FS::mkdir($workDir); - - $this->commandTester->execute([ - 'command' => 'docker', - 'phar' => $pharPath = realpath(self::FIXTURES_DIR.'/simple-phar.phar'), - '--working-dir' => $workDir, - ]); - - $expected = <<assertSameOutput($expected, ExitCode::SUCCESS); - - self::assertFileExists($workDir.'/Dockerfile'); - } - public function test_it_cannot_generate_a_dockerfile_for_a_phar_without_requirements(): void { $this->commandTester->execute([