• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

valksor / php-dev-build / 21323318062

24 Jan 2026 11:21PM UTC coverage: 27.706% (-2.8%) from 30.503%
21323318062

push

github

k0d3r1s
wip

1 of 2 new or added lines in 2 files covered. (50.0%)

909 existing lines in 16 files now uncovered.

791 of 2855 relevant lines covered (27.71%)

0.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

53.93
/Binary/BinaryAssetManager.php
1
<?php declare(strict_types = 1);
2

3
/*
4
 * This file is part of the Valksor package.
5
 *
6
 * (c) Davis Zalitis (k0d3r1s)
7
 * (c) SIA Valksor <packages@valksor.com>
8
 *
9
 * For the full copyright and license information, please view the LICENSE
10
 * file that was distributed with this source code.
11
 */
12

13
namespace ValksorDev\Build\Binary;
14

15
use DateTimeImmutable;
16
use JsonException;
17
use RuntimeException;
18
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
19
use Valksor\Functions\Local\Traits\_MkDir;
20
use ZipArchive;
21

22
use function array_key_exists;
23
use function chmod;
24
use function date;
25
use function escapeshellarg;
26
use function exec;
27
use function explode;
28
use function file_get_contents;
29
use function file_put_contents;
30
use function http_get_last_response_headers;
31
use function implode;
32
use function in_array;
33
use function is_array;
34
use function is_file;
35
use function json_decode;
36
use function json_encode;
37
use function ltrim;
38
use function php_uname;
39
use function rename;
40
use function sleep;
41
use function sprintf;
42
use function str_contains;
43
use function str_starts_with;
44
use function stream_context_create;
45
use function strpos;
46
use function substr;
47
use function sys_get_temp_dir;
48
use function trim;
49
use function uniqid;
50
use function unlink;
51

52
use const DATE_ATOM;
53
use const JSON_PRETTY_PRINT;
54
use const JSON_THROW_ON_ERROR;
55
use const JSON_UNESCAPED_SLASHES;
56
use const PHP_OS_FAMILY;
57

58
/**
59
 * Generic binary/asset manager for downloading and managing tool binaries and assets from GitHub releases.
60
 * Supports tailwindcss, esbuild, daisyui, and other tools.
61
 */
