diff --git a/.gitattributes b/.gitattributes
index 1f2ca4f..9b0f066 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,18 +2,16 @@
.github export-ignore
.phive export-ignore
-tests export-ignore
.* export-ignore
-box.json.dist export-ignore
+*.dist export-ignore
+*.yaml export-ignore
+*.xml export-ignore
composer.lock export-ignore
composer-require-* export-ignore
-docker-compose.yaml export-ignore
Makefile export-ignore
-phpunit.xml* export-ignore
-psalm.* export-ignore
-psalm-baseline.xml export-ignore
infection.* export-ignore
-codecov.* export-ignore
+tests export-ignore
+docs export-ignore
resources/mock export-ignore
*.http binary
diff --git a/README.md b/README.md
index 295473e..db6307e 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ composer require internal/dload -W
## Command Line Usage
-DLoad offers two main commands:
+DLoad offers three main commands:
### List Available Software
@@ -54,6 +54,21 @@ This displays a list of all registered software packages with their IDs, names,
DLoad comes with a pre-configured list of popular tools and software packages ready for download.
You can contribute to this list by submitting issues or pull requests to the DLoad repository.
+### Show Downloaded Software
+
+```bash
+# View all downloaded software
+./vendor/bin/dload show
+
+# Show detailed information about specific software
+./vendor/bin/dload show rr
+
+# Show all available software, including those not downloaded
+./vendor/bin/dload show --all
+```
+
+This command displays information about downloaded software.
+
### Download Software
```bash
diff --git a/bin/dload b/bin/dload
index f6c9b5c..3dddb33 100644
--- a/bin/dload
+++ b/bin/dload
@@ -43,6 +43,7 @@ use Symfony\Component\Console\CommandLoader\FactoryCommandLoader;
new FactoryCommandLoader([
Command\Get::getDefaultName() => static fn() => new Command\Get(),
Command\ListSoftware::getDefaultName() => static fn() => new Command\ListSoftware(),
+ Command\Show::getDefaultName() => static fn() => new Command\Show(),
]),
);
$application->setDefaultCommand(Command\Get::getDefaultName(), false);
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index cbb2d80..e37d0a9 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -37,20 +37,26 @@
]]>
-
-
-
-
-
-
-
-
homepage]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -101,6 +107,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/psalm.xml b/psalm.xml
index 48f9878..212781f 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -12,6 +12,11 @@
+
+
+
+
+
diff --git a/resources/software.json b/resources/software.json
index b0b6a56..37a7a50 100644
--- a/resources/software.json
+++ b/resources/software.json
@@ -4,7 +4,8 @@
"alias": "rr",
"binary": {
"name": "rr",
- "pattern": "/^(roadrunner|rr)(?:\\.exe)?$/"
+ "pattern": "/^(roadrunner|rr)(?:\\.exe)?$/",
+ "version-command": "--version"
},
"homepage": "https://roadrunner.dev",
"description": "High-performance PHP application server, load-balancer and process manager written in Golang",
@@ -29,7 +30,8 @@
}
],
"binary": {
- "name": "temporal"
+ "name": "temporal",
+ "version-command": "--version"
}
},
{
@@ -45,7 +47,8 @@
}
],
"binary": {
- "name": "dolt"
+ "name": "dolt",
+ "version-command": "--version"
}
},
{
@@ -75,7 +78,8 @@
}
],
"binary": {
- "name": "protoc"
+ "name": "protoc",
+ "version-command": "--version"
}
},
{
@@ -91,11 +95,12 @@
}
],
"binary": {
- "name": "tigerbeetle"
+ "name": "tigerbeetle",
+ "version-command": "version"
}
},
{
- "name": "CTX ",
+ "name": "CTX",
"alias": "ctx",
"description": "Context generator and MCP server",
"homepage": "https://docs.ctxgithub.com/",
@@ -108,7 +113,25 @@
],
"binary": {
"name": "ctx",
- "pattern": "/^ctx-.*$/"
+ "pattern": "/^ctx-.*$/",
+ "version-command": "--version"
+ }
+ },
+ {
+ "name": "Trap",
+ "alias": "trap",
+ "description": "A minimized version of the Buggregator Server that does not require Docker and is intended solely for local use.\n",
+ "homepage": "https://buggregator.dev/",
+ "repositories": [
+ {
+ "type": "github",
+ "uri": "buggregator/trap",
+ "asset-pattern": "/^trap-.*/"
+ }
+ ],
+ "binary": {
+ "name": "trap",
+ "version-command": "--version"
}
}
]
diff --git a/src/Bootstrap.php b/src/Bootstrap.php
index 3ca60a9..66f356c 100644
--- a/src/Bootstrap.php
+++ b/src/Bootstrap.php
@@ -4,6 +4,8 @@
namespace Internal\DLoad;
+use Internal\DLoad\Module\Binary\BinaryProvider;
+use Internal\DLoad\Module\Binary\Internal\BinaryProviderImpl;
use Internal\DLoad\Module\Common\Architecture;
use Internal\DLoad\Module\Common\Internal\Injection\ConfigLoader;
use Internal\DLoad\Module\Common\Internal\ObjectContainer;
@@ -100,6 +102,10 @@ public function withConfig(
static fn(Container $container): RepositoryProvider => (new RepositoryProvider())
->addRepositoryFactory($container->get(GithubRepositoryFactory::class)),
);
+ $this->container->bind(
+ BinaryProvider::class,
+ static fn(Container $c): BinaryProvider => $c->get(BinaryProviderImpl::class),
+ );
return $this;
}
diff --git a/src/Command/Base.php b/src/Command/Base.php
index c6c7a54..564c4cc 100644
--- a/src/Command/Base.php
+++ b/src/Command/Base.php
@@ -91,7 +91,7 @@ protected function execute(
*
* @return non-empty-string|null Path to the configuration file
*/
- private function getConfigFile(InputInterface $input): ?string
+ protected function getConfigFile(InputInterface $input): ?string
{
/** @var string|null $config */
$config = $input->getOption('config');
diff --git a/src/Command/Show.php b/src/Command/Show.php
new file mode 100644
index 0000000..af8e223
--- /dev/null
+++ b/src/Command/Show.php
@@ -0,0 +1,321 @@
+addArgument(
+ 'software',
+ InputArgument::OPTIONAL,
+ 'Software name to show detailed information about',
+ );
+ $this->addOption(
+ 'all',
+ null,
+ InputOption::VALUE_NONE,
+ 'Show all available software, not just those configured or downloaded',
+ );
+ }
+
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ // Always call parent execute first to initialize services
+ parent::execute($input, $output);
+
+ // Get all software from collection
+ $collection = $this->container->get(SoftwareCollection::class);
+ $binaryProvider = $this->container->get(BinaryProvider::class);
+ $softwareName = (string) $input->getArgument('software');
+
+ // Get configuration if available
+ $configFile = $this->getConfigFile($input);
+ $actions = null;
+
+ if ($configFile !== null) {
+ $actions = $this->container->get(Actions::class);
+ }
+
+ if ($softwareName !== '') {
+ return $this->showSoftwareDetails($softwareName, $collection, $binaryProvider, $actions, $output);
+ }
+
+ return $this->listAllSoftware($collection, $binaryProvider, $actions, $input, $output);
+ }
+
+ private function listAllSoftware(
+ SoftwareCollection $collection,
+ BinaryProvider $binaryProvider,
+ ?Actions $actions,
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $showAll = (bool) $input->getOption('all');
+ $destinationPath = Path::create((string) \getcwd());
+
+ $configSoftwareIds = [];
+ if ($actions !== null) {
+ $configSoftwareIds = \array_map(
+ static fn($download) => $download->software,
+ $actions->downloads,
+ );
+ }
+
+ // Track downloaded software to avoid showing them twice
+ $downloadedSoftwareIds = [];
+
+ // BLOCK 1: Software configured in project
+ if (!empty($configSoftwareIds)) {
+ $output->writeln('Configured software:');
+ $foundConfigured = false;
+
+ foreach ($collection as $software) {
+ if ($software->binary === null) {
+ continue;
+ }
+
+ if (!\in_array($software->getId(), $configSoftwareIds, true)) {
+ continue;
+ }
+
+ $binary = $binaryProvider->getBinary($destinationPath, $software->binary);
+ if ($binary === null) {
+ continue;
+ }
+
+ $foundConfigured = true;
+ $downloadedSoftwareIds[] = $software->getId();
+
+ $output->writeln(\sprintf(
+ ' %s (%s) %s',
+ $software->getId(),
+ $binary->getVersion() ?? 'unknown',
+ $binary->getPath(),
+ ));
+ }
+
+ if (!$foundConfigured) {
+ $output->writeln(' No configured software found');
+ }
+
+ $output->writeln('');
+ }
+
+ // BLOCK 2: Software downloaded but not configured
+ $output->writeln('Downloaded software (not configured):');
+ $foundDownloaded = false;
+
+ foreach ($collection as $software) {
+ if ($software->binary === null) {
+ continue;
+ }
+
+ // Skip software already shown in configured block
+ if (\in_array($software->getId(), $downloadedSoftwareIds, true)) {
+ continue;
+ }
+
+ // Skip software in project config
+ if (\in_array($software->getId(), $configSoftwareIds, true)) {
+ continue;
+ }
+
+ $binary = $binaryProvider->getBinary($destinationPath, $software->binary);
+ if ($binary === null) {
+ continue;
+ }
+
+ $foundDownloaded = true;
+ $downloadedSoftwareIds[] = $software->getId();
+
+ $output->writeln(\sprintf(
+ ' %s (%s) %s',
+ $software->getId(),
+ $binary->getVersion() ?? 'unknown',
+ $binary->getPath(),
+ ));
+ }
+
+ if (!$foundDownloaded) {
+ $output->writeln(' No additional downloaded software found');
+ }
+
+ // BLOCK 3: Other available software (only shown with --all)
+ if ($showAll) {
+ $output->writeln('');
+ $output->writeln('Other available software:');
+ $foundOther = false;
+
+ foreach ($collection as $software) {
+ if ($software->binary === null) {
+ continue;
+ }
+
+ // Skip software already shown in downloaded blocks
+ if (\in_array($software->getId(), $downloadedSoftwareIds, true)) {
+ continue;
+ }
+
+ $foundOther = true;
+
+ $output->writeln(\sprintf(
+ ' %s %s',
+ $software->getId(),
+ $software->description ? '- ' . $software->description : '',
+ ));
+ }
+
+ if (!$foundOther) {
+ $output->writeln(' No other software available');
+ }
+ } else {
+ $output->writeln('');
+ $output->writeln('Use --all flag to show all available software');
+ }
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * @param non-empty-string $softwareName
+ */
+ private function showSoftwareDetails(
+ string $softwareName,
+ SoftwareCollection $collection,
+ BinaryProvider $binaryProvider,
+ ?Actions $actions,
+ OutputInterface $output,
+ ): int {
+ $software = $collection->findSoftware($softwareName);
+
+ if ($software === null) {
+ $output->writeln(\sprintf('Software "%s" not found in registry', $softwareName));
+ return Command::FAILURE;
+ }
+
+ if ($software->binary === null) {
+ $output->writeln(\sprintf('Software "%s" does not have a binary', $softwareName));
+ return Command::FAILURE;
+ }
+
+ $destinationPath = \getcwd();
+
+
+ // Check if software is in project config
+ $inConfig = false;
+ $configConstraints = null;
+ $configExtractPath = null;
+
+ if ($actions !== null) {
+ foreach ($actions->downloads as $download) {
+ if ($download->software === $software->getId()) {
+ $inConfig = true;
+ $configConstraints = $download->version;
+ $configExtractPath = $download->extractPath;
+ break;
+ }
+ }
+ }
+
+ // Display detailed information
+ $output->writeln(\sprintf('Software: %s', $software->name));
+
+ $software->alias === null or $software->alias === $software->name or $output
+ ->writeln(\sprintf('Alias: %s', $software->alias));
+ $software->description and $output
+ ->writeln(\sprintf('Description: %s', $software->description));
+ $software->homepage === null or $output
+ ->writeln(\sprintf('Homepage: %s', $software->homepage));
+
+ // Show project config information
+ $output->writeln('');
+ if ($actions !== null) {
+ if ($inConfig) {
+ $output->writeln('Project configuration: Included in project config');
+
+ if ($configConstraints !== null) {
+ $output->writeln(\sprintf(
+ ' Version constraint: %s',
+ $configConstraints,
+ ));
+ }
+
+ if ($configExtractPath !== null) {
+ $output->writeln(\sprintf(
+ ' Extract path: %s',
+ $configExtractPath,
+ ));
+ }
+ } else {
+ $output->writeln('Project configuration: Not included in project config');
+ }
+ }
+
+ // Binary information
+ $binary = $binaryProvider->getBinary($destinationPath, $software->binary);
+
+ $this->displayBinaryDetails($binary, $output);
+
+ return Command::SUCCESS;
+ }
+
+ private function displayBinaryDetails(?Binary $binary, OutputInterface $output): void
+ {
+ $output->writeln('');
+ if ($binary === null) {
+ $output->writeln('Binary not exists');
+ return;
+ }
+
+ $binaryPath = $binary->getPath();
+
+ $output->writeln('Binary information:');
+ $output->writeln(\sprintf(' Full path: %s', $binaryPath->absolute()));
+ $output->writeln(\sprintf(' Version: %s', $binary->getVersion() ?? 'unknown'));
+ $output->writeln(\sprintf(' Size: %s', $this->formatSize($binary->getSize())));
+
+ $mtime = $binary->getMTime();
+ $mtime === null or $output->writeln(\sprintf(
+ ' Last modified: %s',
+ $mtime->format('Y-m-d H:i:s'),
+ ));
+ }
+
+ private function formatSize(?int $bytes): string
+ {
+ if ($bytes === null) {
+ return 'unknown';
+ }
+
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ $i = 0;
+ while ($bytes >= 1024 && $i < \count($units) - 1) {
+ $bytes /= 1024;
+ $i++;
+ }
+
+ return \sprintf('%.2f %s', $bytes, $units[$i]);
+ }
+}
diff --git a/src/DLoad.php b/src/DLoad.php
index a536758..a682e56 100644
--- a/src/DLoad.php
+++ b/src/DLoad.php
@@ -5,13 +5,13 @@
namespace Internal\DLoad;
use Internal\DLoad\Module\Archive\ArchiveFactory;
+use Internal\DLoad\Module\Binary\BinaryProvider;
use Internal\DLoad\Module\Common\Config\Action\Download as DownloadConfig;
use Internal\DLoad\Module\Common\Config\Embed\File;
use Internal\DLoad\Module\Common\Config\Embed\Software;
use Internal\DLoad\Module\Common\Input\Destination;
use Internal\DLoad\Module\Common\OperatingSystem;
use Internal\DLoad\Module\Downloader\Downloader;
-use Internal\DLoad\Module\Downloader\Internal\BinaryExistenceChecker;
use Internal\DLoad\Module\Downloader\SoftwareCollection;
use Internal\DLoad\Module\Downloader\Task\DownloadResult;
use Internal\DLoad\Module\Downloader\Task\DownloadTask;
@@ -49,7 +49,7 @@ public function __construct(
private readonly ArchiveFactory $archiveFactory,
private readonly Destination $configDestination,
private readonly OutputInterface $output,
- private readonly BinaryExistenceChecker $binaryChecker,
+ private readonly BinaryProvider $binaryProvider,
private readonly OperatingSystem $os,
) {}
@@ -57,7 +57,7 @@ public function __construct(
* Adds a download task to the execution queue.
*
* Creates and schedules a task to download and extract a software package based on the provided action.
- * Skips task creation if binary already exists and force flag is not set.
+ * Skips task creation if binary already exists with a satisfying version and force flag is not set.
*
* @param DownloadConfig $action Download configuration action
* @param bool $force Whether to force download even if binary exists
@@ -70,16 +70,32 @@ public function addTask(DownloadConfig $action, bool $force = false): void
"Software `{$action->software}` not found in registry.",
);
- // Check if binary already exists
+ // Check if binary already exists and satisfies version constraint
$destinationPath = $this->getDestinationPath($action);
- if (!$force && $software->binary !== null && $this->binaryChecker->exists($destinationPath, $software->binary)) {
- $binaryPath = $this->binaryChecker->buildBinaryPath($destinationPath, $software->binary);
- $this->logger->info(
- "Binary {$binaryPath} already exists. Skipping download. Skipping download. Use --force to override.",
- );
-
- // Skip task creation entirely
- return;
+
+ if (!$force && $software->binary !== null) {
+ // Check different constraints
+ $binary = $this->binaryProvider->getBinary($destinationPath, $software->binary);
+
+ // Check if binary exists and satisfies version constraint
+ if ($binary !== null && ($version = $binary->getVersion()) !== null) {
+ if ($action->version !== null && $binary->satisfiesVersion($action->version)) {
+ $this->logger->info(
+ 'Binary `%s` exists with version `%s`, satisfies constraint `%s`. Skipping download. Use `--force` to override.',
+ $binary->getName(),
+ (string) $binary->getVersion(),
+ $action->version,
+ );
+
+ // Skip task creation entirely
+ return;
+ }
+
+ // Download a newer version only if version is specified
+ if ($version) {
+ // todo
+ }
+ }
}
$this->taskManager->addTask(function () use ($software, $action): void {
diff --git a/src/Module/Binary/Binary.php b/src/Module/Binary/Binary.php
new file mode 100644
index 0000000..c4b0579
--- /dev/null
+++ b/src/Module/Binary/Binary.php
@@ -0,0 +1,58 @@
+&1", $output, $returnCode);
+
+ // If command failed, throw exception
+ if ($returnCode !== 0) {
+ throw new BinaryExecutionException(
+ \sprintf(
+ 'Failed to execute binary "%s" with command "%s". Exit code: %d. Output: %s',
+ $binaryPath,
+ $command,
+ $returnCode,
+ \implode("\n", $output),
+ ),
+ );
+ }
+
+ // Return combined output
+ return \implode("\n", $output);
+ }
+}
diff --git a/src/Module/Binary/Internal/BinaryInfo.php b/src/Module/Binary/Internal/BinaryInfo.php
new file mode 100644
index 0000000..0c68a8f
--- /dev/null
+++ b/src/Module/Binary/Internal/BinaryInfo.php
@@ -0,0 +1,108 @@
+name;
+ }
+
+ public function getPath(): Path
+ {
+ return $this->path;
+ }
+
+ public function exists(): bool
+ {
+ return $this->path->exists();
+ }
+
+ /**
+ * @return non-empty-string|null
+ */
+ public function getVersion(): ?string
+ {
+ if ($this->version !== null) {
+ return $this->version === '' ? null : $this->version;
+ }
+
+ if (!$this->exists() || $this->config->versionCommand === null) {
+ return null;
+ }
+
+ try {
+ $output = $this->executor->execute($this->path, $this->config->versionCommand);
+ $this->version = (string) $this->versionResolver->resolveVersion($output);
+ return $this->version === '' ? null : $this->version;
+ } catch (\Throwable) {
+ $this->version = '';
+ return null;
+ }
+ }
+
+ public function satisfiesVersion(string $versionConstraint): ?bool
+ {
+ $version = $this->getVersion();
+ return $version === null
+ ? null
+ : $this->versionComparator->satisfies($version, $versionConstraint);
+ }
+
+ public function getSize(): ?int
+ {
+ if (!$this->exists()) {
+ return null;
+ }
+
+ $size = \filesize((string) $this->path);
+ return $size === false ? null : $size;
+ }
+
+ public function getMTime(): ?\DateTimeImmutable
+ {
+ if (!$this->exists()) {
+ return null;
+ }
+
+ $mtime = \filemtime((string) $this->path);
+ if ($mtime === false) {
+ return null;
+ }
+
+ try {
+ return new \DateTimeImmutable('@' . $mtime);
+ } catch (\Exception) {
+ return null;
+ }
+ }
+}
diff --git a/src/Module/Binary/Internal/BinaryProviderImpl.php b/src/Module/Binary/Internal/BinaryProviderImpl.php
new file mode 100644
index 0000000..d924822
--- /dev/null
+++ b/src/Module/Binary/Internal/BinaryProviderImpl.php
@@ -0,0 +1,56 @@
+buildBinaryPath($destinationPath, $config);
+
+ // Create binary instance
+ $binary = new BinaryInfo(
+ name: $config->name,
+ path: $binaryPath,
+ config: $config,
+ executor: $this->executor,
+ versionResolver: $this->versionResolver,
+ versionComparator: $this->versionComparator,
+ );
+
+ // Return binary only if it exists
+ return $binary->exists() ? $binary : null;
+ }
+
+ /**
+ * Builds the path to a binary without checking if it exists.
+ *
+ * @param Path|non-empty-string $destinationPath Directory path
+ * @param BinaryConfig $config Binary configuration
+ * @internal
+ */
+ private function buildBinaryPath(Path|string $destinationPath, BinaryConfig $config): Path
+ {
+ return Path::create($destinationPath)
+ ->join("{$config->name}{$this->operatingSystem->getBinaryExtension()}");
+ }
+}
diff --git a/src/Module/Binary/Internal/VersionComparator.php b/src/Module/Binary/Internal/VersionComparator.php
new file mode 100644
index 0000000..878f865
--- /dev/null
+++ b/src/Module/Binary/Internal/VersionComparator.php
@@ -0,0 +1,302 @@
+satisfiesCaretRange($version, \substr($constraint, 1));
+ }
+
+ if (\str_starts_with($constraint, '~')) {
+ return $this->satisfiesTildeRange($version, \substr($constraint, 1));
+ }
+
+ if (\str_contains($constraint, ' ')) {
+ return $this->satisfiesExplicitRange($version, $constraint);
+ }
+
+ // Default to exact version comparison for simple constraints
+ return $this->compareVersions($version, $constraint) === 0;
+ }
+
+ /**
+ * Compares two versions and returns comparison result.
+ *
+ * @param string $versionA First version
+ * @param string $versionB Second version
+ * @return int -1 if A < B, 0 if A = B, 1 if A > B
+ */
+ public function compareVersions(string $versionA, string $versionB): int
+ {
+ // Normalize versions
+ $versionA = \ltrim($versionA, 'v');
+ $versionB = \ltrim($versionB, 'v');
+
+ // Extract components
+ $componentsA = $this->extractVersionComponents($versionA);
+ $componentsB = $this->extractVersionComponents($versionB);
+
+ // Compare major, minor, patch
+ for ($i = 0; $i < 3; $i++) {
+ $a = $componentsA[$i] ?? 0;
+ $b = $componentsB[$i] ?? 0;
+
+ if ($a > $b) {
+ return 1;
+ }
+
+ if ($a < $b) {
+ return -1;
+ }
+ }
+
+ // If we reach here, major.minor.patch are equal
+ // Compare pre-release and build metadata
+ return $this->comparePreRelease($versionA, $versionB);
+ }
+
+ /**
+ * Extracts version components from a version string.
+ *
+ * @param string $version Version string
+ * @return array Array of major, minor, patch components
+ */
+ private function extractVersionComponents(string $version): array
+ {
+ // Remove build metadata and pre-release parts
+ $versionOnly = \preg_replace('/[-+].*$/', '', $version);
+
+ // Split into components
+ $parts = \explode('.', $versionOnly);
+
+ // Convert to integers and ensure we have 3 parts
+ return [
+ (int) ($parts[0] ?? 0),
+ (int) ($parts[1] ?? 0),
+ (int) ($parts[2] ?? 0),
+ ];
+ }
+
+ /**
+ * Compares pre-release versions according to semver rules.
+ *
+ * @param string $versionA First version
+ * @param string $versionB Second version
+ * @return int -1 if A < B, 0 if A = B, 1 if A > B
+ */
+ private function comparePreRelease(string $versionA, string $versionB): int
+ {
+ // Extract pre-release parts
+ \preg_match('/-([^+]+)/', $versionA, $preReleaseA);
+ \preg_match('/-([^+]+)/', $versionB, $preReleaseB);
+
+ $hasPreA = isset($preReleaseA[1]);
+ $hasPreB = isset($preReleaseB[1]);
+
+ // No pre-release > Has pre-release
+ if (!$hasPreA && $hasPreB) {
+ return 1;
+ }
+
+ if ($hasPreA && !$hasPreB) {
+ return -1;
+ }
+
+ if (!$hasPreA && !$hasPreB) {
+ return 0;
+ }
+
+ // Both have pre-release identifiers, compare them
+ $identifiersA = \explode('.', $preReleaseA[1]);
+ $identifiersB = \explode('.', $preReleaseB[1]);
+
+ $count = \min(\count($identifiersA), \count($identifiersB));
+
+ for ($i = 0; $i < $count; $i++) {
+ $a = $identifiersA[$i];
+ $b = $identifiersB[$i];
+
+ // Numeric identifiers always have lower precedence than non-numeric
+ $aIsNum = \ctype_digit($a);
+ $bIsNum = \ctype_digit($b);
+
+ if ($aIsNum && !$bIsNum) {
+ return -1;
+ }
+
+ if (!$aIsNum && $bIsNum) {
+ return 1;
+ }
+
+ // If both are numeric or both are non-numeric, compare normally
+ if ($aIsNum && $bIsNum) {
+ $aVal = (int) $a;
+ $bVal = (int) $b;
+
+ if ($aVal > $bVal) {
+ return 1;
+ }
+
+ if ($aVal < $bVal) {
+ return -1;
+ }
+ } else {
+ // Non-numeric comparison
+ $comparison = \strcmp($a, $b);
+
+ if ($comparison !== 0) {
+ return $comparison > 0 ? 1 : -1;
+ }
+ }
+ }
+
+ // If we've compared all common identifiers and they're equal,
+ // the one with more identifiers has lower precedence
+ return \count($identifiersA) <=> \count($identifiersB);
+ }
+
+ /**
+ * Checks if a version satisfies a caret range constraint (^X.Y.Z).
+ * Allows changes that don't modify the most significant non-zero digit.
+ *
+ * @param string $version Version to check
+ * @param string $constraint Base version for the constraint (without ^)
+ * @return bool True if version satisfies constraint
+ */
+ private function satisfiesCaretRange(string $version, string $constraint): bool
+ {
+ $components = $this->extractVersionComponents($constraint);
+
+ // Find the most significant non-zero component
+ $significantIndex = 0;
+ foreach ($components as $i => $value) {
+ if ($value > 0) {
+ $significantIndex = $i;
+ break;
+ }
+ }
+
+ // Create lower bound (same as constraint)
+ $lowerBound = $constraint;
+
+ // Create upper bound by incrementing the significant digit and zeroing others
+ $upperComponents = $components;
+ $upperComponents[$significantIndex]++;
+
+ for ($i = $significantIndex + 1; $i < \count($upperComponents); $i++) {
+ $upperComponents[$i] = 0;
+ }
+
+ $upperBound = \implode('.', $upperComponents);
+
+ // Check if version is in range [lowerBound, upperBound)
+ return $this->compareVersions($version, $lowerBound) >= 0 &&
+ $this->compareVersions($version, $upperBound) < 0;
+ }
+
+ /**
+ * Checks if a version satisfies a tilde range constraint (~X.Y.Z).
+ * Allows patch-level changes if minor version is specified,
+ * minor-level changes if only major version is specified.
+ *
+ * @param string $version Version to check
+ * @param string $constraint Base version for the constraint (without ~)
+ * @return bool True if version satisfies constraint
+ */
+ private function satisfiesTildeRange(string $version, string $constraint): bool
+ {
+ $components = $this->extractVersionComponents($constraint);
+ $versionParts = \explode('.', \preg_replace('/[-+].*$/', '', $constraint));
+
+ // Determine the index to increment based on constraint specificity
+ $incrementIndex = \count($versionParts) > 1 ? 1 : 0;
+
+ // Create lower bound (same as constraint)
+ $lowerBound = $constraint;
+
+ // Create upper bound by incrementing the appropriate digit and zeroing others
+ $upperComponents = $components;
+ $upperComponents[$incrementIndex]++;
+
+ for ($i = $incrementIndex + 1; $i < \count($upperComponents); $i++) {
+ $upperComponents[$i] = 0;
+ }
+
+ $upperBound = \implode('.', $upperComponents);
+
+ // Check if version is in range [lowerBound, upperBound)
+ return $this->compareVersions($version, $lowerBound) >= 0 &&
+ $this->compareVersions($version, $upperBound) < 0;
+ }
+
+ /**
+ * Checks if a version satisfies an explicit range constraint (e.g., ">1.0.0 <2.0.0").
+ *
+ * @param string $version Version to check
+ * @param string $constraint Range constraint
+ * @return bool True if version satisfies constraint
+ */
+ private function satisfiesExplicitRange(string $version, string $constraint): bool
+ {
+ $conditions = \explode(' ', \trim($constraint));
+
+ foreach ($conditions as $condition) {
+ if (empty($condition)) {
+ continue;
+ }
+
+ // Extract the operator and version
+ \preg_match('/^([<>=!~^]+)(.*)$/', $condition, $matches);
+
+ if (!isset($matches[1]) || !isset($matches[2])) {
+ continue;
+ }
+
+ $operator = $matches[1];
+ $conditionVersion = $matches[2];
+
+ $comparison = $this->compareVersions($version, $conditionVersion);
+
+ // Check if condition is met
+ $satisfied = match ($operator) {
+ '>' => $comparison > 0,
+ '>=' => $comparison >= 0,
+ '<' => $comparison < 0,
+ '<=' => $comparison <= 0,
+ '=' => $comparison === 0,
+ '==' => $comparison === 0,
+ '!=' => $comparison !== 0,
+ '^' => $this->satisfiesCaretRange($version, $conditionVersion),
+ '~' => $this->satisfiesTildeRange($version, $conditionVersion),
+ default => false,
+ };
+
+ // If any condition is not satisfied, the range is not satisfied
+ if (!$satisfied) {
+ return false;
+ }
+ }
+
+ // All conditions were satisfied
+ return true;
+ }
+}
diff --git a/src/Module/Binary/Internal/VersionResolver.php b/src/Module/Binary/Internal/VersionResolver.php
new file mode 100644
index 0000000..d96a679
--- /dev/null
+++ b/src/Module/Binary/Internal/VersionResolver.php
@@ -0,0 +1,55 @@
+extractVersionWithFallbacks($output);
+ }
+
+ /**
+ * Attempts to extract version using various fallback patterns.
+ *
+ * @param string $output Output from binary execution
+ * @return string|null Extracted version or null if no version found
+ */
+ private function extractVersionWithFallbacks(string $output): ?string
+ {
+ // Fallback pattern for simple digits-only version (e.g., "2", "15")
+ if (\preg_match('/version:?\s*(\d+)/i', $output, $matches)) {
+ return $matches[1];
+ }
+
+ // Fallback pattern for partial semver (e.g., "2.0")
+ if (\preg_match('/version:?\s*(\d+\.\d+)/i', $output, $matches)) {
+ return $matches[1];
+ }
+
+ // No version could be extracted
+ return null;
+ }
+}
diff --git a/src/Module/Common/FileSystem/Path.php b/src/Module/Common/FileSystem/Path.php
new file mode 100644
index 0000000..e79b851
--- /dev/null
+++ b/src/Module/Common/FileSystem/Path.php
@@ -0,0 +1,246 @@
+path;
+
+ foreach ($paths as $path) {
+ if ($path instanceof self) {
+ $path->isAbsolute and throw new \LogicException('Joining an absolute path is not allowed.');
+ $result .= self::DS . $path->path;
+ continue;
+ }
+
+ if ($path === '') {
+ continue;
+ }
+
+ $path = self::normalizePath($path);
+ self::_isAbsolute($path) and throw new \LogicException('Joining an absolute path is not allowed.');
+
+ $result .= self::DS . $path;
+ }
+
+ // We return the raw string, not a normalized path, since it's already normalized
+ return self::create($result);
+ }
+
+ /**
+ * Return the file name (the final path component)
+ */
+ public function name(): string
+ {
+ $pos = \strrpos($this->path, self::DS);
+ return $pos === false
+ ? $this->path
+ : \substr($this->path, $pos + 1);
+ }
+
+ /**
+ * Return the file stem (the file name without its extension)
+ */
+ public function stem(): string
+ {
+ $name = $this->name();
+ $pos = \strrpos($name, '.');
+ return $pos === false || $pos === 0 ? $name : \substr($name, 0, $pos);
+ }
+
+ /**
+ * Return the file suffix (extension) without the leading dot
+ */
+ public function extension(): string
+ {
+ $name = $this->name();
+
+ return \pathinfo($name, PATHINFO_EXTENSION);
+ }
+
+ /**
+ * Return whether this path is absolute
+ */
+ public function isAbsolute(): bool
+ {
+ return $this->isAbsolute;
+ }
+
+ /**
+ * Return whether this path is relative
+ */
+ public function isRelative(): bool
+ {
+ return !$this->isAbsolute;
+ }
+
+ /**
+ * Check if the path exists.
+ */
+ public function exists(): bool
+ {
+ return \file_exists($this->path);
+ }
+
+ /**
+ * Check if the path is a directory.
+ * True as the result doesn't mean that the directory exists.
+ */
+ public function isDir(): bool
+ {
+ return match (true) {
+ $this->path === '.',
+ $this->path === '..',
+ $this->isAbsolute && \substr($this->path, -2) === self::DS . '.',
+ \is_dir($this->path) => true,
+ default => false,
+ };
+ }
+
+ /**
+ * Check if the path is a file.
+ * True as the result doesn't mean that the file exists.
+ */
+ public function isFile(): bool
+ {
+ return match (true) {
+ $this->path === '.',
+ $this->path === '..',
+ $this->isAbsolute && \substr($this->path, -2) === self::DS . '.' => false,
+ \is_file($this->path) => true,
+ default => false,
+ };
+ }
+
+ /**
+ * Return a normalized absolute version of this path
+ */
+ public function absolute(): self
+ {
+ if ($this->isAbsolute()) {
+ return $this;
+ }
+
+ $cwd = \getcwd();
+ $cwd === false and throw new \RuntimeException('Cannot get current working directory.');
+ return self::create($cwd . self::DS . $this->path);
+ }
+
+ public function __toString(): string
+ {
+ return $this->path;
+ }
+
+ /**
+ * Check if a path is absolute
+ *
+ * @param non-empty-string $path A normalized path
+ */
+ private static function _isAbsolute(string $path): bool
+ {
+ return \preg_match('~^[a-zA-Z]:~', $path) === 1 || \str_starts_with($path, self::DS);
+ }
+
+ /**
+ * Normalize a path by converting directory separators and resolving special path segments
+ *
+ * @return non-empty-string
+ */
+ private static function normalizePath(string $path): string
+ {
+ // Normalize directory separators
+ $path = \trim(\str_replace(['\\', '/'], self::DS, $path));
+
+ // Normalize multiple separators
+ $path = (string) \preg_replace(
+ '~' . \preg_quote(self::DS, '~') . '{2,}~',
+ self::DS,
+ $path,
+ );
+
+ // Empty path becomes current directory
+ if ($path === '') {
+ return '.';
+ }
+
+
+ // Determine if the path is absolute
+ $isAbsolute = self::_isAbsolute($path);
+
+ // Resolve special path segments
+ $parts = \explode(self::DS, $path);
+ /** @var non-empty-string|null $driverLetter */
+
+ if ($isAbsolute && \preg_match('~^([a-zA-Z]):~', $path, $matches) === 1) {
+ // Windows-style path with a drive letter
+ $driverLetter = $matches[1];
+ \array_shift($parts);
+ } else {
+ $driverLetter = null;
+ }
+
+ $result = [];
+ foreach ($parts as $part) {
+ $part = \trim($part, ' ');
+ if ($part === '.' || $part === '') {
+ continue;
+ }
+
+ if ($part === '..') {
+ if ($result !== [] && $result[\array_key_last($result)] !== '..') {
+ \array_pop($result);
+ continue;
+ }
+
+ $isAbsolute and throw new \LogicException("Cannot go up from root in `{$path}`");
+ $result[] = '..';
+ continue;
+ }
+
+ $result[] = $part;
+ }
+
+ // Reconstruct the path
+ $normalizedPath = $result === [] ? '.' : \implode(self::DS, $result);
+
+ // Add an absolute path prefix if necessary
+ if ($isAbsolute) {
+ $normalizedPath = $driverLetter !== null
+ ? "$driverLetter:" . self::DS . $normalizedPath
+ : self::DS . $normalizedPath;
+ }
+
+ return $normalizedPath;
+ }
+}
diff --git a/src/Service/Logger.php b/src/Service/Logger.php
index 9c0add7..c594628 100644
--- a/src/Service/Logger.php
+++ b/src/Service/Logger.php
@@ -89,7 +89,7 @@ public function debug(string $message, string|int|float|bool ...$values): void
*/
public function error(string $message, string|int|float|bool ...$values): void
{
- $this->echo("\033[31m" . \sprintf($message, ...self::values($values)) . "\033[0m\n");
+ $this->echo("\033[31m" . \sprintf($message, ...self::values($values)) . "\033[0m\n", false);
}
/**
diff --git a/tests/Unit/Module/Common/FileSystem/PathTest.php b/tests/Unit/Module/Common/FileSystem/PathTest.php
new file mode 100644
index 0000000..09f297b
--- /dev/null
+++ b/tests/Unit/Module/Common/FileSystem/PathTest.php
@@ -0,0 +1,425 @@
+ ['C:/Users/test', true];
+ yield 'windows drive letter' => ['C:', true];
+ yield 'windows relative path' => ['Users/test', false];
+ yield 'windows implicit relative' => ['./test', false];
+ yield 'unix absolute path' => ['/home/user', true];
+ yield 'unix relative path' => ['home/user', false];
+ yield 'unix implicit relative' => ['./test', false];
+ yield 'dot path' => ['.', false];
+ yield 'double dot path' => ['..', false];
+ }
+
+ public function testCreateReturnsPathInstance(): void
+ {
+ // Arrange & Act
+ $path = Path::create('test/path');
+
+ // Assert
+ self::assertInstanceOf(Path::class, $path);
+ }
+
+ public function testCreateWithEmptyPathReturnsCurrentDirectory(): void
+ {
+ // Arrange & Act
+ $path = Path::create('');
+
+ // Assert
+ self::assertSame('.', (string) $path);
+ }
+
+ public function testCreateNormalizesDirectorySeparators(): void
+ {
+ // Arrange & Act
+ $path = Path::create('test\\path/mixed/separators\\here');
+
+ // Assert
+ self::assertSame('test/path/mixed/separators/here', (string) $path);
+ }
+
+ public function testCreateRemovesMultipleSeparators(): void
+ {
+ // Arrange & Act
+ $path = Path::create('test//path///extra//separators');
+
+ // Assert
+ self::assertSame('test/path/extra/separators', (string) $path);
+ }
+
+ public function testCreateResolvesCurrentDirectorySegments(): void
+ {
+ // Arrange & Act
+ $path = Path::create('test/./path/./current');
+
+ // Assert
+ self::assertSame('test/path/current', (string) $path);
+ }
+
+ public function testCreateResolvesParentDirectorySegments(): void
+ {
+ // Arrange & Act
+ $path = Path::create('test/parent/../path');
+
+ // Assert
+ self::assertSame('test/path', (string) $path);
+ }
+
+ public function testCreateThrowsExceptionForInvalidParentNavigation(): void
+ {
+ // Arrange & Assert
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('Cannot go up from root');
+
+ // Act
+ Path::create('/test/../..');
+ }
+
+ public function testJoinPathComponents(): void
+ {
+ // Arrange
+ $path = Path::create('base/path');
+
+ // Act
+ $result = $path->join('additional', 'components');
+
+ // Assert
+ self::assertSame('base/path/additional/components', (string) $result);
+ }
+
+ public function testJoinWithEmptyComponentsIgnoresThem(): void
+ {
+ // Arrange
+ $path = Path::create('base/path');
+
+ // Act
+ $result = $path->join('', 'component', '');
+
+ // Assert
+ self::assertSame('base/path/component', (string) $result);
+ }
+
+ public function testJoinWithPathObjects(): void
+ {
+ // Arrange
+ $path = Path::create('base/path');
+ $additionalPath = Path::create('additional/path');
+
+ // Assert (prepare for expected exception)
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('Joining an absolute path is not allowed');
+
+ // Act
+ // Using an absolute Path object which should throw
+ $path->join($additionalPath->absolute());
+ }
+
+ public function testJoinWithRelativePathObjects(): void
+ {
+ // Arrange
+ $path = Path::create('base/path');
+ $additionalPath = Path::create('additional/path');
+
+ // Act
+ $result = $path->join($additionalPath);
+
+ // Assert
+ self::assertSame('base/path/additional/path', (string) $result);
+ }
+
+ public function testJoinWithAbsolutePathString(): void
+ {
+ // Arrange
+ $path = Path::create('base/path');
+
+ // Assert (prepare for expected exception)
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('Joining an absolute path is not allowed');
+
+ // Act
+ $path->join('/absolute/path');
+ }
+
+ public function testName(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file.txt');
+
+ // Act
+ $name = $path->name();
+
+ // Assert
+ self::assertSame('file.txt', $name);
+ }
+
+ public function testNameWithNoDirectoryComponents(): void
+ {
+ // Arrange
+ $path = Path::create('file.txt');
+
+ // Act
+ $name = $path->name();
+
+ // Assert
+ self::assertSame('file.txt', $name);
+ }
+
+ public function testStem(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file.txt');
+
+ // Act
+ $stem = $path->stem();
+
+ // Assert
+ self::assertSame('file', $stem);
+ }
+
+ public function testStemWithNoExtension(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file');
+
+ // Act
+ $stem = $path->stem();
+
+ // Assert
+ self::assertSame('file', $stem);
+ }
+
+ public function testStemWithMultipleDots(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file.config.json');
+
+ // Act
+ $stem = $path->stem();
+
+ // Assert
+ self::assertSame('file.config', $stem);
+ }
+
+ public function testStemWithHiddenFile(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/.hidden');
+
+ // Act
+ $stem = $path->stem();
+
+ // Assert
+ self::assertSame('.hidden', $stem);
+ }
+
+ public function testExtension(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file.txt');
+
+ // Act
+ $extension = $path->extension();
+
+ // Assert
+ self::assertSame('txt', $extension);
+ }
+
+ public function testExtensionWithMultipleDots(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file.config.json');
+
+ // Act
+ $extension = $path->extension();
+
+ // Assert
+ self::assertSame('json', $extension);
+ }
+
+ public function testExtensionWithNoExtension(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file');
+
+ // Act
+ $extension = $path->extension();
+
+ // Assert
+ self::assertSame('', $extension);
+ }
+
+ public function testExtensionWithHiddenFile(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/.hidden');
+
+ // Act
+ $extension = $path->extension();
+
+ // Assert
+ self::assertSame('hidden', $extension);
+ }
+
+ #[DataProvider('providePathsForAbsoluteDetection')]
+ public function testIsAbsolute(string $pathString, bool $expected): void
+ {
+ // Arrange
+ $path = Path::create($pathString);
+
+ // Act
+ $isAbsolute = $path->isAbsolute();
+
+ // Assert
+ self::assertSame($expected, $isAbsolute, "Path '$pathString' should be " . ($expected ? 'absolute' : 'relative'));
+ }
+
+ public function testIsRelative(): void
+ {
+ // Arrange
+ $absolutePath = DIRECTORY_SEPARATOR === '\\'
+ ? Path::create('C:/Users/test')
+ : Path::create('/home/user');
+
+ $relativePath = Path::create('relative/path');
+
+ // Act & Assert
+ self::assertFalse($absolutePath->isRelative());
+ self::assertTrue($relativePath->isRelative());
+ }
+
+ /**
+ * This test uses real filesystem access to check if a path exists.
+ * It creates a temporary file and checks its existence.
+ */
+ public function testExists(): void
+ {
+ // Arrange
+ $tempFile = \tempnam(\sys_get_temp_dir(), 'path_test_');
+ self::assertIsString($tempFile, 'Failed to create temp file');
+
+ $path = Path::create($tempFile);
+ $nonExistingPath = Path::create('non/existing/path/file.txt');
+
+ // Act & Assert
+ try {
+ self::assertTrue($path->exists());
+ self::assertFalse($nonExistingPath->exists());
+ } finally {
+ // Clean up
+ @\unlink($tempFile);
+ }
+ }
+
+ /**
+ * Note: This test might have limitations depending on the environment.
+ * It checks the expected behavior of isDir without requiring an actual directory to exist.
+ */
+ public function testIsDir(): void
+ {
+ // Arrange
+ $currentDirPath = Path::create('.');
+ $parentDirPath = Path::create('..');
+ $filePath = Path::create('file.txt');
+
+ // Act & Assert
+ self::assertTrue($currentDirPath->isDir());
+ self::assertTrue($parentDirPath->isDir());
+ self::assertFalse($filePath->isDir());
+ }
+
+ /**
+ * Note: This test might have limitations depending on the environment.
+ * It checks the expected behavior of isFile without requiring an actual file to exist.
+ */
+ public function testIsFile(): void
+ {
+ // Arrange
+ $currentDirPath = Path::create('.');
+ $parentDirPath = Path::create('..');
+ $filePath = Path::create('file.txt');
+
+ // Create a temporary file to test with
+ $tempFile = \tempnam(\sys_get_temp_dir(), 'path_test_');
+ self::assertIsString($tempFile, 'Failed to create temp file');
+ $realFilePath = Path::create($tempFile);
+
+ // Act & Assert
+ try {
+ self::assertFalse($currentDirPath->isFile());
+ self::assertFalse($parentDirPath->isFile());
+ self::assertFalse($filePath->isFile()); // Doesn't exist yet
+ self::assertTrue($realFilePath->isFile(), "Temporary file should be a file `$realFilePath`");
+ } finally {
+ // Clean up
+ @\unlink($tempFile);
+ }
+ }
+
+ public function testAbsoluteForAlreadyAbsolutePath(): void
+ {
+ // Arrange
+ $absolutePath = DIRECTORY_SEPARATOR === '\\'
+ ? Path::create('C:/Users/test')
+ : Path::create('/home/user');
+
+ // Act
+ $result = $absolutePath->absolute();
+
+ // Assert
+ self::assertSame((string) $absolutePath, (string) $result);
+ }
+
+ public function testAbsoluteForRelativePath(): void
+ {
+ // Arrange
+ $relativePath = Path::create('relative/path');
+
+ // Skip this test if we can't get cwd
+ $cwd = \getcwd();
+ if ($cwd === false) {
+ self::markTestSkipped('Cannot get current working directory');
+ }
+
+ $expected = Path::create($cwd . DIRECTORY_SEPARATOR . 'relative/path');
+
+ // Act
+ $result = $relativePath->absolute();
+
+ // Assert
+ self::assertSame((string) $expected, (string) $result);
+ }
+
+ public function testCreateWindowsTmpFile(): void
+ {
+ $path = Path::create('C:\Users\roxbl\AppData\Local\Temp\patB6E7.tmp');
+
+ self::assertSame('C:/Users/roxbl/AppData/Local/Temp/patB6E7.tmp', (string) $path);
+ }
+
+ public function testToString(): void
+ {
+ // Arrange
+ $pathString = 'some/path/file.txt';
+ $path = Path::create($pathString);
+
+ // Act
+ $result = (string) $path;
+
+ // Assert
+ self::assertSame('some/path/file.txt', $result);
+ }
+}