diff --git a/.vortex/installer/src/Command/InstallCommand.php b/.vortex/installer/src/Command/InstallCommand.php index a67f1fd2b..456ef979e 100644 --- a/.vortex/installer/src/Command/InstallCommand.php +++ b/.vortex/installer/src/Command/InstallCommand.php @@ -4,6 +4,7 @@ namespace DrevOps\VortexInstaller\Command; +use DrevOps\VortexInstaller\Downloader\Artifact; use DrevOps\VortexInstaller\Downloader\Downloader; use DrevOps\VortexInstaller\Downloader\RepositoryDownloader; use DrevOps\VortexInstaller\Prompts\Handlers\Starter; @@ -85,6 +86,11 @@ class InstallCommand extends Command implements CommandRunnerAwareInterface, Exe */ protected ?Downloader $fileDownloader = NULL; + /** + * The artifact representing the repository and reference to install. + */ + protected Artifact $artifact; + /** * {@inheritdoc} */ @@ -92,20 +98,26 @@ protected function configure(): void { $this->setName('install'); $this->setDescription('Install Vortex from remote or local repository.'); $this->setHelp(<<Interactively install Vortex from the latest stable release into the current directory: + php installer.php --destination=. + + Non-interactively install Vortex from the latest stable release into the specified directory: + php installer.php --no-interaction --destination=path/to/destination + + Install from the latest auto-discovered stable release (default behavior if --uri is specified): + php installer.php --uri=https://github.com/drevops/vortex.git + php installer.php --uri=https://github.com/drevops/vortex.git#stable + + Install using repository URL with specific git ref after #: + php installer.php --uri=https://github.com/drevops/vortex.git#25.11.0 + php installer.php --uri=https://github.com/drevops/vortex.git#v1.2.3 + php installer.php --uri=https://github.com/drevops/vortex.git#main + + Copy GitHub URL directly from your browser: + php installer.php --uri=https://github.com/drevops/vortex/releases/tag/25.11.0 + php installer.php --uri=https://github.com/drevops/vortex/tree/1.2.3 + php installer.php --uri=https://github.com/drevops/vortex/tree/main + php installer.php --uri=https://github.com/drevops/vortex/commit/abcd123 EOF ); $this->addOption(static::OPTION_DESTINATION, NULL, InputOption::VALUE_REQUIRED, 'Destination directory. Defaults to the current directory.'); @@ -138,6 +150,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->header(); + // Only validate if using custom repository or custom reference. + if (!$this->artifact->isDefault()) { + Task::action( + label: 'Validating repository and reference', + action: function (): string { + $this->getRepositoryDownloader()->validate($this->artifact); + return 'Repository and reference validated successfully'; + }, + hint: fn(): string => sprintf('Checking repository "%s" and reference "%s"', $this->artifact->getRepo(), $this->artifact->getRef()), + success: fn(string $return): string => $return + ); + Tui::line(''); + } + + Tui::line(Tui::dim('Press any key to continue...')); + Tui::getChar(); + $this->promptManager->runPrompts(); Tui::list($this->promptManager->getResponsesSummary(), 'Installation summary'); @@ -153,11 +182,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int Task::action( label: 'Downloading Vortex', action: function (): string { - $version = $this->getRepositoryDownloader()->download($this->config->get(Config::REPO), $this->config->get(Config::REF), $this->config->get(Config::TMP)); + $version = $this->getRepositoryDownloader()->download($this->artifact, $this->config->get(Config::TMP)); $this->config->set(Config::VERSION, $version); return $version; }, - hint: fn(): string => sprintf('Downloading from "%s" repository at commit "%s"', ...RepositoryDownloader::parseUri($this->config->get(Config::REPO))), + hint: fn(): string => sprintf('Downloading from "%s" repository at ref "%s"', $this->artifact->getRepo(), $this->artifact->getRef()), success: fn(string $return): string => sprintf('Vortex downloaded (%s)', $return) ); @@ -301,9 +330,25 @@ protected function resolveOptions(array $arguments, array $options): void { Env::putFromDotenv($dest_env_file); } - [$repo, $ref] = RepositoryDownloader::parseUri($options[static::OPTION_URI] ?: 'https://github.com/drevops/vortex.git@stable'); - $this->config->set(Config::REPO, $repo); - $this->config->set(Config::REF, $ref); + // Build URI for artifact. + $uri_from_option = !empty($options[static::OPTION_URI]) && is_scalar($options[static::OPTION_URI]) ? strval($options[static::OPTION_URI]) : NULL; + $repo = Env::get(Config::REPO) ?: ($this->config->get(Config::REPO) ?: NULL); + $ref = Env::get(Config::REF) ?: ($this->config->get(Config::REF) ?: NULL); + + // Priority: option URI > env/config repo+ref > default. + $uri = $uri_from_option; + if (!$uri && $repo) { + $uri = $ref ? $repo . '#' . $ref : $repo; + } + + try { + $this->artifact = Artifact::fromUri($uri); + $this->config->set(Config::REPO, $this->artifact->getRepo()); + $this->config->set(Config::REF, $this->artifact->getRef()); + } + catch (\RuntimeException $e) { + throw new \RuntimeException(sprintf('Invalid repository URI: %s', $e->getMessage()), $e->getCode(), $e); + } // Check if the project is a Vortex project. $this->config->set(Config::IS_VORTEX_PROJECT, File::contains($this->config->getDst() . DIRECTORY_SEPARATOR . 'README.md', '/badge\/Vortex-/')); @@ -508,15 +553,14 @@ protected function header(): void { $title = 'Welcome to the Vortex interactive installer'; $content = ''; - $ref = $this->config->get(Config::REF); - if ($ref == RepositoryDownloader::REF_STABLE) { + if ($this->artifact->isStable()) { $content .= 'This tool will guide you through installing the latest ' . Tui::underscore('stable') . ' version of Vortex into your project.' . PHP_EOL; } - elseif ($ref == RepositoryDownloader::REF_HEAD) { + elseif ($this->artifact->isDevelopment()) { $content .= 'This tool will guide you through installing the latest ' . Tui::underscore('development') . ' version of Vortex into your project.' . PHP_EOL; } else { - $content .= sprintf('This tool will guide you through installing a ' . Tui::underscore('custom') . ' version of Vortex into your project at commit "%s".', $ref) . PHP_EOL; + $content .= sprintf('This tool will guide you through installing a ' . Tui::underscore('custom') . ' version of Vortex into your project at commit "%s".', $this->artifact->getRef()) . PHP_EOL; } $content .= PHP_EOL; @@ -548,9 +592,6 @@ protected function header(): void { } Tui::box($content, $title); - - Tui::line(Tui::dim('Press any key to continue...')); - Tui::getChar(); } public function footer(): void { diff --git a/.vortex/installer/src/Downloader/Artifact.php b/.vortex/installer/src/Downloader/Artifact.php new file mode 100644 index 000000000..7f6821b6d --- /dev/null +++ b/.vortex/installer/src/Downloader/Artifact.php @@ -0,0 +1,276 @@ +repo; + } + + /** + * Get git reference. + */ + public function getRef(): string { + return $this->ref; + } + + /** + * Get normalized repository URL (without .git extension). + */ + public function getRepoUrl(): string { + return self::normalizeRepoUrl($this->repo); + } + + /** + * Check if this is a remote repository (not local path). + */ + public function isRemote(): bool { + // Check for scp-style git URL (git@host:path). + if (str_starts_with($this->repo, 'git@')) { + return TRUE; + } + + // Check for URLs with schemes. + $parsed = parse_url($this->repo); + if ($parsed !== FALSE && isset($parsed['scheme'])) { + $scheme = strtolower($parsed['scheme']); + return in_array($scheme, ['http', 'https', 'ssh', 'git'], TRUE); + } + + return FALSE; + } + + /** + * Check if this is a local repository (not remote URL). + */ + public function isLocal(): bool { + return !$this->isRemote(); + } + + /** + * Check if this artifact uses default repository and reference. + */ + public function isDefault(): bool { + // Check if using default repository (with or without .git). + $default_repo_without_git = self::normalizeRepoUrl(RepositoryDownloader::DEFAULT_REPO); + $is_default_repo = ($this->repo === RepositoryDownloader::DEFAULT_REPO || $this->repo === $default_repo_without_git); + + // Check if using default reference. + $is_default_ref = ($this->ref === RepositoryDownloader::REF_STABLE || $this->ref === RepositoryDownloader::REF_HEAD); + + return $is_default_repo && $is_default_ref; + } + + /** + * Check if this artifact uses the stable reference. + */ + public function isStable(): bool { + return $this->ref === RepositoryDownloader::REF_STABLE; + } + + /** + * Check if this artifact uses the development reference (HEAD). + */ + public function isDevelopment(): bool { + return $this->ref === RepositoryDownloader::REF_HEAD; + } + + /** + * Parse URI into repository and reference. + */ + protected static function parseUri(string $src): array { + // @todo Remove @ref syntax support in 1.1.0 - use #ref instead. + // Support deprecated @ref syntax by converting to #ref. + // Match @ref at the end of URL, but not @ that's part of user@host. + // For ssh:// and git://, @ in user@host comes before /, so @ref is + // after last /. + // For git@host:path, the host@ is before :, so @ref is after :. + if (preg_match('~^(https?://[^#]+?)@([^/@]+)$~', $src, $matches)) { + // https://example.com/repo@ref. + $src = $matches[1] . '#' . $matches[2]; + } + elseif (preg_match('~^((?:ssh|git)://(?:[^@/]+@)?[^#/]+/.+?)@([^/@]+)$~', $src, $matches)) { + // ssh://git@host/path@ref or git://host/path@ref. + $src = $matches[1] . '#' . $matches[2]; + } + elseif (preg_match('~^(git@[^:]+:.+?)@([^/@]+)$~', $src, $matches)) { + // git@host:path@ref. + $src = $matches[1] . '#' . $matches[2]; + } + + // Try GitHub-specific patterns first. + $github_pattern = self::detectGitHubUrlPattern($src); + if ($github_pattern !== NULL) { + [$repo, $ref] = $github_pattern; + + // Validate the extracted ref. + if (!Validator::gitRef($ref)) { + throw new \RuntimeException(sprintf('Invalid git reference: "%s". Reference must be a valid git tag, branch, or commit hash.', $ref)); + } + + return [$repo, $ref]; + } + + // Fall back to #ref parsing (standard git reference syntax). + if (str_starts_with($src, 'https://') || str_starts_with($src, 'http://')) { + if (!preg_match('~^(https?://[^/]+/[^/]+/[^#]+)(?:#(.+))?$~', $src, $matches)) { + throw new \RuntimeException(sprintf('Invalid remote repository format: "%s". Use # to specify a reference (e.g., repo.git#tag).', $src)); + } + $repo = $matches[1]; + $ref = $matches[2] ?? RepositoryDownloader::REF_HEAD; + } + elseif (str_starts_with($src, 'ssh://') || str_starts_with($src, 'git://')) { + if (!preg_match('~^((?:ssh|git)://(?:[^@/]+@)?[^#/]+/.+?)(?:#(.+))?$~', $src, $matches)) { + throw new \RuntimeException(sprintf('Invalid remote repository format: "%s". Use # to specify a reference (e.g., git://host/repo#tag).', $src)); + } + $repo = $matches[1]; + $ref = $matches[2] ?? RepositoryDownloader::REF_HEAD; + } + elseif (str_starts_with($src, 'git@')) { + if (!preg_match('~^(git@[^:]+:[^#]+)(?:#(.+))?$~', $src, $matches)) { + throw new \RuntimeException(sprintf('Invalid remote repository format: "%s". Use # to specify a reference (e.g., git@host:repo#tag).', $src)); + } + $repo = $matches[1]; + $ref = $matches[2] ?? RepositoryDownloader::REF_HEAD; + } + elseif (str_starts_with($src, 'file://')) { + if (!preg_match('~^file://(.+?)(?:#(.+))?$~', $src, $matches)) { + throw new \RuntimeException(sprintf('Invalid local repository format: "%s". Use # to specify a reference.', $src)); + } + $repo = $matches[1]; + $ref = $matches[2] ?? RepositoryDownloader::REF_HEAD; + } + else { + if (!preg_match('~^(.+?)(?:#(.+))?$~', $src, $matches)) { + throw new \RuntimeException(sprintf('Invalid local repository format: "%s". Use # to specify a reference.', $src)); + } + $repo = rtrim($matches[1], '/'); + $ref = $matches[2] ?? RepositoryDownloader::REF_HEAD; + } + + if (!Validator::gitRef($ref)) { + throw new \RuntimeException(sprintf('Invalid git reference: "%s". Reference must be a valid git tag, branch, or commit hash.', $ref)); + } + + return [$repo, $ref]; + } + + /** + * Detect and parse GitHub-specific URL patterns. + * + * Supports direct GitHub URLs copied from browser. + */ + protected static function detectGitHubUrlPattern(string $uri): ?array { + // Pattern 1: /releases/tag/{ref} + // Example: https://github.com/drevops/vortex/releases/tag/25.11.0 + if (preg_match('#^(https://github\.com/[^/]+/[^/]+)/releases/tag/(.+)$#', $uri, $matches)) { + return [$matches[1], $matches[2]]; + } + + // Pattern 2: /tree/{ref} + // Example: https://github.com/drevops/vortex/tree/1.2.3 + if (preg_match('#^(https://github\.com/[^/]+/[^/]+)/tree/(.+)$#', $uri, $matches)) { + return [$matches[1], $matches[2]]; + } + + // Pattern 3: /commit/{ref} + // Example: https://github.com/drevops/vortex/commit/abcd123 + if (preg_match('#^(https://github\.com/[^/]+/[^/]+)/commit/(.+)$#', $uri, $matches)) { + return [$matches[1], $matches[2]]; + } + + // Pattern 4: .git#{ref} (HTTPS) - alternative to @ syntax + // Example: https://github.com/drevops/vortex.git#25.11.0 + if (preg_match('~^(https://[^#]+\.git)#(.+)$~', $uri, $matches)) { + return [$matches[1], $matches[2]]; + } + + // Pattern 5: git@...#{ref} (SSH) - alternative to @ syntax + // Example: git@github.com:drevops/vortex#stable. + if (preg_match('~^(git@[^#]+)#(.+)$~', $uri, $matches)) { + return [$matches[1], $matches[2]]; + } + + return NULL; + } + + /** + * Normalize repository URL by stripping trailing .git extension. + */ + protected static function normalizeRepoUrl(string $repo): string { + return str_ends_with($repo, '.git') ? substr($repo, 0, -4) : $repo; + } + +} diff --git a/.vortex/installer/src/Downloader/RepositoryDownloader.php b/.vortex/installer/src/Downloader/RepositoryDownloader.php index 645b3cf8e..225bbcc2e 100644 --- a/.vortex/installer/src/Downloader/RepositoryDownloader.php +++ b/.vortex/installer/src/Downloader/RepositoryDownloader.php @@ -6,7 +6,6 @@ use DrevOps\VortexInstaller\Utils\Env; use DrevOps\VortexInstaller\Utils\Git; -use DrevOps\VortexInstaller\Utils\Validator; use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\RequestException; @@ -20,6 +19,8 @@ class RepositoryDownloader implements RepositoryDownloaderInterface { const REF_STABLE = 'stable'; + const DEFAULT_REPO = 'https://github.com/drevops/vortex.git'; + /** * Constructs a new RepositoryDownloader instance. * @@ -44,12 +45,12 @@ public function __construct( ) { } - public function download(string $repo, string $ref, ?string $dst = NULL): string { - if (str_starts_with($repo, 'https://') || str_starts_with($repo, 'git@')) { - $version = $this->downloadFromRemote($repo, $ref, $dst); + public function download(Artifact $artifact, ?string $dst = NULL): string { + if ($artifact->isRemote()) { + $version = $this->downloadFromRemote($artifact, $dst); } else { - $version = $this->downloadFromLocal($repo, $ref, $dst); + $version = $this->downloadFromLocal($artifact, $dst); } if (!is_readable($dst . DIRECTORY_SEPARATOR . 'composer.json')) { @@ -59,51 +60,53 @@ public function download(string $repo, string $ref, ?string $dst = NULL): string return $version; } - public static function parseUri(string $src): array { - if (str_starts_with($src, 'https://')) { - if (!preg_match('#^(https://[^/]+/[^/]+/[^@]+)(?:@(.+))?$#', $src, $matches)) { - throw new \RuntimeException(sprintf('Invalid remote repository format: "%s".', $src)); - } - $repo = $matches[1]; - $ref = $matches[2] ?? static::REF_HEAD; - } - elseif (str_starts_with($src, 'git@')) { - if (!preg_match('#^(git@[^:]+:[^/]+/[^@]+)(?:@(.+))?$#', $src, $matches)) { - throw new \RuntimeException(sprintf('Invalid remote repository format: "%s".', $src)); - } - $repo = $matches[1]; - $ref = $matches[2] ?? static::REF_HEAD; - } - elseif (str_starts_with($src, 'file://')) { - if (!preg_match('#^file://(.+?)(?:@(.+))?$#', $src, $matches)) { - throw new \RuntimeException(sprintf('Invalid local repository format: "%s".', $src)); + /** + * Validate repository and reference exist. + * + * @param \DrevOps\VortexInstaller\Downloader\Artifact $artifact + * The artifact to validate. + * + * @throws \RuntimeException + * If validation fails. + */ + public function validate(Artifact $artifact): void { + // Determine if this is a remote or local repository. + if ($artifact->isRemote()) { + // Remote repository. + $repo_url = $artifact->getRepoUrl(); + + // Validate repository exists. + $this->validateRemoteRepositoryExists($repo_url); + + // Validate ref exists (skip for special refs). + if ($artifact->getRef() !== self::REF_STABLE && $artifact->getRef() !== self::REF_HEAD) { + $this->validateRemoteRefExists($repo_url, $artifact->getRef()); } - $repo = $matches[1]; - $ref = $matches[2] ?? static::REF_HEAD; } else { - if (!preg_match('#^(.+?)(?:@(.+))?$#', $src, $matches)) { - throw new \RuntimeException(sprintf('Invalid local repository format: "%s".', $src)); + // Local repository. + // Validate repository exists. + $this->validateLocalRepositoryExists($artifact->getRepo()); + + // Validate ref exists (skip for HEAD). + $actual_ref = $artifact->getRef() === self::REF_STABLE ? self::REF_HEAD : $artifact->getRef(); + if ($actual_ref !== self::REF_HEAD) { + $this->validateLocalRefExists($artifact->getRepo(), $actual_ref); } - $repo = rtrim($matches[1], '/'); - $ref = $matches[2] ?? static::REF_HEAD; - } - - if ($ref != static::REF_STABLE && $ref != static::REF_HEAD && !Validator::gitCommitSha($ref) && !Validator::gitCommitShaShort($ref)) { - throw new \RuntimeException(sprintf('Invalid reference format: "%s". Supported formats are: %s, %s, %s, %s.', $ref, static::REF_STABLE, static::REF_HEAD, '40-character commit hash', '7-character commit hash')); } - - return [$repo, $ref]; } - protected function downloadFromRemote(string $repo, string $ref, ?string $destination): string { + protected function downloadFromRemote(Artifact $artifact, ?string $destination): string { if ($destination === NULL) { throw new \InvalidArgumentException('Destination cannot be null for remote downloads.'); } - $repo_url = str_ends_with($repo, '.git') ? substr($repo, 0, -4) : $repo; + $repo_url = $artifact->getRepoUrl(); - $version = $ref; - if ($ref === RepositoryDownloader::REF_STABLE) { + // Validate repository exists before proceeding. + $this->validateRemoteRepositoryExists($repo_url); + + $version = $artifact->getRef(); + if ($artifact->getRef() === RepositoryDownloader::REF_STABLE) { $ref = $this->discoverLatestReleaseRemote($repo_url); if ($ref === NULL) { @@ -112,9 +115,15 @@ protected function downloadFromRemote(string $repo, string $ref, ?string $destin $version = $ref; } - elseif ($ref === RepositoryDownloader::REF_HEAD) { + elseif ($artifact->getRef() === RepositoryDownloader::REF_HEAD) { + $ref = $artifact->getRef(); $version = 'develop'; } + else { + $ref = $artifact->getRef(); + // Validate ref exists for non-special refs. + $this->validateRemoteRefExists($repo_url, $ref); + } $url = sprintf('%s/archive/%s.tar.gz', $repo_url, $ref); @@ -126,23 +135,30 @@ protected function downloadFromRemote(string $repo, string $ref, ?string $destin return $version; } - protected function downloadFromLocal(string $repo, string $ref, ?string $destination): string { + protected function downloadFromLocal(Artifact $artifact, ?string $destination): string { if ($destination === NULL) { throw new \InvalidArgumentException('Destination cannot be null for local downloads.'); } - $ref = $ref === RepositoryDownloader::REF_STABLE ? RepositoryDownloader::REF_HEAD : $ref; + // Validate local repository exists. + $this->validateLocalRepositoryExists($artifact->getRepo()); + + $ref = $artifact->getRef() === RepositoryDownloader::REF_STABLE ? RepositoryDownloader::REF_HEAD : $artifact->getRef(); $version = $ref; if ($ref === RepositoryDownloader::REF_HEAD) { if (!$this->git instanceof Git) { - $this->git = new Git($repo); + $this->git = new Git($artifact->getRepo()); } $ref = $this->git->getLastShortCommitId(); $version = 'develop'; } + else { + // Validate ref exists for non-HEAD refs. + $this->validateLocalRefExists($artifact->getRepo(), $ref); + } - $archive_path = $this->archiveFromLocal($repo, $ref); + $archive_path = $this->archiveFromLocal($artifact->getRepo(), $ref); $this->archiver->validate($archive_path); $this->archiver->extract($archive_path, $destination, FALSE); unlink($archive_path); @@ -270,4 +286,121 @@ protected function archiveFromLocal(string $repo, string $ref): string { return $temp_file; } + /** + * Validate that a remote repository exists and is accessible. + * + * @param string $repo_url + * The repository URL (without .git extension). + * + * @throws \RuntimeException + * If the repository is not accessible. + */ + protected function validateRemoteRepositoryExists(string $repo_url): void { + $headers = ['User-Agent' => 'Vortex-Installer']; + + $github_token = Env::get('GITHUB_TOKEN'); + if ($github_token) { + $headers['Authorization'] = sprintf('Bearer %s', $github_token); + } + + try { + // Try to access the repository root to verify it exists. + $response = $this->httpClient->request('HEAD', $repo_url, ['headers' => $headers, 'http_errors' => FALSE]); + $status_code = $response->getStatusCode(); + + if ($status_code >= 400) { + throw new \RuntimeException(sprintf('Repository not found or not accessible: "%s" (HTTP %d)', $repo_url, $status_code)); + } + } + catch (RequestException $e) { + throw new \RuntimeException(sprintf('Unable to access repository: "%s" - %s', $repo_url, $e->getMessage()), $e->getCode(), $e); + } + } + + /** + * Validate that a reference exists in a remote repository. + * + * @param string $repo_url + * The repository URL (without .git extension). + * @param string $ref + * The git reference to validate. + * + * @throws \RuntimeException + * If the reference does not exist. + */ + protected function validateRemoteRefExists(string $repo_url, string $ref): void { + $archive_url = sprintf('%s/archive/%s.tar.gz', $repo_url, $ref); + $headers = ['User-Agent' => 'Vortex-Installer']; + + $github_token = Env::get('GITHUB_TOKEN'); + if ($github_token) { + $headers['Authorization'] = sprintf('Bearer %s', $github_token); + } + + try { + // Use HEAD request to check if the archive URL exists without + // downloading. + $response = $this->httpClient->request('HEAD', $archive_url, ['headers' => $headers, 'http_errors' => FALSE]); + $status_code = $response->getStatusCode(); + + if ($status_code === 404) { + throw new \RuntimeException(sprintf('Reference "%s" not found in repository "%s"', $ref, $repo_url)); + } + elseif ($status_code >= 400) { + throw new \RuntimeException(sprintf('Unable to verify reference "%s" in repository "%s" (HTTP %d)', $ref, $repo_url, $status_code)); + } + } + catch (RequestException $e) { + throw new \RuntimeException(sprintf('Unable to verify reference "%s" in repository "%s" - %s', $ref, $repo_url, $e->getMessage()), $e->getCode(), $e); + } + } + + /** + * Validate that a local repository exists and is a valid git repository. + * + * @param string $repo + * The local repository path. + * + * @throws \RuntimeException + * If the repository does not exist or is not a valid git repository. + */ + protected function validateLocalRepositoryExists(string $repo): void { + if (!is_dir($repo)) { + throw new \RuntimeException(sprintf('Local repository path does not exist: "%s"', $repo)); + } + + if (!is_dir($repo . '/.git')) { + throw new \RuntimeException(sprintf('Path is not a git repository: "%s"', $repo)); + } + } + + /** + * Validate that a reference exists in a local repository. + * + * @param string $repo + * The local repository path. + * @param string $ref + * The git reference to validate. + * + * @throws \RuntimeException + * If the reference does not exist. + */ + protected function validateLocalRefExists(string $repo, string $ref): void { + $repo_path = (string) realpath($repo); + + // Reinitialize Git instance if it doesn't exist or references a different + // repository. + if (!$this->git instanceof Git || $this->git->getRepositoryPath() !== $repo_path) { + $this->git = new Git($repo); + } + + try { + // Use git rev-parse to check if the ref exists. + $this->git->run('rev-parse', '--verify', $ref); + } + catch (\Exception $e) { + throw new \RuntimeException(sprintf('Reference "%s" not found in local repository "%s"', $ref, $repo), $e->getCode(), $e); + } + } + } diff --git a/.vortex/installer/src/Downloader/RepositoryDownloaderInterface.php b/.vortex/installer/src/Downloader/RepositoryDownloaderInterface.php index d6331ad3b..2e4dc3a42 100644 --- a/.vortex/installer/src/Downloader/RepositoryDownloaderInterface.php +++ b/.vortex/installer/src/Downloader/RepositoryDownloaderInterface.php @@ -12,10 +12,8 @@ interface RepositoryDownloaderInterface { /** * Downloads a repository archive from a local or remote source. * - * @param string $repo - * The repository URL or local path. - * @param string $ref - * The reference to download (commit hash, HEAD, or stable). + * @param \DrevOps\VortexInstaller\Downloader\Artifact $artifact + * The artifact to download (contains repository and reference). * @param string|null $dst * The destination directory. If NULL, a temporary directory will be used * for local repositories. @@ -28,20 +26,6 @@ interface RepositoryDownloaderInterface { * @throws \InvalidArgumentException * If the destination is null for remote downloads. */ - public function download(string $repo, string $ref, ?string $dst = NULL): string; - - /** - * Parses a URI into repository and reference components. - * - * @param string $src - * The source URI (e.g., "https://github.com/user/repo@ref"). - * - * @return array - * An array with two elements: [$repo, $ref]. - * - * @throws \RuntimeException - * If the URI format is invalid or the reference format is not supported. - */ - public static function parseUri(string $src): array; + public function download(Artifact $artifact, ?string $dst = NULL): string; } diff --git a/.vortex/installer/src/Runner/ProcessRunner.php b/.vortex/installer/src/Runner/ProcessRunner.php index 187cfd90a..28b184546 100644 --- a/.vortex/installer/src/Runner/ProcessRunner.php +++ b/.vortex/installer/src/Runner/ProcessRunner.php @@ -46,9 +46,7 @@ public function run(string $command, array $args = [], array $inputs = [], array $process->setTimeout(NULL); $process->setIdleTimeout(NULL); - $process->run(function ($type, string|iterable $buffer) use ($logger, $output): void { - // @phpstan-ignore-next-line - $buffer = is_iterable($buffer) ? implode("\n", (array) $buffer) : $buffer; + $process->run(function ($type, string $buffer) use ($logger, $output): void { $this->output = $buffer; if ($this->shouldStream) { $output->write($buffer); diff --git a/.vortex/installer/src/Utils/Validator.php b/.vortex/installer/src/Utils/Validator.php index 36749f288..90a5adf19 100644 --- a/.vortex/installer/src/Utils/Validator.php +++ b/.vortex/installer/src/Utils/Validator.php @@ -41,4 +41,67 @@ public static function gitCommitShaShort(string $value): bool { return (bool) preg_match('/^[0-9a-f]{7}$/i', $value); } + /** + * Validate a git reference (tag, branch, or commit). + * + * Accepts any valid git reference format including: + * - Special keywords: "stable", "HEAD" + * - Commit hashes: 40-character or 7-character SHA-1 hashes + * - Version tags: "1.2.3", "v1.2.3", "25.11.0", "1.0.0-2025.11.0" + * - Drupal-style tags: "8.x-1.10", "9.x-2.3" + * - Pre-release tags: "1.x-rc1", "2.0.0-beta" + * - Branch names: "main", "develop", "feature/my-feature" + * + * Follows git reference naming rules: + * - Can contain alphanumeric, dot, hyphen, underscore, slash + * - Cannot start with dot or hyphen + * - Cannot contain: @, ^, ~, :, ?, *, [, space, \, @{ + * - Cannot end with .lock or contain + * + * @param string $value + * The reference string to validate. + * + * @return bool + * TRUE if valid, FALSE otherwise. + * + * @see https://git-scm.com/docs/git-check-ref-format + */ + public static function gitRef(string $value): bool { + // Reserved keywords have special meaning. + if (in_array($value, ['stable', 'HEAD'], TRUE)) { + return TRUE; + } + + // Already supported: commit hashes. + if (self::gitCommitSha($value) || self::gitCommitShaShort($value)) { + return TRUE; + } + + // Git ref naming rules (simplified): + // - Can contain alphanumeric, dot, hyphen, underscore, slash, plus. + // - Cannot start with dot or hyphen. + // - Cannot contain .. or end with .lock. + // - Cannot end with / or contain //. + $pattern = '/^(?![.\-])(?!.*\.\.)[a-zA-Z0-9._\/+-]+(?archiver = new Archiver(); } - #[DataProvider('providerDetectFormat')] + #[DataProvider('dataProviderDetectFormat')] public function testDetectFormat(string $creator, string $expected): void { $archive_path = $this->$creator(); $format = $this->archiver->detectFormat($archive_path); @@ -40,14 +40,14 @@ public function testDetectFormatNonExistentFile(): void { $this->archiver->detectFormat('/non/existent/file.tar.gz'); } - #[DataProvider('providerValidateValidArchive')] + #[DataProvider('dataProviderValidateValidArchive')] public function testValidateValidArchive(string $creator): void { $archive_path = $this->$creator(); $this->archiver->validate($archive_path); $this->expectNotToPerformAssertions(); } - #[DataProvider('providerValidateInvalid')] + #[DataProvider('dataProviderValidateInvalid')] public function testValidateInvalid(?string $path, ?string $content, string $expectedMessage): void { if ($path === NULL) { $path = self::$tmp . '/test_invalid_' . uniqid() . '.txt'; @@ -61,7 +61,7 @@ public function testValidateInvalid(?string $path, ?string $content, string $exp $this->archiver->validate($path); } - #[DataProvider('providerExtract')] + #[DataProvider('dataProviderExtract')] public function testExtract(string $creator, bool $strip, string $expectedPath): void { $archive_path = $this->$creator(); $destination = self::$tmp . '/test_extract_' . uniqid(); @@ -77,7 +77,7 @@ public function testExtract(string $creator, bool $strip, string $expectedPath): } } - #[DataProvider('providerExtractErrors')] + #[DataProvider('dataProviderExtractErrors')] public function testExtractErrors(?string $extension, ?string $content, bool $strip, ?string $creator, string $expectedMessage): void { if ($creator !== NULL) { $archive_path = $this->$creator(); @@ -101,7 +101,7 @@ public function testExtractErrors(?string $extension, ?string $content, bool $st * @return array> * Test data. */ - public static function providerDetectFormat(): array { + public static function dataProviderDetectFormat(): array { return [ 'tar.gz' => [ 'creator' => 'createTestTarGz', @@ -124,7 +124,7 @@ public static function providerDetectFormat(): array { * @return array> * Test data. */ - public static function providerValidateValidArchive(): array { + public static function dataProviderValidateValidArchive(): array { return [ 'tar.gz' => [ 'creator' => 'createTestTarGz', @@ -141,7 +141,7 @@ public static function providerValidateValidArchive(): array { * @return array> * Test data. */ - public static function providerValidateInvalid(): array { + public static function dataProviderValidateInvalid(): array { return [ 'non-existent file' => [ 'path' => '/non/existent/file.tar.gz', @@ -167,7 +167,7 @@ public static function providerValidateInvalid(): array { * @return array> * Test data. */ - public static function providerExtract(): array { + public static function dataProviderExtract(): array { return [ 'tar.gz without strip' => [ 'creator' => 'createTestTarGz', @@ -198,7 +198,7 @@ public static function providerExtract(): array { * @return array> * Test data. */ - public static function providerExtractErrors(): array { + public static function dataProviderExtractErrors(): array { return [ 'unsupported format' => [ 'extension' => '.rar', diff --git a/.vortex/installer/tests/Unit/Downloader/ArtifactTest.php b/.vortex/installer/tests/Unit/Downloader/ArtifactTest.php new file mode 100644 index 000000000..7240fa37a --- /dev/null +++ b/.vortex/installer/tests/Unit/Downloader/ArtifactTest.php @@ -0,0 +1,430 @@ + $expectedException */ + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedMessage); + } + + $artifact = Artifact::fromUri($uri); + + if ($expectedException === NULL) { + $this->assertEquals($expectedRepo, $artifact->getRepo()); + $this->assertEquals($expectedRef, $artifact->getRef()); + } + } + + /** + * Data provider for testFromUri(). + */ + public static function dataProviderFromUri(): array { + return [ + // Default URI cases. + 'null uri defaults to default repo and stable ref' => [ + NULL, + RepositoryDownloader::DEFAULT_REPO, + RepositoryDownloader::REF_STABLE, + ], + 'empty string defaults to default repo and stable ref' => [ + '', + RepositoryDownloader::DEFAULT_REPO, + RepositoryDownloader::REF_STABLE, + ], + + // GitHub HTTPS patterns. + 'https url with #ref' => [ + 'https://github.com/drevops/vortex.git#1.0.0', + 'https://github.com/drevops/vortex.git', + '1.0.0', + ], + 'https url without #ref defaults to HEAD' => [ + 'https://github.com/drevops/vortex.git', + 'https://github.com/drevops/vortex.git', + 'HEAD', + ], + 'https url with release tag pattern' => [ + 'https://github.com/drevops/vortex/releases/tag/25.11.0', + 'https://github.com/drevops/vortex', + '25.11.0', + ], + 'https url with tree pattern' => [ + 'https://github.com/drevops/vortex/tree/feature-branch', + 'https://github.com/drevops/vortex', + 'feature-branch', + ], + 'https url with commit pattern' => [ + 'https://github.com/drevops/vortex/commit/abc123def', + 'https://github.com/drevops/vortex', + 'abc123def', + ], + + // Git SSH patterns. + 'git@ scp-style with #ref' => [ + 'git@github.com:drevops/vortex#stable', + 'git@github.com:drevops/vortex', + 'stable', + ], + 'git@ scp-style without #ref defaults to HEAD' => [ + 'git@github.com:drevops/vortex', + 'git@github.com:drevops/vortex', + 'HEAD', + ], + + // SSH and Git protocol URLs. + 'ssh:// url with #ref' => [ + 'ssh://git@github.com/drevops/vortex#develop', + 'ssh://git@github.com/drevops/vortex', + 'develop', + ], + 'ssh:// url without #ref defaults to HEAD' => [ + 'ssh://git@github.com/drevops/vortex', + 'ssh://git@github.com/drevops/vortex', + 'HEAD', + ], + 'git:// url with #ref' => [ + 'git://github.com/drevops/vortex#main', + 'git://github.com/drevops/vortex', + 'main', + ], + 'git:// url without #ref defaults to HEAD' => [ + 'git://github.com/drevops/vortex', + 'git://github.com/drevops/vortex', + 'HEAD', + ], + 'http:// url with #ref' => [ + 'http://github.com/drevops/vortex#feature', + 'http://github.com/drevops/vortex', + 'feature', + ], + 'http:// url without #ref defaults to HEAD' => [ + 'http://github.com/drevops/vortex', + 'http://github.com/drevops/vortex', + 'HEAD', + ], + + // Local path patterns. + 'local path with #ref' => [ + '/path/to/repo#develop', + '/path/to/repo', + 'develop', + ], + 'local path without #ref defaults to HEAD' => [ + '/path/to/repo', + '/path/to/repo', + 'HEAD', + ], + 'local path with trailing slash removed' => [ + '/path/to/repo/', + '/path/to/repo', + 'HEAD', + ], + 'file:// url with #ref' => [ + 'file:///path/to/repo#main', + '/path/to/repo', + 'main', + ], + 'file:// url without #ref' => [ + 'file:///path/to/repo', + '/path/to/repo', + 'HEAD', + ], + + // Invalid ref format. + 'invalid ref with space' => [ + 'https://github.com/drevops/vortex.git#invalid ref', + '', + '', + \RuntimeException::class, + 'Invalid git reference: "invalid ref"', + ], + 'invalid ref with trailing slash' => [ + 'https://github.com/drevops/vortex.git#feature/', + '', + '', + \RuntimeException::class, + 'Invalid git reference: "feature/"', + ], + 'invalid ref with consecutive slashes' => [ + 'https://github.com/drevops/vortex.git#feature//name', + '', + '', + \RuntimeException::class, + 'Invalid git reference: "feature//name"', + ], + + // Invalid URI formats. + 'invalid https format - missing path structure' => [ + 'https://github.com', + '', + '', + \RuntimeException::class, + 'Invalid remote repository format', + ], + 'invalid ssh format - missing colon' => [ + 'git@github.com', + '', + '', + \RuntimeException::class, + 'Invalid remote repository format', + ], + 'invalid file:// format - empty path' => [ + 'file://', + '', + '', + \RuntimeException::class, + 'Invalid local repository format', + ], + 'non-github https with invalid ref' => [ + 'https://gitlab.com/user/repo#invalid ref', + '', + '', + \RuntimeException::class, + 'Invalid git reference: "invalid ref"', + ], + + // Deprecated @ref syntax (supported until 1.1.0). + 'deprecated @ref with https' => [ + 'https://github.com/drevops/vortex.git@1.0.0', + 'https://github.com/drevops/vortex.git', + '1.0.0', + ], + 'deprecated @ref with http' => [ + 'http://github.com/drevops/vortex.git@stable', + 'http://github.com/drevops/vortex.git', + 'stable', + ], + 'deprecated @ref with ssh://' => [ + 'ssh://git@github.com/drevops/vortex@main', + 'ssh://git@github.com/drevops/vortex', + 'main', + ], + 'deprecated @ref with git://' => [ + 'git://github.com/drevops/vortex@develop', + 'git://github.com/drevops/vortex', + 'develop', + ], + 'deprecated @ref with git@ scp-style' => [ + 'git@github.com:drevops/vortex@feature', + 'git@github.com:drevops/vortex', + 'feature', + ], + ]; + } + + #[DataProvider('dataProviderCreate')] + public function testCreate(string $repo, string $ref, ?string $expectedException = NULL, ?string $expectedMessage = NULL): void { + if ($expectedException !== NULL) { + /** @var class-string<\Throwable> $expectedException */ + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedMessage); + } + + $artifact = Artifact::create($repo, $ref); + + if ($expectedException === NULL) { + $this->assertEquals($repo, $artifact->getRepo()); + $this->assertEquals($ref, $artifact->getRef()); + } + } + + /** + * Data provider for testCreate(). + */ + public static function dataProviderCreate(): array { + return [ + 'valid remote repo and ref' => [ + 'https://github.com/drevops/vortex.git', + '1.0.0', + ], + 'valid local repo and ref' => [ + '/path/to/repo', + 'main', + ], + 'invalid ref with space' => [ + 'https://github.com/drevops/vortex.git', + 'invalid ref', + \RuntimeException::class, + 'Invalid git reference: "invalid ref"', + ], + 'invalid ref with trailing slash' => [ + '/path/to/repo', + 'feature/', + \RuntimeException::class, + 'Invalid git reference: "feature/"', + ], + ]; + } + + #[DataProvider('dataProviderIsRemote')] + public function testIsRemote(string $repo, bool $expected): void { + $artifact = Artifact::create($repo, 'HEAD'); + $this->assertEquals($expected, $artifact->isRemote()); + } + + /** + * Data provider for testIsRemote(). + */ + public static function dataProviderIsRemote(): array { + return [ + 'https url' => ['https://github.com/drevops/vortex.git', TRUE], + 'http url' => ['http://github.com/drevops/vortex.git', TRUE], + 'ssh:// url' => ['ssh://git@github.com/drevops/vortex.git', TRUE], + 'git:// url' => ['git://github.com/drevops/vortex.git', TRUE], + 'git@ scp-style url' => ['git@github.com:drevops/vortex', TRUE], + 'local absolute path' => ['/path/to/repo', FALSE], + 'local relative path' => ['./repo', FALSE], + 'file:// url treated as local' => ['file:///path/to/repo', FALSE], + ]; + } + + #[DataProvider('dataProviderIsLocal')] + public function testIsLocal(string $repo, bool $expected): void { + $artifact = Artifact::create($repo, 'HEAD'); + $this->assertEquals($expected, $artifact->isLocal()); + } + + /** + * Data provider for testIsLocal(). + */ + public static function dataProviderIsLocal(): array { + return [ + 'https url' => ['https://github.com/drevops/vortex.git', FALSE], + 'http url' => ['http://github.com/drevops/vortex.git', FALSE], + 'ssh:// url' => ['ssh://git@github.com/drevops/vortex.git', FALSE], + 'git:// url' => ['git://github.com/drevops/vortex.git', FALSE], + 'git@ scp-style url' => ['git@github.com:drevops/vortex', FALSE], + 'local absolute path' => ['/path/to/repo', TRUE], + 'local relative path' => ['./repo', TRUE], + 'file:// url treated as local' => ['file:///path/to/repo', TRUE], + ]; + } + + #[DataProvider('dataProviderIsDefault')] + public function testIsDefault(string $repo, string $ref, bool $expected): void { + $artifact = Artifact::create($repo, $ref); + $this->assertEquals($expected, $artifact->isDefault()); + } + + /** + * Data provider for testIsDefault(). + */ + public static function dataProviderIsDefault(): array { + return [ + 'default repo with stable ref' => [ + RepositoryDownloader::DEFAULT_REPO, + RepositoryDownloader::REF_STABLE, + TRUE, + ], + 'default repo without .git with stable ref' => [ + 'https://github.com/drevops/vortex', + RepositoryDownloader::REF_STABLE, + TRUE, + ], + 'default repo with HEAD ref' => [ + RepositoryDownloader::DEFAULT_REPO, + RepositoryDownloader::REF_HEAD, + TRUE, + ], + 'default repo with custom ref' => [ + RepositoryDownloader::DEFAULT_REPO, + 'custom-branch', + FALSE, + ], + 'custom repo with stable ref' => [ + 'https://github.com/custom/repo.git', + RepositoryDownloader::REF_STABLE, + FALSE, + ], + 'custom repo with custom ref' => [ + 'https://github.com/custom/repo.git', + 'custom-branch', + FALSE, + ], + ]; + } + + #[DataProvider('dataProviderGetRepoUrl')] + public function testGetRepoUrl(string $repo, string $expectedUrl): void { + $artifact = Artifact::create($repo, 'HEAD'); + $this->assertEquals($expectedUrl, $artifact->getRepoUrl()); + } + + /** + * Data provider for testGetRepoUrl(). + */ + public static function dataProviderGetRepoUrl(): array { + return [ + 'https url with .git' => [ + 'https://github.com/drevops/vortex.git', + 'https://github.com/drevops/vortex', + ], + 'https url without .git' => [ + 'https://github.com/drevops/vortex', + 'https://github.com/drevops/vortex', + ], + 'ssh url with .git' => [ + 'git@github.com:drevops/vortex.git', + 'git@github.com:drevops/vortex', + ], + 'local path not affected' => [ + '/path/to/repo', + '/path/to/repo', + ], + ]; + } + + #[DataProvider('dataProviderIsStable')] + public function testIsStable(string $repo, string $ref, bool $expected): void { + $artifact = Artifact::create($repo, $ref); + $this->assertEquals($expected, $artifact->isStable()); + } + + /** + * Data provider for testIsStable(). + */ + public static function dataProviderIsStable(): array { + return [ + 'stable ref' => ['https://github.com/drevops/vortex.git', 'stable', TRUE], + 'HEAD ref' => ['https://github.com/drevops/vortex.git', 'HEAD', FALSE], + 'custom ref' => ['https://github.com/drevops/vortex.git', '1.0.0', FALSE], + 'branch ref' => ['https://github.com/drevops/vortex.git', 'feature-branch', FALSE], + ]; + } + + #[DataProvider('dataProviderIsDevelopment')] + public function testIsDevelopment(string $repo, string $ref, bool $expected): void { + $artifact = Artifact::create($repo, $ref); + $this->assertEquals($expected, $artifact->isDevelopment()); + } + + /** + * Data provider for testIsDevelopment(). + */ + public static function dataProviderIsDevelopment(): array { + return [ + 'HEAD ref' => ['https://github.com/drevops/vortex.git', 'HEAD', TRUE], + 'stable ref' => ['https://github.com/drevops/vortex.git', 'stable', FALSE], + 'custom ref' => ['https://github.com/drevops/vortex.git', '1.0.0', FALSE], + 'branch ref' => ['https://github.com/drevops/vortex.git', 'feature-branch', FALSE], + ]; + } + +} diff --git a/.vortex/installer/tests/Unit/Downloader/DownloaderTest.php b/.vortex/installer/tests/Unit/Downloader/DownloaderTest.php index 72d36309c..87d4c90e5 100644 --- a/.vortex/installer/tests/Unit/Downloader/DownloaderTest.php +++ b/.vortex/installer/tests/Unit/Downloader/DownloaderTest.php @@ -80,4 +80,34 @@ public function testDownloadWithDefaultClient(): void { $this->assertInstanceOf(Downloader::class, $downloader); } + public function testDownloadWithCustomHeaders(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_response = $this->createMock(ResponseInterface::class); + $mock_response->method('getStatusCode')->willReturn(200); + + $custom_headers = [ + 'Authorization' => 'Bearer token123', + 'X-Custom-Header' => 'custom-value', + ]; + + $mock_http_client->expects($this->once()) + ->method('request') + ->with( + 'GET', + 'https://example.com/file.sql', + $this->callback(fn(array $options): bool => isset($options['sink']) + && isset($options['allow_redirects']) + && isset($options['headers']) + && $options['headers'] === $custom_headers) + ) + ->willReturn($mock_response); + + $destination = self::$tmp . '/downloaded_file.sql'; + + $downloader = new Downloader($mock_http_client); + $downloader->download('https://example.com/file.sql', $destination, $custom_headers); + + $this->addToAssertionCount(1); + } + } diff --git a/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php b/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php index 38e78481d..311e30977 100644 --- a/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php +++ b/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php @@ -5,6 +5,7 @@ namespace DrevOps\VortexInstaller\Tests\Unit\Downloader; use AlexSkrypnyk\File\File; +use DrevOps\VortexInstaller\Downloader\Artifact; use DrevOps\VortexInstaller\Downloader\ArchiverInterface; use DrevOps\VortexInstaller\Downloader\Downloader; use DrevOps\VortexInstaller\Downloader\RepositoryDownloader; @@ -21,22 +22,6 @@ #[CoversClass(RepositoryDownloader::class)] class RepositoryDownloaderTest extends UnitTestCase { - #[DataProvider('dataProviderParseUri')] - public function testParseUri(string $src, ?string $expected_repo = NULL, ?string $expected_ref = NULL, ?string $expected_exception_message = NULL): void { - if (!is_null($expected_exception_message)) { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage($expected_exception_message); - } - - $result = RepositoryDownloader::parseUri($src); - - if (is_null($expected_exception_message)) { - $this->assertCount(2, $result); - $this->assertEquals($expected_repo, $result[0], 'Repository matches input: ' . $src); - $this->assertEquals($expected_ref, $result[1], 'Reference matches input: ' . $src); - } - } - public function testDownloadWithMockedArchiver(): void { $mock_http_client = $this->createMockHttpClient(); /** @var \PHPUnit\Framework\MockObject\MockObject&\DrevOps\VortexInstaller\Downloader\ArchiverInterface $mock_archiver */ @@ -48,7 +33,7 @@ public function testDownloadWithMockedArchiver(): void { File::mkdir($destination); File::dump($destination . '/composer.json', '{}'); $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); - $version = $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $version = $downloader->download(Artifact::create('https://github.com/user/repo', 'HEAD'), $destination); $this->assertEquals('develop', $version); } @@ -65,7 +50,7 @@ public function testDownloadThrowsExceptionWhenComposerJsonMissing(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('The downloaded repository does not contain a composer.json file.'); - $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $downloader->download(Artifact::create('https://github.com/user/repo', 'HEAD'), $destination); } public function testDownloadFromRemoteCallsArchiverCorrectly(): void { @@ -81,7 +66,7 @@ public function testDownloadFromRemoteCallsArchiverCorrectly(): void { File::dump($destination . '/composer.json', '{}'); $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); - $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $downloader->download(Artifact::create('https://github.com/user/repo', 'HEAD'), $destination); } public function testDownloadArchiveCreatesTemporaryFile(): void { @@ -92,7 +77,7 @@ public function testDownloadArchiveCreatesTemporaryFile(): void { File::mkdir($destination); File::dump($destination . '/composer.json', '{}'); $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); - $version = $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $version = $downloader->download(Artifact::create('https://github.com/user/repo', 'HEAD'), $destination); $this->assertEquals('develop', $version); } @@ -106,7 +91,7 @@ public function testDownloadArchiveHandlesHttpError(): void { $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Failed to download archive from'); - $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $downloader->download(Artifact::create('https://github.com/user/repo', 'HEAD'), $destination); } public function testDownloadArchiveHandlesRequestException(): void { @@ -119,10 +104,10 @@ public function testDownloadArchiveHandlesRequestException(): void { $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Failed to download archive from'); - $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $downloader->download(Artifact::create('https://github.com/user/repo', 'HEAD'), $destination); } - #[DataProvider('providerDiscoverLatestReleaseRemote')] + #[DataProvider('dataProviderDiscoverLatestReleaseRemote')] public function testDiscoverLatestReleaseRemote(string $repo, mixed $releaseData, bool $throwException, bool $skipMockSetup, ?string $expectedVersion, ?string $expectedException, ?string $expectedMessage): void { $mock_http_client = $this->createMock(ClientInterface::class); @@ -162,7 +147,7 @@ public function testDiscoverLatestReleaseRemote(string $repo, mixed $releaseData $this->expectExceptionMessage($expectedMessage); } - $version = $downloader->download($repo, 'stable', $destination); + $version = $downloader->download(Artifact::create($repo, 'stable'), $destination); if ($expectedVersion !== NULL) { $this->assertEquals($expectedVersion, $version); @@ -180,19 +165,19 @@ public function testDownloadFromRemoteWithGitSuffix(): void { File::mkdir($destination); File::dump($destination . '/composer.json', '{}'); $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); - $version = $downloader->download('https://github.com/user/repo.git', 'HEAD', $destination); + $version = $downloader->download(Artifact::create('https://github.com/user/repo.git', 'HEAD'), $destination); $this->assertEquals('develop', $version); } - #[DataProvider('providerDownloadWithNullDestination')] + #[DataProvider('dataProviderDownloadWithNullDestination')] public function testDownloadWithNullDestination(string $repo, string $expectedMessage): void { $downloader = new RepositoryDownloader(); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($expectedMessage); - $downloader->download($repo, 'HEAD'); + $downloader->download(Artifact::create($repo, 'HEAD')); } - #[DataProvider('providerDownloadFromLocal')] + #[DataProvider('dataProviderDownloadFromLocal')] public function testDownloadFromLocal(string $ref, string $expectedVersion): void { $temp_repo_dir = $this->createGitRepo(); $destination = self::$tmp . '/dest_' . uniqid(); @@ -211,7 +196,7 @@ public function testDownloadFromLocal(string $ref, string $expectedVersion): voi /** @var \PHPUnit\Framework\MockObject\MockObject&\DrevOps\VortexInstaller\Downloader\ArchiverInterface $mock_archiver */ $mock_archiver = $this->createMockArchiverWithExtract(); $downloader = new RepositoryDownloader(NULL, $mock_archiver); - $version = $downloader->download($temp_repo_dir, $ref, $destination); + $version = $downloader->download(Artifact::create($temp_repo_dir, $ref), $destination); $this->assertEquals($expectedVersion, $version); $this->removeGitRepo($temp_repo_dir); } @@ -230,8 +215,8 @@ public function testArchiveFromLocalHandlesGitFailure(): void { exec('git commit -m "Initial commit" 2>&1'); $downloader = new RepositoryDownloader(); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Failed to create archive from local repository'); - $downloader->download($temp_repo_dir, 'nonexistent-ref', $temp_dest_dir); + $this->expectExceptionMessage('Reference "nonexistent-ref" not found in local repository'); + $downloader->download(Artifact::create($temp_repo_dir, 'nonexistent-ref'), $temp_dest_dir); } public function testDiscoverLatestReleaseRemoteWithGithubToken(): void { @@ -245,8 +230,8 @@ public function testDiscoverLatestReleaseRemoteWithGithubToken(): void { $mock_response->method('getBody')->willReturn($mock_body); $mock_body->method('getContents')->willReturn($release_json); $mock_response->method('getStatusCode')->willReturn(200); - // Only the API call uses httpClient now. - $mock_http_client->expects($this->once())->method('request')->willReturnCallback(function ($method, $url, array|\ArrayAccess $options) use ($mock_response): ResponseInterface { + // Two calls: HEAD for repo validation, GET for releases API. + $mock_http_client->expects($this->exactly(2))->method('request')->willReturnCallback(function ($method, $url, array|\ArrayAccess $options) use ($mock_response): ResponseInterface { $this->assertArrayHasKey('headers', $options); $this->assertArrayHasKey('Authorization', $options['headers']); $this->assertEquals('Bearer test_token_12345', $options['headers']['Authorization']); @@ -263,7 +248,7 @@ public function testDiscoverLatestReleaseRemoteWithGithubToken(): void { File::mkdir($destination); File::dump($destination . '/composer.json', '{}'); $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); - $version = $downloader->download('https://github.com/user/repo', 'stable', $destination); + $version = $downloader->download(Artifact::create('https://github.com/user/repo', 'stable'), $destination); $this->assertEquals('v1.5.0', $version); } @@ -281,423 +266,17 @@ public function testDownloadArchiveWithGithubToken(): void { File::mkdir($destination); File::dump($destination . '/composer.json', '{}'); $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); - $version = $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $version = $downloader->download(Artifact::create('https://github.com/user/repo', 'HEAD'), $destination); $this->assertEquals('develop', $version); } - public static function dataProviderParseUri(): array { - return [ - // Valid test cases. - 'HTTPS URLs - with default HEAD reference - GitHub' => [ - 'https://github.com/user/repo', - 'https://github.com/user/repo', - RepositoryDownloader::REF_HEAD, - - ], - 'HTTPS URLs - with default HEAD reference - GitLab' => [ - 'https://gitlab.com/user/repo', - 'https://gitlab.com/user/repo', - RepositoryDownloader::REF_HEAD, - - ], - 'HTTPS URLs - with default HEAD reference - Bitbucket' => [ - 'https://bitbucket.org/user/repo', - 'https://bitbucket.org/user/repo', - RepositoryDownloader::REF_HEAD, - - ], - 'HTTPS URLs - with specific valid references - stable' => [ - 'https://github.com/user/repo@stable', - 'https://github.com/user/repo', - RepositoryDownloader::REF_STABLE, - - ], - 'HTTPS URLs - with specific valid references - HEAD' => [ - 'https://github.com/user/repo@HEAD', - 'https://github.com/user/repo', - RepositoryDownloader::REF_HEAD, - - ], - 'HTTPS URLs - with 40-character commit hash' => [ - 'https://github.com/user/repo@1234567890abcdef1234567890abcdef12345678', - 'https://github.com/user/repo', - '1234567890abcdef1234567890abcdef12345678', - - ], - 'HTTPS URLs - with 7-character short commit hash' => [ - 'https://github.com/user/repo@1234567', - 'https://github.com/user/repo', - '1234567', - - ], - 'Git SSH URLs - with default HEAD reference - GitHub' => [ - 'git@github.com:user/repo', - 'git@github.com:user/repo', - RepositoryDownloader::REF_HEAD, - ], - 'Git SSH URLs - with default HEAD reference - GitLab' => [ - 'git@gitlab.com:user/repo', - 'git@gitlab.com:user/repo', - RepositoryDownloader::REF_HEAD, - ], - 'Git SSH URLs - with default HEAD reference - Bitbucket' => [ - 'git@bitbucket.org:user/repo', - 'git@bitbucket.org:user/repo', - RepositoryDownloader::REF_HEAD, - ], - 'Git SSH URLs - with specific valid references - stable' => [ - 'git@github.com:user/repo@stable', - 'git@github.com:user/repo', - RepositoryDownloader::REF_STABLE, - ], - 'Git SSH URLs - with specific valid references - HEAD' => [ - 'git@github.com:user/repo@HEAD', - 'git@github.com:user/repo', - RepositoryDownloader::REF_HEAD, - ], - 'Git SSH URLs - with commit hashes - 40 char' => [ - 'git@github.com:user/repo@1234567890abcdef1234567890abcdef12345678', - 'git@github.com:user/repo', - '1234567890abcdef1234567890abcdef12345678', - ], - 'Git SSH URLs - with commit hashes - 7 char' => [ - 'git@github.com:user/repo@1234567', - 'git@github.com:user/repo', - '1234567', - ], - 'File URLs - with default HEAD reference' => [ - 'file:///path/to/repo', - '/path/to/repo', - RepositoryDownloader::REF_HEAD, - ], - 'File URLs - with default HEAD reference - user path' => [ - 'file:///home/user/repos/myrepo', - '/home/user/repos/myrepo', - RepositoryDownloader::REF_HEAD, - ], - 'File URLs - with specific valid references - stable' => [ - 'file:///path/to/repo@stable', - '/path/to/repo', - RepositoryDownloader::REF_STABLE, - ], - 'File URLs - with specific valid references - HEAD' => [ - 'file:///path/to/repo@HEAD', - '/path/to/repo', - RepositoryDownloader::REF_HEAD, - ], - 'File URLs - with 40-character commit hash' => [ - 'file:///path/to/repo@1234567890abcdef1234567890abcdef12345678', - '/path/to/repo', - '1234567890abcdef1234567890abcdef12345678', - ], - 'File URLs - with 7-character commit hash' => [ - 'file:///path/to/repo@1234567', - '/path/to/repo', - '1234567', - ], - 'Local paths - with default HEAD reference - absolute' => [ - '/path/to/repo', - '/path/to/repo', - RepositoryDownloader::REF_HEAD, - ], - 'Local paths - with default HEAD reference - user home' => [ - '/home/user/repos/myrepo', - '/home/user/repos/myrepo', - RepositoryDownloader::REF_HEAD, - ], - 'Local paths - with default HEAD reference - relative' => [ - 'relative/path/to/repo', - 'relative/path/to/repo', - RepositoryDownloader::REF_HEAD, - ], - 'Local paths - with default HEAD reference - current dir' => [ - './repo', - './repo', - RepositoryDownloader::REF_HEAD, - ], - 'Local paths - with default HEAD reference - parent dir' => [ - '../repo', - '../repo', - RepositoryDownloader::REF_HEAD, - ], - 'Local paths - with specific valid references - stable' => [ - '/path/to/repo@stable', - '/path/to/repo', - RepositoryDownloader::REF_STABLE, - ], - 'Local paths - with specific valid references - HEAD' => [ - '/path/to/repo@HEAD', - '/path/to/repo', - RepositoryDownloader::REF_HEAD, - ], - 'Local paths - with 40-character commit hash' => [ - '/path/to/repo@1234567890abcdef1234567890abcdef12345678', - '/path/to/repo', - '1234567890abcdef1234567890abcdef12345678', - ], - 'Local paths - with 7-character commit hash' => [ - '/path/to/repo@1234567', - '/path/to/repo', - '1234567', - ], - 'Local paths with trailing slashes - should be trimmed - single slash' => [ - '/path/to/repo/', - '/path/to/repo', - RepositoryDownloader::REF_HEAD, - ], - 'Local paths with trailing slashes - should be trimmed - double slash' => [ - '/path/to/repo//', - '/path/to/repo', - RepositoryDownloader::REF_HEAD, - ], - 'Local paths with trailing slashes - should be trimmed - with reference' => [ - '/path/to/repo/@stable', - '/path/to/repo', - RepositoryDownloader::REF_STABLE, - ], - 'Relative paths - simple' => [ - 'repo', - 'repo', - RepositoryDownloader::REF_HEAD, - ], - 'Relative paths - with reference' => [ - 'repo@stable', - 'repo', - RepositoryDownloader::REF_STABLE, - ], - 'Edge cases with valid commit hashes - uppercase 40 char' => [ - 'https://github.com/user/repo@ABCDEF1234567890ABCDEF1234567890ABCDEF12', - 'https://github.com/user/repo', - 'ABCDEF1234567890ABCDEF1234567890ABCDEF12', - ], - 'Edge cases with valid commit hashes - uppercase 7 char' => [ - 'git@github.com:user/repo@ABCDEF1', - 'git@github.com:user/repo', - 'ABCDEF1', - ], - 'Edge cases that are actually valid - HTTPS with extra path' => [ - 'https://github.com/user/repo/extra/path', - 'https://github.com/user/repo/extra/path', - RepositoryDownloader::REF_HEAD, - ], - 'Edge cases that are actually valid - Git SSH with extra path' => [ - 'git@github.com:user/repo/extra', - 'git@github.com:user/repo/extra', - RepositoryDownloader::REF_HEAD, - ], - 'Edge cases that are actually valid - protocol-less GitHub' => [ - 'github.com/user/repo', - 'github.com/user/repo', - RepositoryDownloader::REF_HEAD, - ], - 'Edge cases that are actually valid - file root' => [ - 'file:///', - '/', - RepositoryDownloader::REF_HEAD, - ], - 'Empty reference defaults to HEAD - @ is captured in repo part' => [ - '/path/to/repo@', - '/path/to/repo@', - RepositoryDownloader::REF_HEAD, - ], - - // Invalid test cases. - 'Invalid HTTPS URL formats - missing repo' => [ - 'https://github.com', - NULL, - NULL, - 'Invalid remote repository format: "https://github.com".', - ], - 'Invalid HTTPS URL formats - missing repo with slash' => [ - 'https://github.com/', - NULL, - NULL, - 'Invalid remote repository format: "https://github.com/".', - ], - 'Invalid HTTPS URL formats - missing repo name' => [ - 'https://github.com/user', - NULL, - NULL, - 'Invalid remote repository format: "https://github.com/user".', - ], - 'Invalid HTTPS URL formats - missing repo name with slash' => [ - 'https://github.com/user/', - NULL, - NULL, - 'Invalid remote repository format: "https://github.com/user/".', - ], - 'Invalid HTTPS URL formats - malformed with reference' => [ - 'https://github.com/user@main', - NULL, - NULL, - 'Invalid remote repository format: "https://github.com/user@main".', - ], - 'Invalid HTTPS URL formats - protocol only' => [ - 'https://', - NULL, - NULL, - 'Invalid remote repository format: "https://".', - ], - 'Invalid HTTPS URL formats - protocol with slash' => [ - 'https:///', - NULL, - NULL, - 'Invalid remote repository format: "https:///".', - ], - 'Invalid Git SSH URL formats - missing colon' => [ - 'git@github.com', - NULL, - NULL, - 'Invalid remote repository format: "git@github.com".', - ], - 'Invalid Git SSH URL formats - empty after colon' => [ - 'git@github.com:', - NULL, - NULL, - 'Invalid remote repository format: "git@github.com:".', - ], - 'Invalid Git SSH URL formats - missing repo name' => [ - 'git@github.com:user', - NULL, - NULL, - 'Invalid remote repository format: "git@github.com:user".', - ], - 'Invalid Git SSH URL formats - malformed with reference' => [ - 'git@github.com:user@main', - NULL, - NULL, - 'Invalid remote repository format: "git@github.com:user@main".', - ], - 'Invalid Git SSH URL formats - empty user' => [ - 'git@', - NULL, - NULL, - 'Invalid remote repository format: "git@".', - ], - 'Invalid Git SSH URL formats - empty host' => [ - 'git@:user/repo', - NULL, - NULL, - 'Invalid remote repository format: "git@:user/repo".', - ], - 'Invalid file URL formats - empty path' => [ - 'file://', - NULL, - NULL, - 'Invalid local repository format: "file://".', - ], - 'Invalid reference formats - branch names not allowed - main' => [ - 'https://github.com/user/repo@main', - NULL, - NULL, - 'Invalid reference format: "main". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid reference formats - branch names not allowed - develop' => [ - 'git@github.com:user/repo@develop', - NULL, - NULL, - 'Invalid reference format: "develop". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid reference formats - branch names not allowed - version tag' => [ - '/path/to/repo@v1.0.0', - NULL, - NULL, - 'Invalid reference format: "v1.0.0". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid reference formats - branch names not allowed - feature branch' => [ - 'file:///path/to/repo@feature-branch', - NULL, - NULL, - 'Invalid reference format: "feature-branch". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid reference formats - special characters - exclamation' => [ - 'https://github.com/user/repo@invalid-ref-with-special-chars!', - NULL, - NULL, - 'Invalid reference format: "invalid-ref-with-special-chars!". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid reference formats - special characters - double @' => [ - 'git@github.com:user/repo@invalid@ref', - NULL, - NULL, - 'Invalid reference format: "invalid@ref". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 6 chars' => [ - 'file:///path/to/repo@123456', - NULL, - NULL, - 'Invalid reference format: "123456". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 8 chars' => [ - 'https://github.com/user/repo@12345678', - NULL, - NULL, - 'Invalid reference format: "12345678". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 39 chars' => [ - 'git@github.com:user/repo@123456789012345678901234567890123456789', - NULL, - NULL, - 'Invalid reference format: "123456789012345678901234567890123456789". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - invalid characters - 40 char with g' => [ - 'https://github.com/user/repo@1234567890abcdef1234567890abcdef1234567g', - NULL, - NULL, - 'Invalid reference format: "1234567890abcdef1234567890abcdef1234567g". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - invalid characters - 7 char with g' => [ - '/path/to/repo@123456g', - NULL, - NULL, - 'Invalid reference format: "123456g". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 3 chars' => [ - 'https://github.com/user/repo@123', - NULL, - NULL, - 'Invalid reference format: "123". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 41 chars' => [ - 'git@github.com:user/repo@12345678901234567890123456789012345678901', - NULL, - NULL, - 'Invalid reference format: "12345678901234567890123456789012345678901". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 39 chars mixed' => [ - '/path/to/repo@1234567890abcdef1234567890abcdef123456789', - NULL, - NULL, - 'Invalid reference format: "1234567890abcdef1234567890abcdef123456789". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Edge cases - double @ character results in reference validation error - HTTPS' => [ - 'https://github.com/user/repo@@main', - NULL, - NULL, - 'Invalid reference format: "@main". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Edge cases - double @ character results in reference validation error - Git SSH' => [ - 'git@github.com:user/repo@@main', - NULL, - NULL, - 'Invalid reference format: "@main". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Protocol-less URLs - treated as local paths - reference validation error' => [ - 'user/repo@github.com', - NULL, - NULL, - 'Invalid reference format: "github.com". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - ]; - } - /** * Data provider for testDiscoverLatestReleaseRemote(). * * @return array> * Test data. */ - public static function providerDiscoverLatestReleaseRemote(): array { + public static function dataProviderDiscoverLatestReleaseRemote(): array { return [ 'valid releases' => [ 'repo' => 'https://github.com/user/repo', @@ -739,7 +318,7 @@ public static function providerDiscoverLatestReleaseRemote(): array { 'skipMockSetup' => FALSE, 'expectedVersion' => NULL, 'expectedException' => \RuntimeException::class, - 'expectedMessage' => 'Unable to download release information from', + 'expectedMessage' => 'Unable to access repository', ], 'empty response' => [ 'repo' => 'https://github.com/user/repo', @@ -757,54 +336,54 @@ public static function providerDiscoverLatestReleaseRemote(): array { 'skipMockSetup' => TRUE, 'expectedVersion' => NULL, 'expectedException' => \RuntimeException::class, - 'expectedMessage' => 'Invalid repository URL', + 'expectedMessage' => 'Local repository path does not exist', ], 'SemVer+CalVer format - single release' => [ - 'repo' => 'https://github.com/drevops/vortex', + 'repo' => str_replace('.git', '', RepositoryDownloader::DEFAULT_REPO), 'releaseData' => [ - ['tag_name' => '1.0.0-2025.11.0', 'draft' => FALSE], + ['tag_name' => '1.0.0+2025.11.0', 'draft' => FALSE], ], 'throwException' => FALSE, 'skipMockSetup' => FALSE, - 'expectedVersion' => '1.0.0-2025.11.0', + 'expectedVersion' => '1.0.0+2025.11.0', 'expectedException' => NULL, 'expectedMessage' => NULL, ], 'SemVer+CalVer format - multiple releases' => [ - 'repo' => 'https://github.com/drevops/vortex', + 'repo' => str_replace('.git', '', RepositoryDownloader::DEFAULT_REPO), 'releaseData' => [ - ['tag_name' => '1.2.0-2025.12.0', 'draft' => FALSE], - ['tag_name' => '1.1.0-2025.11.0', 'draft' => FALSE], - ['tag_name' => '1.0.0-2025.10.0', 'draft' => FALSE], + ['tag_name' => '1.2.0+2025.12.0', 'draft' => FALSE], + ['tag_name' => '1.1.0+2025.11.0', 'draft' => FALSE], + ['tag_name' => '1.0.0+2025.10.0', 'draft' => FALSE], ], 'throwException' => FALSE, 'skipMockSetup' => FALSE, - 'expectedVersion' => '1.2.0-2025.12.0', + 'expectedVersion' => '1.2.0+2025.12.0', 'expectedException' => NULL, 'expectedMessage' => NULL, ], 'SemVer+CalVer format - skip draft' => [ - 'repo' => 'https://github.com/drevops/vortex', + 'repo' => str_replace('.git', '', RepositoryDownloader::DEFAULT_REPO), 'releaseData' => [ - ['tag_name' => '2.0.0-2026.01.0', 'draft' => TRUE], - ['tag_name' => '1.0.0-2025.11.0', 'draft' => FALSE], + ['tag_name' => '2.0.0+2026.01.0', 'draft' => TRUE], + ['tag_name' => '1.0.0+2025.11.0', 'draft' => FALSE], ], 'throwException' => FALSE, 'skipMockSetup' => FALSE, - 'expectedVersion' => '1.0.0-2025.11.0', + 'expectedVersion' => '1.0.0+2025.11.0', 'expectedException' => NULL, 'expectedMessage' => NULL, ], 'Mixed format - SemVer+CalVer and CalVer' => [ - 'repo' => 'https://github.com/drevops/vortex', + 'repo' => str_replace('.git', '', RepositoryDownloader::DEFAULT_REPO), 'releaseData' => [ - ['tag_name' => '1.0.0-2025.11.0', 'draft' => FALSE], + ['tag_name' => '1.0.0+2025.11.0', 'draft' => FALSE], ['tag_name' => '25.10.0', 'draft' => FALSE], ['tag_name' => '25.9.0', 'draft' => FALSE], ], 'throwException' => FALSE, 'skipMockSetup' => FALSE, - 'expectedVersion' => '1.0.0-2025.11.0', + 'expectedVersion' => '1.0.0+2025.11.0', 'expectedException' => NULL, 'expectedMessage' => NULL, ], @@ -817,7 +396,7 @@ public static function providerDiscoverLatestReleaseRemote(): array { * @return array> * Test data. */ - public static function providerDownloadWithNullDestination(): array { + public static function dataProviderDownloadWithNullDestination(): array { return [ 'remote repository' => [ 'repo' => 'https://github.com/user/repo', @@ -836,7 +415,7 @@ public static function providerDownloadWithNullDestination(): array { * @return array> * Test data. */ - public static function providerDownloadFromLocal(): array { + public static function dataProviderDownloadFromLocal(): array { return [ 'HEAD ref' => [ 'ref' => 'HEAD', @@ -907,4 +486,127 @@ protected function createMockFileDownloader(): MockObject { return $this->createMock(Downloader::class); } + public function testValidateRemoteRepositoryExistsWithNotFoundError(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_response = $this->createMock(ResponseInterface::class); + $mock_response->method('getStatusCode')->willReturn(404); + $mock_http_client->method('request')->willReturn($mock_response); + $destination = self::$tmp . '/destination_' . uniqid(); + File::mkdir($destination); + $downloader = new RepositoryDownloader($mock_http_client); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Repository not found or not accessible: "https://github.com/user/nonexistent" (HTTP 404)'); + $downloader->download(Artifact::create('https://github.com/user/nonexistent', '1.0.0'), $destination); + } + + public function testValidateRemoteRefExistsWithNotFoundError(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $repo_response = $this->createMock(ResponseInterface::class); + $repo_response->method('getStatusCode')->willReturn(200); + $ref_response = $this->createMock(ResponseInterface::class); + $ref_response->method('getStatusCode')->willReturn(404); + $mock_http_client->method('request')->willReturnCallback(function ($method, $url) use ($repo_response, $ref_response): ResponseInterface { + if (str_contains($url, '/archive/')) { + return $ref_response; + } + return $repo_response; + }); + $destination = self::$tmp . '/destination_' . uniqid(); + File::mkdir($destination); + $downloader = new RepositoryDownloader($mock_http_client); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Reference "nonexistent-tag" not found in repository "https://github.com/user/repo"'); + $downloader->download(Artifact::create('https://github.com/user/repo', 'nonexistent-tag'), $destination); + } + + public function testValidateLocalRepositoryExistsWithNonexistentPath(): void { + $nonexistent_path = self::$tmp . '/nonexistent_repo_' . uniqid(); + $destination = self::$tmp . '/destination_' . uniqid(); + File::mkdir($destination); + $downloader = new RepositoryDownloader(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(sprintf('Local repository path does not exist: "%s"', $nonexistent_path)); + $downloader->download(Artifact::create($nonexistent_path, 'main'), $destination); + } + + public function testValidateLocalRepositoryExistsWithNonGitDirectory(): void { + $non_git_path = self::$tmp . '/non_git_dir_' . uniqid(); + File::mkdir($non_git_path); + $destination = self::$tmp . '/destination_' . uniqid(); + File::mkdir($destination); + $downloader = new RepositoryDownloader(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(sprintf('Path is not a git repository: "%s"', $non_git_path)); + $downloader->download(Artifact::create($non_git_path, 'main'), $destination); + } + + public function testValidateRemoteRepositoryExistsWithRequestException(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_http_client->method('request')->willThrowException(new RequestException('Connection timeout', $this->createMock(RequestInterface::class))); + $destination = self::$tmp . '/destination_' . uniqid(); + File::mkdir($destination); + $downloader = new RepositoryDownloader($mock_http_client); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to access repository: "https://github.com/user/repo" - Connection timeout'); + $downloader->download(Artifact::create('https://github.com/user/repo', '1.0.0'), $destination); + } + + public function testValidateRemoteRefExistsWithRequestException(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $repo_response = $this->createMock(ResponseInterface::class); + $repo_response->method('getStatusCode')->willReturn(200); + $mock_http_client->method('request')->willReturnCallback(function ($method, $url) use ($repo_response): ResponseInterface { + if (str_contains($url, '/archive/')) { + throw new RequestException('Network error', $this->createMock(RequestInterface::class)); + } + return $repo_response; + }); + $destination = self::$tmp . '/destination_' . uniqid(); + File::mkdir($destination); + $downloader = new RepositoryDownloader($mock_http_client); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to verify reference "test-tag" in repository "https://github.com/user/repo" - Network error'); + $downloader->download(Artifact::create('https://github.com/user/repo', 'test-tag'), $destination); + } + + public function testValidateRemoteArtifactWithStableRef(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_response = $this->createMock(ResponseInterface::class); + $mock_response->method('getStatusCode')->willReturn(200); + $mock_http_client->method('request')->willReturn($mock_response); + $downloader = new RepositoryDownloader($mock_http_client); + $artifact = Artifact::create('https://github.com/user/repo', 'stable'); + $downloader->validate($artifact); + $this->expectNotToPerformAssertions(); + } + + public function testValidateRemoteArtifactWithCustomRef(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_response = $this->createMock(ResponseInterface::class); + $mock_response->method('getStatusCode')->willReturn(200); + $mock_http_client->method('request')->willReturn($mock_response); + $downloader = new RepositoryDownloader($mock_http_client); + $artifact = Artifact::create('https://github.com/user/repo', 'v1.0.0'); + $downloader->validate($artifact); + $this->expectNotToPerformAssertions(); + } + + public function testValidateLocalArtifactWithHeadRef(): void { + $temp_repo_dir = $this->createGitRepo(); + $downloader = new RepositoryDownloader(); + $artifact = Artifact::create($temp_repo_dir, 'HEAD'); + $downloader->validate($artifact); + $this->expectNotToPerformAssertions(); + $this->removeGitRepo($temp_repo_dir); + } + + public function testValidateLocalArtifactWithCustomRef(): void { + $temp_repo_dir = $this->createGitRepo(); + $downloader = new RepositoryDownloader(); + $artifact = Artifact::create($temp_repo_dir, 'main'); + $downloader->validate($artifact); + $this->expectNotToPerformAssertions(); + $this->removeGitRepo($temp_repo_dir); + } + } diff --git a/.vortex/installer/tests/Unit/Handlers/AbstractHandlerDiscoveryTestCase.php b/.vortex/installer/tests/Unit/Handlers/AbstractHandlerDiscoveryTestCase.php index 267310cb9..f8d3524d4 100644 --- a/.vortex/installer/tests/Unit/Handlers/AbstractHandlerDiscoveryTestCase.php +++ b/.vortex/installer/tests/Unit/Handlers/AbstractHandlerDiscoveryTestCase.php @@ -5,6 +5,7 @@ namespace DrevOps\VortexInstaller\Tests\Unit\Handlers; use AlexSkrypnyk\PhpunitHelpers\Traits\TuiTrait as UpstreamTuiTrait; +use DrevOps\VortexInstaller\Downloader\RepositoryDownloader; use DrevOps\VortexInstaller\Prompts\Handlers\AiCodeInstructions; use DrevOps\VortexInstaller\Prompts\Handlers\AssignAuthorPr; use DrevOps\VortexInstaller\Prompts\Handlers\CiProvider; @@ -266,7 +267,8 @@ protected function stubDotenvValue(string $name, mixed $value, string $filename protected function stubVortexProject(Config $config): void { // Add a README.md file with a Vortex badge. $readme = static::$sut . DIRECTORY_SEPARATOR . 'README.md'; - file_put_contents($readme, '[![Vortex](https://img.shields.io/badge/Vortex-1.2.3-65ACBC.svg)](https://github.com/drevops/vortex/tree/1.2.3)' . PHP_EOL, FILE_APPEND); + $repo_url = str_replace('.git', '', RepositoryDownloader::DEFAULT_REPO); + file_put_contents($readme, sprintf('[![Vortex](https://img.shields.io/badge/Vortex-1.2.3-65ACBC.svg)](%s/tree/1.2.3)', $repo_url) . PHP_EOL, FILE_APPEND); $config->set(Config::IS_VORTEX_PROJECT, TRUE); } diff --git a/.vortex/installer/tests/Unit/ValidatorTest.php b/.vortex/installer/tests/Unit/ValidatorTest.php index a38017a3f..66eccdc8d 100644 --- a/.vortex/installer/tests/Unit/ValidatorTest.php +++ b/.vortex/installer/tests/Unit/ValidatorTest.php @@ -167,4 +167,84 @@ public static function dataProviderGitCommitShaShort(): array { ]; } + #[DataProvider('dataProviderGitRef')] + public function testGitRef(string $ref, bool $expected): void { + $this->assertSame($expected, Validator::gitRef($ref)); + } + + public static function dataProviderGitRef(): array { + return [ + // Special keywords. + 'special keyword stable' => ['stable', TRUE], + 'special keyword HEAD' => ['HEAD', TRUE], + + // Commit hashes (already tested, but included for completeness). + 'valid 40-char commit hash' => ['a1b2c3d4e5f6789012345678901234567890abcd', TRUE], + 'valid 7-char commit hash' => ['a1b2c3d', TRUE], + + // Semantic versioning tags. + 'semver without prefix' => ['1.2.3', TRUE], + 'semver with v prefix' => ['v1.2.3', TRUE], + 'semver with patch zero' => ['2.0.0', TRUE], + 'semver with pre-release' => ['1.2.3-beta', TRUE], + 'semver with pre-release alpha' => ['1.2.3-alpha.1', TRUE], + 'semver with pre-release numbered' => ['1.2.3-beta.1', TRUE], + 'semver with build metadata' => ['1.2.3+20130313144700', TRUE], + 'semver with build metadata simple' => ['1.2.3+build', TRUE], + 'semver with pre-release and build' => ['1.2.3-alpha.1+build.123', TRUE], + + // Calendar versioning tags. + 'calver YY.MM.PATCH' => ['24.10.0', TRUE], + 'calver YY.MM.PATCH with higher version' => ['25.11.0', TRUE], + 'calver YYYY.MM.PATCH' => ['2024.12.3', TRUE], + + // Drupal-style versioning. + 'drupal 8.x version' => ['8.x-1.10', TRUE], + 'drupal 9.x version' => ['9.x-2.3', TRUE], + 'drupal 10.x version' => ['10.x-1.0', TRUE], + + // Hybrid versioning (SemVer with CalVer build metadata). + 'semver+calver hybrid' => ['1.0.0+2025.11.0', TRUE], + 'semver+calver hybrid v2' => ['1.2.0+2025.12.0', TRUE], + 'semver+calver with pre-release' => ['1.0.0-beta+2025.11.0', TRUE], + + // Pre-release tags. + 'pre-release rc' => ['1.x-rc1', TRUE], + 'pre-release beta' => ['2.0.0-beta', TRUE], + 'pre-release alpha' => ['3.0.0-alpha', TRUE], + + // Branch names. + 'branch main' => ['main', TRUE], + 'branch master' => ['master', TRUE], + 'branch develop' => ['develop', TRUE], + 'branch feature with slash' => ['feature/my-feature', TRUE], + 'branch bugfix with slash' => ['bugfix/fix-123', TRUE], + 'branch release with slash' => ['release/1.0', TRUE], + + // Invalid formats - special characters. + 'invalid with @' => ['invalid@ref', FALSE], + 'invalid with ^' => ['invalid^ref', FALSE], + 'invalid with ~' => ['invalid~ref', FALSE], + 'invalid with :' => ['invalid:ref', FALSE], + 'invalid with ?' => ['invalid?ref', FALSE], + 'invalid with *' => ['invalid*ref', FALSE], + 'invalid with [' => ['invalid[ref', FALSE], + 'invalid with space' => ['invalid ref', FALSE], + 'invalid with backslash' => ['invalid\ref', FALSE], + 'invalid with @{' => ['invalid@{ref', FALSE], + + // Invalid formats - starting/ending patterns. + 'invalid starting with dot' => ['.invalid', FALSE], + 'invalid starting with hyphen' => ['-invalid', FALSE], + 'invalid ending with .lock' => ['invalid.lock', FALSE], + 'invalid containing ..' => ['invalid..ref', FALSE], + 'invalid trailing slash' => ['feature/', FALSE], + 'invalid consecutive slashes' => ['feature//name', FALSE], + + // Empty and edge cases. + 'invalid empty string' => ['', FALSE], + 'invalid only spaces' => [' ', FALSE], + ]; + } + } diff --git a/.vortex/tests/bats/unit/update-vortex.bats b/.vortex/tests/bats/unit/update-vortex.bats index dea4bc2a8..1816165a1 100644 --- a/.vortex/tests/bats/unit/update-vortex.bats +++ b/.vortex/tests/bats/unit/update-vortex.bats @@ -19,7 +19,7 @@ load ../_helper.bash # Test default values when no environment variables are set. declare -a STEPS=( "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" - "@php installer.php --no-interaction --uri=https://github.com/drevops/vortex.git@stable # 0" + "@php installer.php --no-interaction --uri=https://github.com/drevops/vortex.git\#stable # 0" "Using installer script from URL: https://www.vortextemplate.com/install" "Downloading installer to installer.php" ) @@ -43,13 +43,13 @@ load ../_helper.bash declare -a STEPS=( "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" - "@php installer.php --no-interaction --uri=https://github.com/custom/repo.git@main # 0" + "@php installer.php --no-interaction --uri=https://github.com/custom/repo.git\#main # 0" "Using installer script from URL: https://www.vortextemplate.com/install" "Downloading installer to installer.php" ) mocks="$(run_steps "setup")" - run "${ROOT_DIR}/scripts/vortex/update-vortex.sh" "https://github.com/custom/repo.git@main" + run "${ROOT_DIR}/scripts/vortex/update-vortex.sh" "https://github.com/custom/repo.git#main" run_steps "assert" "${mocks[@]}" assert_success @@ -70,7 +70,7 @@ load ../_helper.bash export VORTEX_INSTALLER_PATH="${test_installer}" declare -a STEPS=( - "@php ${test_installer} --no-interaction --uri=https://github.com/drevops/vortex.git@stable # 0" + "@php ${test_installer} --no-interaction --uri=https://github.com/drevops/vortex.git\#stable # 0" "Using installer script from local path: ${test_installer}" ) @@ -142,13 +142,13 @@ load ../_helper.bash declare -a STEPS=( "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" - "@php installer.php --no-interaction --uri=/local/path/to/vortex@stable # 0" + "@php installer.php --no-interaction --uri=/local/path/to/vortex\#stable # 0" "Using installer script from URL: https://www.vortextemplate.com/install" "Downloading installer to installer.php" ) mocks="$(run_steps "setup")" - run "${ROOT_DIR}/scripts/vortex/update-vortex.sh" "/local/path/to/vortex@stable" + run "${ROOT_DIR}/scripts/vortex/update-vortex.sh" "/local/path/to/vortex#stable" run_steps "assert" "${mocks[@]}" assert_success @@ -166,13 +166,13 @@ load ../_helper.bash declare -a STEPS=( "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" - "@php installer.php --no-interaction --uri=git@github.com:drevops/vortex.git@v1.2.3 # 0" + "@php installer.php --no-interaction --uri=git@github.com:drevops/vortex.git\#v1.2.3 # 0" "Using installer script from URL: https://www.vortextemplate.com/install" "Downloading installer to installer.php" ) mocks="$(run_steps "setup")" - run "${ROOT_DIR}/scripts/vortex/update-vortex.sh" "git@github.com:drevops/vortex.git@v1.2.3" + run "${ROOT_DIR}/scripts/vortex/update-vortex.sh" "git@github.com:drevops/vortex.git#v1.2.3" run_steps "assert" "${mocks[@]}" assert_success @@ -190,7 +190,7 @@ load ../_helper.bash declare -a STEPS=( "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" - "@php installer.php --no-interaction --uri=https://github.com/drevops/vortex.git@stable # 1" + "@php installer.php --no-interaction --uri=https://github.com/drevops/vortex.git\#stable # 1" "Using installer script from URL: https://www.vortextemplate.com/install" "Downloading installer to installer.php" ) @@ -214,7 +214,7 @@ load ../_helper.bash declare -a STEPS=( "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" - "@php installer.php --uri=https://github.com/drevops/vortex.git@stable # 0" + "@php installer.php --uri=https://github.com/drevops/vortex.git\#stable # 0" "Using installer script from URL: https://www.vortextemplate.com/install" "Downloading installer to installer.php" ) @@ -238,13 +238,13 @@ load ../_helper.bash declare -a STEPS=( "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" - "@php installer.php --uri=https://github.com/custom/repo.git@main # 0" + "@php installer.php --uri=https://github.com/custom/repo.git\#main # 0" "Using installer script from URL: https://www.vortextemplate.com/install" "Downloading installer to installer.php" ) mocks="$(run_steps "setup")" - run "${ROOT_DIR}/scripts/vortex/update-vortex.sh" --interactive https://github.com/custom/repo.git@main + run "${ROOT_DIR}/scripts/vortex/update-vortex.sh" --interactive https://github.com/custom/repo.git#main run_steps "assert" "${mocks[@]}" assert_success diff --git a/.vortex/tests/phpunit/Functional/AhoyWorkflowTest.php b/.vortex/tests/phpunit/Functional/AhoyWorkflowTest.php index ce1703e9d..28112f573 100644 --- a/.vortex/tests/phpunit/Functional/AhoyWorkflowTest.php +++ b/.vortex/tests/phpunit/Functional/AhoyWorkflowTest.php @@ -380,7 +380,7 @@ public function testAhoyUpdateVortexRef(): void { $this->logSubstep('Update Vortex from the template repository'); // Use the argument instead of `VORTEX_INSTALLER_TEMPLATE_REPO` variable. - $this->cmd('ahoy update-vortex ' . static::$repo . '@' . $latest_installer_commit1, txt: 'Update Vortex to a specific version', env: [ + $this->cmd('ahoy update-vortex ' . static::$repo . '#' . $latest_installer_commit1, txt: 'Update Vortex to a specific version', env: [ // Override installer path to be called from SUT's update script. 'VORTEX_INSTALLER_URL' => 'file://' . $installer_bin, // Do not suppress the installer output so it could be used in assertions. diff --git a/.vortex/tests/phpunit/Functional/InstallerTest.php b/.vortex/tests/phpunit/Functional/InstallerTest.php index 3476fe9c3..7a6ce5de0 100644 --- a/.vortex/tests/phpunit/Functional/InstallerTest.php +++ b/.vortex/tests/phpunit/Functional/InstallerTest.php @@ -71,7 +71,7 @@ public function testInstallFromLatest(): void { // Do not suppress the installer output so it could be used in assertions. 'SHELL_VERBOSITY' => FALSE, ]; - $this->runInstaller([sprintf('--uri=%s@%s', static::$repo, 'stable')]); + $this->runInstaller([sprintf('--uri=%s#%s', static::$repo, 'stable')]); $this->assertProcessOutputContains(static::$repo); $this->assertProcessOutputNotContains($latest_installer_commit1); $this->assertProcessOutputNotContains($latest_installer_commit2); @@ -131,7 +131,7 @@ public function testInstallFromRef(): void { // Do not suppress the installer output so it could be used in assertions. 'SHELL_VERBOSITY' => FALSE, ]; - $this->runInstaller([sprintf('--uri=%s@%s', static::$repo, $latest_installer_commit1)]); + $this->runInstaller([sprintf('--uri=%s#%s', static::$repo, $latest_installer_commit1)]); $this->assertProcessOutputContains(static::$repo); $this->assertProcessOutputContains($latest_installer_commit1); $this->gitAssertIsRepository(static::$sut); diff --git a/.vortex/tests/yarn.lock b/.vortex/tests/yarn.lock index adcfacda7..64cdc6340 100644 --- a/.vortex/tests/yarn.lock +++ b/.vortex/tests/yarn.lock @@ -56,16 +56,16 @@ argparse@^2.0.1: integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== "bats-helpers@npm:@drevops/bats-helpers@^1.5.1": - version "1.5.1" - resolved "https://registry.yarnpkg.com/@drevops/bats-helpers/-/bats-helpers-1.5.1.tgz#96167a769887063b1c8090d3234b63ab760dc2c7" - integrity sha512-RxGHLHivcxyjgMtablXEArVvp8NkEx49YGe1wSSScTl4vad0U2BWkVkqedFBlrieyqegfEKx+tWpuPBz90plMw== + version "1.5.2" + resolved "https://registry.yarnpkg.com/@drevops/bats-helpers/-/bats-helpers-1.5.2.tgz#da4358420b7963d59c094eb3e0c88a07eaeed557" + integrity sha512-Ze4J+G+wsSYwD4gkSpyo5AxKnbKXYQKw131LgNjobAyeQbDLUxTsoYezn0q+7c2JJsltdcFTrgj9W4rlo4KXVg== dependencies: bats "^1" bats@^1: - version "1.12.0" - resolved "https://registry.yarnpkg.com/bats/-/bats-1.12.0.tgz#3ed99170325141e5d6dd53a84c3e5a702d5ab5be" - integrity sha512-1HTv2n+fjn3bmY9SNDgmzS6bjoKtVlSK2pIHON5aSA2xaqGkZFoCCWP46/G6jm9zZ7MCi84mD+3Byw4t3KGwBg== + version "1.13.0" + resolved "https://registry.yarnpkg.com/bats/-/bats-1.13.0.tgz#b90aa4434832fc80458ade07ebdeecee0925d8ae" + integrity sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ== braces@^3.0.3: version "3.0.3" @@ -95,9 +95,9 @@ commander@^8.3.0: integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== debug@^4.0.0: - version "4.4.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" - integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" @@ -227,9 +227,9 @@ jsonc-parser@3.3.1: integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== katex@^0.16.0: - version "0.16.22" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.22.tgz#d2b3d66464b1e6d69e6463b28a86ced5a02c5ccd" - integrity sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg== + version "0.16.25" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.25.tgz#61699984277e3bdb3e89e0e446b83cd0a57d87db" + integrity sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q== dependencies: commander "^8.3.0" diff --git a/scripts/vortex/update-vortex.sh b/scripts/vortex/update-vortex.sh index 396cded5c..4555de220 100755 --- a/scripts/vortex/update-vortex.sh +++ b/scripts/vortex/update-vortex.sh @@ -15,18 +15,18 @@ set -eu # # Examples: # https://github.com/drevops/vortex.git # Will auto-discover the latest stable tag from remote repo. -# https://github.com/drevops/vortex.git@stable # Will auto-discover the latest stable tag from remote repo. -# https://github.com/drevops/vortex.git@1.2.3 # Will use specific release from remote repo. -# https://github.com/drevops/vortex.git@abcd123 # Will use specific commit from remote repo. +# https://github.com/drevops/vortex.git#stable # Will auto-discover the latest stable tag from remote repo. +# https://github.com/drevops/vortex.git#1.2.3 # Will use specific release from remote repo. +# https://github.com/drevops/vortex.git#abcd123 # Will use specific commit from remote repo. # file:///local/path/to/vortex.git # Will auto-discover the latest stable tag from local repo. -# file:///local/path/to/vortex.git@stable # Will auto-discover the latest stable tag from local repo. -# file:///local/path/to/vortex.git@1.2.3 # Will use specific release from local repo. -# file:///local/path/to/vortex.git@abcd123 # Will use specific commit from local repo. +# file:///local/path/to/vortex.git#stable # Will auto-discover the latest stable tag from local repo. +# file:///local/path/to/vortex.git#1.2.3 # Will use specific release from local repo. +# file:///local/path/to/vortex.git#abcd123 # Will use specific commit from local repo. # /local/path/to/vortex.git # Will auto-discover the latest stable tag from local repo. -# /local/path/to/vortex.git@stable # Will auto-discover the latest stable tag from local repo. -# /local/path/to/vortex.git@1.2.3 # Will use specific release from local repo. -# /local/path/to/vortex.git@abcd123 # Will use specific commit from local repo. -VORTEX_INSTALLER_TEMPLATE_REPO="${VORTEX_INSTALLER_TEMPLATE_REPO:-https://github.com/drevops/vortex.git@stable}" +# /local/path/to/vortex.git#stable # Will auto-discover the latest stable tag from local repo. +# /local/path/to/vortex.git#1.2.3 # Will use specific release from local repo. +# /local/path/to/vortex.git#abcd123 # Will use specific commit from local repo. +VORTEX_INSTALLER_TEMPLATE_REPO="${VORTEX_INSTALLER_TEMPLATE_REPO:-https://github.com/drevops/vortex.git#stable}" # The URL of the installer script. VORTEX_INSTALLER_URL="${VORTEX_INSTALLER_URL:-https://www.vortextemplate.com/install}"