62
final class BinaryAssetManager
63
{
64
    private const string VERSION_FILE = 'version.json';
65

66
    /**
67
     * @param array{
68
     *     name: string,
69
     *     source: 'github'|'npm'|'github-zip',
70
     *     repo?: string,
71
     *     npm_package?: string,
72
     *     npm_dist_tag?: string,
73
     *     assets: array<int,array{pattern:string,target:string,executable:bool,extract_path?:string}>,
74
     *     target_dir: string,
75
     *     version_in_path?: bool,
76
     *     download_strategy?: 'release'|'tag'|'commit',
77
     *     commit_ref?: string,
78
     *     pinned_version?: string
79
     * } $toolConfig
80
     */
81
    public function __construct(
82
        private readonly array $toolConfig,
83
        private readonly ?ParameterBagInterface $parameterBag = null,
84
    ) {
85
    }
18✔
86

87
    /**
88
     * @throws JsonException
89
     */
90
    public function ensureLatest(
91
        ?callable $logger = null,
92
    ): string {
93
        $targetDir = $this->toolConfig['target_dir'];
6✔
94
        $this->ensureDirectory($targetDir);
6✔
95

96
        $currentTag = $this->readCurrentTag($targetDir);
6✔
97
        $assetsPresent = $this->assetsPresent($targetDir);
6✔
98

99
        // Check for pinned version first
100
        if (isset($this->toolConfig['pinned_version'])) {
6✔
101
            $pinnedVersion = $this->toolConfig['pinned_version'];
×
102

103
            if (null !== $currentTag && $assetsPresent && $currentTag === $pinnedVersion) {
×
UNCOV
104
                $this->log($logger, sprintf('%s assets pinned to version %s.', $this->toolConfig['name'], $currentTag));
×
105

UNCOV
106
                return $currentTag;
×
107
            }
108

109
            // Download pinned version
110
            $this->log($logger, sprintf('Downloading %s assets (%s)…', $this->toolConfig['name'], $pinnedVersion));
×
111

UNCOV
112
            if ('npm' === $this->toolConfig['source']) {
×
113
                $this->downloadNpmAsset($pinnedVersion, $targetDir);
×
114
            } elseif ('github-zip' === $this->toolConfig['source'] || in_array($this->toolConfig['download_strategy'] ?? 'release', ['tag', 'commit'], true)) {
×
UNCOV
115
                $this->downloadGithubZipAsset($pinnedVersion, $targetDir);
×
116
            } else {
UNCOV
117
                foreach ($this->toolConfig['assets'] as $assetConfig) {
×
118
                    $this->downloadAsset($pinnedVersion, $assetConfig, $targetDir);
×
119
                }
120
            }
121

UNCOV
122
            $this->writeVersionFile($targetDir, $pinnedVersion, $pinnedVersion);
×
UNCOV
123
            $this->log($logger, sprintf('%s assets updated.', $this->toolConfig['name']));
×
124

UNCOV
125
            return $pinnedVersion;
×
126
        }
127

128
        // Original logic for non-pinned versions
129
        $downloadStrategy = $this->toolConfig['download_strategy'] ?? 'release';
6✔
130
        $latest = match (true) {
1✔
131
            'npm' === $this->toolConfig['source'] => $this->fetchLatestNpmVersion(),
6✔
132
            'tag' === $downloadStrategy => $this->fetchLatestTag(),
5✔
133
            'commit' === $downloadStrategy => $this->fetchLatestCommit(),
4✔
134
            default => $this->fetchLatestRelease(),
3✔
135
        };
1✔
136

137
        if (null !== $currentTag && $assetsPresent && $currentTag === $latest['version']) {
1✔
138
            $this->log($logger, sprintf('%s assets already current (%s).', $this->toolConfig['name'], $currentTag));
×
139

UNCOV
140
            return $currentTag;
×
141
        }
142

143
        $this->log($logger, sprintf('Downloading %s assets (%s)…', $this->toolConfig['name'], $latest['tag']));
1✔
144

145
        if ('npm' === $this->toolConfig['source']) {
1✔
146
            $this->downloadNpmAsset($latest['version'], $targetDir);
1✔
UNCOV
147
        } elseif ('github-zip' === $this->toolConfig['source'] || in_array($downloadStrategy, ['tag', 'commit'], true)) {
×
UNCOV
148
            $this->downloadGithubZipAsset($latest['tag'], $targetDir);
×
149
        } else {
150
            foreach ($this->toolConfig['assets'] as $assetConfig) {
×
151
                $this->downloadAsset($latest['tag'], $assetConfig, $targetDir);
×
152
            }
153
        }
154

UNCOV
155
        $this->writeVersionFile($targetDir, $latest['tag'], $latest['version']);
×
UNCOV
156
        $this->log($logger, sprintf('%s assets updated.', $this->toolConfig['name']));
×
157

158
        return $latest['tag'];
×
159
    }
160

161
    /**
162
     * Factory method from custom tool definition array.
163
     */
164
    public static function createFromDefinition(
165
        array $definition,
166
    ): self {
UNCOV
167
        if (!isset($definition['name'], $definition['source'], $definition['assets'], $definition['target_dir'])) {
×
UNCOV
168
            throw new RuntimeException('Tool definition must include name, source, assets, and target_dir.');
×
169
        }
170

UNCOV
171
        if (isset($definition['repo'])) {
×
UNCOV
172
            throw new RuntimeException('GitHub source requires repo parameter.');
×
173
        }
174

UNCOV
175
        if (isset($definition['npm_package'])) {
×
UNCOV
176
            throw new RuntimeException('npm source requires npm_package parameter.');
×
177
        }
178

UNCOV
179
        return new self($definition);
×
180
    }
181

182
    /**
183
     * Detect current platform for binary downloads.
184
     */
185
    public static function detectPlatform(): string
186
    {
UNCOV
187
        $os = PHP_OS_FAMILY;
×
188
        $arch = php_uname('m');
×
189

190
        if ('Darwin' === $os) {
×
191
            return str_contains($arch, 'arm') || str_contains($arch, 'aarch64') ? 'darwin-arm64' : 'darwin-x64';
×
192
        }
193

UNCOV
194
        if ('Linux' === $os) {
×
195
            return str_contains($arch, 'arm') || str_contains($arch, 'aarch64') ? 'linux-arm64' : 'linux-x64';
×
196
        }
197

198
        if ('Windows' === $os) {
×
199
            return 'windows-x64';
×
200
        }
201

202
        return 'linux-x64'; // Default fallback
×
203
    }
204

205
    private function assetsPresent(
206
        string $targetDir,
207
    ): bool {
208
        foreach ($this->toolConfig['assets'] as $assetConfig) {
7✔
209
            if (!is_file($targetDir . '/' . $assetConfig['target'])) {
5✔
210
                return false;
4✔
211
            }
212
        }
213

214
        return true;
4✔
215
    }
216

217
    /**
218
     * @param array{pattern:string,target:string,executable:bool} $assetConfig
219
     */
220
    private function downloadAsset(
221
        string $tag,
222
        array $assetConfig,
223
        string $targetDir,
224
    ): void {
UNCOV
225
        $url = sprintf(
×
UNCOV
226
            'https://github.com/%s/releases/download/%s/%s',
×
227
            $this->toolConfig['repo'],
×
228
            $tag,
×
UNCOV
229
            $assetConfig['pattern'],
×
230
        );
×
231

232
        $context = stream_context_create([
×
233
            'http' => [
×
234
                'method' => 'GET',
×
235
                'header' => [
×
236
                    'User-Agent: valksor-binary-manager',
×
237
                ],
×
238
                'follow_location' => 1,
×
239
                'timeout' => 30,
×
240
            ],
×
241
        ]);
×
242

UNCOV
243
        $content = @file_get_contents($url, false, $context);
×
244

UNCOV
245
        if (false === $content) {
×
246
            throw new RuntimeException(sprintf('Failed to download %s asset: %s', $this->toolConfig['name'], $assetConfig['pattern']));
×
247
        }
248

UNCOV
249
        $targetPath = $targetDir . '/' . $assetConfig['target'];
×
250

251
        if (false === file_put_contents($targetPath, $content)) {
×
252
            throw new RuntimeException(sprintf('Failed to write %s asset to %s', $this->toolConfig['name'], $targetPath));
×
253
        }
254

255
        if ($assetConfig['executable'] && !chmod($targetPath, 0o755)) {
×
256
            throw new RuntimeException(sprintf('Failed to set executable permissions on %s', $targetPath));
×
257
        }
258
    }
259

260
    private function downloadGithubZipAsset(
261
        string $tag,
262
        string $targetDir,
263
    ): void {
UNCOV
264
        $downloadStrategy = $this->toolConfig['download_strategy'] ?? 'release';
×
265
        $assetConfig = $this->toolConfig['assets'][0];
×
266

267
        $url = match ($downloadStrategy) {
×
268
            'tag', 'commit' => sprintf(
×
UNCOV
269
                'https://api.github.com/repos/%s/tarball/%s',
×
UNCOV
270
                $this->toolConfig['repo'],
×
UNCOV
271
                $tag,
×
272
            ),
×
UNCOV
273
            default => sprintf(
×
274
                'https://github.com/%s/releases/download/%s/%s',
×
275
                $this->toolConfig['repo'],
×
UNCOV
276
                $tag,
×
UNCOV
277
                sprintf($assetConfig['pattern'], ltrim($tag, 'v')),
×
278
            ),
×
279
        };
×
280

281
        $headers = ['User-Agent: valksor-binary-manager'];
×
282

UNCOV
283
        if (in_array($downloadStrategy, ['tag', 'commit'], true)) {
×
UNCOV
284
            $headers[] = 'Accept: application/vnd.github+json';
×
285
        }
286

UNCOV
287
        $context = stream_context_create([
×
UNCOV
288
            'http' => [
×
UNCOV
289
                'method' => 'GET',
×
UNCOV
290
                'header' => $headers,
×
UNCOV
291
                'follow_location' => 1,
×
UNCOV
292
                'timeout' => 30,
×
UNCOV
293
            ],
×
UNCOV
294
        ]);
×
295

296
        $zipContent = @file_get_contents($url, false, $context);
×
297

UNCOV
298
        if (false === $zipContent) {
×
UNCOV
299
            throw new RuntimeException(sprintf('Failed to download %s zip: %s', $this->toolConfig['name'], $url));
×
300
        }
301

UNCOV
302
        $tmpZip = sys_get_temp_dir() . '/valksor-' . uniqid(more_entropy: true) . '.zip';
×
303

UNCOV
304
        if (false === file_put_contents($tmpZip, $zipContent)) {
×
UNCOV
305
            throw new RuntimeException(sprintf('Failed to write temporary zip for %s.', $this->toolConfig['name']));
×
306
        }
307

308
        try {
UNCOV
309
            $zip = new ZipArchive();
×
310

UNCOV
311
            if (true !== $zip->open($tmpZip)) {
×
UNCOV
312
                throw new RuntimeException(sprintf('Unable to open %s zip archive.', $this->toolConfig['name']));
×
313
            }
314

315
            $zip->extractTo($targetDir);
×
UNCOV
316
            $zip->close();
×
317
        } finally {
UNCOV
318
            @unlink($tmpZip);
×
319
        }
320
    }
321

322
    private function downloadNpmAsset(
323
        string $version,
324
        string $targetDir,
325
    ): void {
326
        $assetConfig = $this->toolConfig['assets'][0];
1✔
327

328
        // Use the actual npm package from configuration instead of hardcoded @esbuild
329
        $npmPackage = $this->toolConfig['npm_package'];
1✔
330

331
        // For scoped packages (@scope/package), use the correct URL format
332
        if (str_starts_with($npmPackage, '@')) {
1✔
UNCOV
333
            $packageName = substr($npmPackage, strpos($npmPackage, '/') + 1);
×
UNCOV
334
            $url = sprintf('https://registry.npmjs.org/%s/-/%s-%s.tgz', $npmPackage, $packageName, $version);
×
335
        } else {
336
            // For regular packages, download the full package
337
            $url = sprintf('https://registry.npmjs.org/%s/-/%s-%s.tgz', $npmPackage, $npmPackage, $version);
1✔
338
        }
339

340
        $context = stream_context_create([
1✔
341
            'http' => [
1✔
342
                'method' => 'GET',
1✔
343
                'header' => 'User-Agent: valksor-binary-manager',
1✔
344
                'follow_location' => 1,
1✔
345
                'timeout' => 30,
1✔
346
            ],
1✔
347
        ]);
1✔
348

349
        $tgzContent = @file_get_contents($url, false, $context);
1✔
350

351
        if (false === $tgzContent) {
1✔
UNCOV
352
            throw new RuntimeException(sprintf('Failed to download %s from npm registry: %s', $this->toolConfig['name'], $url));
×
353
        }
354

355
        $tmpDir = sys_get_temp_dir() . '/valksor-binary-' . uniqid(more_entropy: true);
1✔
356
        $this->ensureDirectory($tmpDir);
1✔
357

358
        try {
359
            $tgzPath = $tmpDir . '/package.tgz';
1✔
360

361
            if (false === file_put_contents($tgzPath, $tgzContent)) {
1✔
362
                throw new RuntimeException(sprintf('Failed to write temporary tarball for %s.', $this->toolConfig['name']));
×
363
            }
364

365
            $extractPath = $assetConfig['extract_path'] ?? 'package/bin/esbuild';
1✔
366
            $extractDir = $tmpDir . '/extracted';
1✔
367
            $this->ensureDirectory($extractDir);
1✔
368

369
            // Extract the entire tarball
370
            exec(sprintf('tar -xzf %s -C %s 2>&1', escapeshellarg($tgzPath), escapeshellarg($extractDir)), $output, $returnCode);
1✔
371

372
            if (0 !== $returnCode) {
1✔
UNCOV
373
                throw new RuntimeException(sprintf('Failed to extract %s tarball: %s', $this->toolConfig['name'], implode("\n", $output)));
×
374
            }
375

376
            // Handle both file and directory extraction
377
            $sourcePath = $extractDir . '/' . $extractPath;
1✔
378
            $targetPath = $targetDir . '/' . $assetConfig['target'];
1✔
379

380
            // If target is '.', copy the entire extracted directory
381
            if ('.' === $assetConfig['target']) {
1✔
382
                // Copy entire extracted directory contents to target
UNCOV
383
                exec(sprintf('cp -r %s/* %s/ 2>&1', escapeshellarg($sourcePath), escapeshellarg($targetDir)), $output, $returnCode);
×
384

UNCOV
385
                if (0 !== $returnCode) {
×
UNCOV
386
                    throw new RuntimeException(sprintf('Failed to copy %s package: %s', $this->toolConfig['name'], implode("\n", $output)));
×
387
                }
388
            } else {
389
                // Original behavior: move specific file
390
                if (!is_file($sourcePath)) {
1✔
391
                    throw new RuntimeException(sprintf('Extracted binary not found at %s', $sourcePath));
1✔
392
                }
393

UNCOV
394
                if (!rename($sourcePath, $targetPath)) {
×
UNCOV
395
                    throw new RuntimeException(sprintf('Failed to move %s binary to %s', $this->toolConfig['name'], $targetPath));
×
396
                }
397
            }
398

UNCOV
399
            if ($assetConfig['executable'] && !chmod($targetPath, 0o755)) {
×
UNCOV
400
                throw new RuntimeException(sprintf('Failed to set executable permissions on %s', $targetPath));
×
401
            }
402
        } finally {
403
            exec(sprintf('rm -rf %s 2>&1', escapeshellarg($tmpDir)));
1✔
404
        }
405
    }
406

407
    private function ensureDirectory(
408
        string $directory,
409
    ): void {
410
        static $_helper = null;
7✔
411

412
        if (null === $_helper) {
7✔
413
            $_helper = new class {
1✔
414
                use _MkDir;
415
            };
1✔
416
        }
417

418
        $_helper->mkdir($directory);
7✔
419
    }
420

421
    /**
422
     * Extract HTTP status code from response headers.
423
     */
424
    private function extractHttpStatusCode(
425
        array $headers,
426
    ): int {
427
        foreach ($headers as $header) {
4✔
428
            if (str_starts_with($header, 'HTTP/')) {
4✔
429
                $parts = explode(' ', $header);
4✔
430

431
                return (int) ($parts[1] ?? 0);
4✔
432
            }
433
        }
434

UNCOV
435
        return 0;
×
436
    }
437

438
    /**
439
     * @return array{tag: string, version: string}
440
     */
441
    private function fetchLatestCommit(): array
442
    {
443
        $commitRef = $this->toolConfig['commit_ref'] ?? $this->getDefaultBranch();
2✔
444

445
        $apiUrl = sprintf('https://api.github.com/repos/%s/commits/%s', $this->toolConfig['repo'], $commitRef);
2✔
446

447
        $context = stream_context_create([
2✔
448
            'http' => [
2✔
449
                'method' => 'GET',
2✔
450
                'header' => [
2✔
451
                    'User-Agent: valksor-binary-manager',
2✔
452
                    'Accept: application/vnd.github+json',
2✔
453
                ],
2✔
454
                'timeout' => 15,
2✔
455
            ],
2✔
456
        ]);
2✔
457

458
        $response = @file_get_contents($apiUrl, false, $context);
2✔
459

460
        if (false === $response) {
2✔
461
            throw new RuntimeException(sprintf('Failed to fetch commit %s for %s from GitHub API.', $commitRef, $this->toolConfig['name']));
2✔
462
        }
463

464
        try {
UNCOV
465
            $commit = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
UNCOV
466
        } catch (JsonException $exception) {
×
UNCOV
467
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s commit: %s', $this->toolConfig['name'], $exception->getMessage()));
×
468
        }
469

470
        if (!array_key_exists('sha', $commit)) {
×
UNCOV
471
            throw new RuntimeException(sprintf('Unexpected GitHub API commit response structure for %s.', $this->toolConfig['name']));
×
472
        }
473

474
        $shortSha = substr($commit['sha'], 0, 7);
×
475

UNCOV
476
        return [
×
UNCOV
477
            'tag' => $shortSha,
×
UNCOV
478
            'version' => $shortSha,
×
UNCOV
479
        ];
×
480
    }
481

482
    /**
483
     * @return array{tag: string, version: string}
484
     */
485
    private function fetchLatestNpmVersion(): array
486
    {
487
        $distTag = $this->toolConfig['npm_dist_tag'] ?? 'latest';
1✔
488
        $packageUrl = sprintf('https://registry.npmjs.org/%s/%s', $this->toolConfig['npm_package'], $distTag);
1✔
489

490
        $context = stream_context_create([
1✔
491
            'http' => [
1✔
492
                'method' => 'GET',
1✔
493
                'header' => 'User-Agent: valksor-binary-manager',
1✔
494
                'timeout' => 15,
1✔
495
            ],
1✔
496
        ]);
1✔
497

498
        $response = @file_get_contents($packageUrl, false, $context);
1✔
499

500
        if (false === $response) {
1✔
UNCOV
501
            throw new RuntimeException(sprintf('Failed to fetch latest version for %s from npm registry.', $this->toolConfig['name']));
×
502
        }
503

504
        try {
505
            $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
1✔
UNCOV
506
        } catch (JsonException $exception) {
×
UNCOV
507
            throw new RuntimeException(sprintf('Invalid JSON response from npm registry for %s: %s', $this->toolConfig['name'], $exception->getMessage()));
×
508
        }
509

510
        if (!array_key_exists('version', $data)) {
1✔
UNCOV
511
            throw new RuntimeException(sprintf('Unexpected npm registry response structure for %s.', $this->toolConfig['name']));
×
512
        }
513

514
        return [
1✔
515
            'tag' => $data['version'],
1✔
516
            'version' => $data['version'],
1✔
517
        ];
1✔
518
    }
519

520
    /**
521
     * @return array{tag: string, version: string}
522
     */
523
    private function fetchLatestRelease(): array
524
    {
525
        if ('npm' === $this->toolConfig['source']) {
4✔
UNCOV
526
            return $this->fetchLatestNpmVersion();
×
527
        }
528

529
        $maxRetries = 3;
4✔
530
        $baseDelay = 2; // seconds
4✔
531

532
        for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
4✔
533
            try {
534
                return $this->fetchLatestReleaseWithRetry($attempt);
4✔
535
            } catch (RuntimeException $exception) {
4✔
536
                // Don't retry on rate limit errors with explicit guidance
537
                if ($this->isRateLimitError($exception->getMessage())) {
4✔
538
                    throw $exception;
1✔
539
                }
540

541
                // Retry on other failures with exponential backoff
542
                if ($attempt < $maxRetries) {
3✔
543
                    $delay = $baseDelay ** $attempt;
3✔
544
                    sleep($delay);
3✔
545

546
                    continue;
3✔
547
                }
548

549
                throw $exception;
3✔
550
            }
551
        }
552

UNCOV
553
        throw new RuntimeException(sprintf('Failed to fetch latest release for %s after %d attempts.', $this->toolConfig['name'], $maxRetries));
×
554
    }
555

556
    /**
557
     * @return array{tag: string, version: string}
558
     */
559
    private function fetchLatestReleaseWithRetry(
560
        int $attempt,
561
    ): array {
562
        $apiUrl = sprintf('https://api.github.com/repos/%s/releases/latest', $this->toolConfig['repo']);
4✔
563

564
        // Build headers with optional GitHub token
565
        $headers = [
4✔
566
            'User-Agent: valksor-binary-manager',
4✔
567
            'Accept: application/vnd.github+json',
4✔
568
        ];
4✔
569

570
        $githubToken = $this->getGitHubToken();
4✔
571

572
        if ($githubToken) {
4✔
UNCOV
573
            $headers[] = 'Authorization: token ' . $githubToken;
×
574
        }
575

576
        $context = stream_context_create([
4✔
577
            'http' => [
4✔
578
                'method' => 'GET',
4✔
579
                'header' => $headers,
4✔
580
                'timeout' => 15,
4✔
581
                'ignore_errors' => true, // Allow us to read response headers even on errors
4✔
582
            ],
4✔
583
        ]);
4✔
584

585
        $response = @file_get_contents($apiUrl, false, $context);
4✔
586

587
        // Check HTTP response code
588
        $responseHeaders = http_get_last_response_headers();
4✔
589

590
        if (null !== $responseHeaders) {
4✔
591
            $statusCode = $this->extractHttpStatusCode($responseHeaders);
4✔
592

593
            if (403 === $statusCode) {
4✔
594
                $rateLimitInfo = $this->parseRateLimitHeaders($responseHeaders);
1✔
595

596
                if (0 === $rateLimitInfo['remaining']) {
1✔
597
                    $resetTime = $rateLimitInfo['reset'] ? date('Y-m-d H:i:s', $rateLimitInfo['reset']) : 'unknown';
1✔
598
                    $message = sprintf(
1✔
599
                        'GitHub API rate limit exceeded for %s. Reset time: %s. ' .
1✔
600
                        'To increase rate limit, set valksor.build.github_token parameter',
1✔
601
                        $this->toolConfig['name'],
1✔
602
                        $resetTime,
1✔
603
                    );
1✔
604

605
                    throw new RuntimeException($message);
1✔
606
                }
607

UNCOV
608
                throw new RuntimeException(sprintf('Access forbidden to GitHub API for %s (HTTP 403).', $this->toolConfig['name']));
×
609
            }
610

611
            if (404 === $statusCode) {
3✔
612
                throw new RuntimeException(sprintf('GitHub repository or releases not found for %s (HTTP 404).', $this->toolConfig['name']));
3✔
613
            }
614

UNCOV
615
            if ($statusCode >= 400) {
×
UNCOV
616
                throw new RuntimeException(sprintf('GitHub API returned HTTP %d for %s.', $statusCode, $this->toolConfig['name']));
×
617
            }
618
        }
619

UNCOV
620
        if (false === $response) {
×
UNCOV
621
            $errorMsg = sprintf('Failed to fetch latest release for %s from GitHub API', $this->toolConfig['name']);
×
622

UNCOV
623
            if ($attempt > 1) {
×
UNCOV
624
                $errorMsg .= sprintf(' (attempt %d/%d)', $attempt, 3);
×
625
            }
626

UNCOV
627
            throw new RuntimeException($errorMsg . '.');
×
628
        }
629

630
        try {
UNCOV
631
            $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
632
        } catch (JsonException $exception) {
×
633
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s: %s', $this->toolConfig['name'], $exception->getMessage()));
×
634
        }
635

UNCOV
636
        if (!array_key_exists('tag_name', $data)) {
×
637
            throw new RuntimeException(sprintf('Unexpected GitHub API response structure for %s.', $this->toolConfig['name']));
×
638
        }
639

UNCOV
640
        return [
×
641
            'tag' => $data['tag_name'],
×
UNCOV
642
            'version' => $data['name'] ?? $data['tag_name'],
×
643
        ];
×
644
    }
645

646
    /**
647
     * @return array{tag: string, version: string}
648
     */
649
    private function fetchLatestTag(): array
650
    {
651
        $apiUrl = sprintf('https://api.github.com/repos/%s/tags', $this->toolConfig['repo']);
2✔
652

653
        $context = stream_context_create([
2✔
654
            'http' => [
2✔
655
                'method' => 'GET',
2✔
656
                'header' => [
2✔
657
                    'User-Agent: valksor-binary-manager',
2✔
658
                    'Accept: application/vnd.github+json',
2✔
659
                ],
2✔
660
                'timeout' => 15,
2✔
661
            ],
2✔
662
        ]);
2✔
663

664
        $response = @file_get_contents($apiUrl, false, $context);
2✔
665

666
        if (false === $response) {
2✔
667
            throw new RuntimeException(sprintf('Failed to fetch latest tag for %s from GitHub API.', $this->toolConfig['name']));
2✔
668
        }
669

670
        try {
UNCOV
671
            $tags = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
UNCOV
672
        } catch (JsonException $exception) {
×
UNCOV
673
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s tags: %s', $this->toolConfig['name'], $exception->getMessage()));
×
674
        }
675

UNCOV
676
        if (!is_array($tags) || empty($tags)) {
×
UNCOV
677
            throw new RuntimeException(sprintf('No tags found for %s repository.', $this->toolConfig['name']));
×
678
        }
679

680
        $latestTag = $tags[0];
×
681

682
        if (!array_key_exists('name', $latestTag)) {
×
UNCOV
683
            throw new RuntimeException(sprintf('Unexpected GitHub API tags response structure for %s.', $this->toolConfig['name']));
×
684
        }
685

686
        $tagName = $latestTag['name'];
×
687

UNCOV
688
        return [
×
689
            'tag' => $tagName,
×
UNCOV
690
            'version' => $tagName,
×
UNCOV
691
        ];
×
692
    }
693

694
    /**
695
     * Get the default branch for the repository.
696
     */
697
    private function getDefaultBranch(): string
698
    {
699
        $apiUrl = sprintf('https://api.github.com/repos/%s', $this->toolConfig['repo']);
1✔
700

701
        $context = stream_context_create([
1✔
702
            'http' => [
1✔
703
                'method' => 'GET',
1✔
704
                'header' => [
1✔
705
                    'User-Agent: valksor-binary-manager',
1✔
706
                    'Accept: application/vnd.github+json',
1✔
707
                ],
1✔
708
                'timeout' => 15,
1✔
709
            ],
1✔
710
        ]);
1✔
711

712
        $response = @file_get_contents($apiUrl, false, $context);
1✔
713

714
        if (false === $response) {
1✔
715
            throw new RuntimeException(sprintf('Failed to fetch repository info for %s from GitHub API.', $this->toolConfig['name']));
1✔
716
        }
717

718
        try {
UNCOV
719
            $repo = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
UNCOV
720
        } catch (JsonException $exception) {
×
UNCOV
721
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s repository: %s', $this->toolConfig['name'], $exception->getMessage()));
×
722
        }
723

UNCOV
724
        if (!array_key_exists('default_branch', $repo)) {
×
UNCOV
725
            throw new RuntimeException(sprintf('Unexpected GitHub API repository response structure for %s.', $this->toolConfig['name']));
×
726
        }
727

UNCOV
728
        return $repo['default_branch'];
×
729
    }
730

731
    /**
732
     * Get GitHub token from parameter bag or environment variable.
733
     */
734
    private function getGitHubToken(): ?string
735
    {
736
        // Try parameter bag first
737
        if ($this->parameterBag && $this->parameterBag->has('valksor.build.github_token')) {
4✔
UNCOV
738
            return $this->parameterBag->get('valksor.build.github_token');
×
739
        }
740

741
        // Fallback to environment variable
742
        return null;
4✔
743
    }
744

745
    /**
746
     * Check if an error message indicates a rate limit issue.
747
     */
748
    private function isRateLimitError(
749
        string $message,
750
    ): bool {
751
        return str_contains($message, 'rate limit') || str_contains($message, 'Rate limit');
4✔
752
    }
753

754
    private function log(
755
        ?callable $logger,
756
        string $message,
757
    ): void {
758
        if (null !== $logger) {
2✔
759
            $logger($message);
1✔
760
        }
761
    }
762

763
    /**
764
     * Parse rate limit information from response headers.
765
     */
766
    private function parseRateLimitHeaders(
767
        array $headers,
768
    ): array {
769
        $rateLimitInfo = [
1✔
770
            'limit' => null,
1✔
771
            'remaining' => null,
1✔
772
            'reset' => null,
1✔
773
        ];
1✔
774

775
        foreach ($headers as $header) {
1✔
776
            if (str_starts_with($header, 'X-RateLimit-Limit:')) {
1✔
777
                $rateLimitInfo['limit'] = (int) trim(substr($header, 19));
1✔
778
            } elseif (str_starts_with($header, 'X-RateLimit-Remaining:')) {
1✔
779
                $rateLimitInfo['remaining'] = (int) trim(substr($header, 23));
1✔
780
            } elseif (str_starts_with($header, 'X-RateLimit-Reset:')) {
1✔
781
                $rateLimitInfo['reset'] = (int) trim(substr($header, 19));
1✔
782
            }
783
        }
784

785
        return $rateLimitInfo;
1✔
786
    }
787

788
    private function readCurrentTag(
789
        string $targetDir,
790
    ): ?string {
791
        $versionFile = $targetDir . '/' . self::VERSION_FILE;
7✔
792

793
        if (!is_file($versionFile)) {
7✔
794
            return null;
6✔
795
        }
796

797
        $raw = @file_get_contents($versionFile);
2✔
798

799
        if (false === $raw || '' === $raw) {
2✔
UNCOV
800
            return null;
×
801
        }
802

803
        try {
804
            $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
2✔
805
        } catch (JsonException) {
1✔
806
            return null;
1✔
807
        }
808

809
        return array_key_exists('tag', $data) ? (string) $data['tag'] : null;
2✔
810
    }
811

812
    /**
813
     * @throws JsonException
814
     */
815
    private function writeVersionFile(
816
        string $targetDir,
817
        string $tag,
818
        string $version,
819
    ): void {
820
        $data = [
1✔
821
            'tag' => $tag,
1✔
822
            'version' => $version,
1✔
823
            'downloaded_at' => new DateTimeImmutable()->format(DATE_ATOM),
1✔
824
        ];
1✔
825

826
        $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
1✔
827
        $versionFile = $targetDir . '/' . self::VERSION_FILE;
1✔
828

829
        if (false === file_put_contents($versionFile, $json)) {
1✔
UNCOV
830
            throw new RuntimeException(sprintf('Failed to write version file for %s.', $this->toolConfig['name']));
×
831
        }
832
    }
833
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc