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

valksor / php-dev-build / 19705532601

26 Nov 2025 01:33PM UTC coverage: 30.503% (+2.6%) from 27.943%
19705532601

push

github

k0d3r1s
generic binary provider

131 of 243 new or added lines in 7 files covered. (53.91%)

135 existing lines in 7 files now uncovered.

783 of 2567 relevant lines covered (30.5%)

1.15 hits per line

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

55.81
/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 escapeshellarg;
25
use function exec;
26
use function file_get_contents;
27
use function file_put_contents;
28
use function implode;
29
use function in_array;
30
use function is_array;
31
use function is_file;
32
use function json_decode;
33
use function json_encode;
34
use function ltrim;
35
use function php_uname;
36
use function rename;
37
use function sleep;
38
use function sprintf;
39
use function str_contains;
40
use function stream_context_create;
41
use function strpos;
42
use function sys_get_temp_dir;
43
use function uniqid;
44
use function unlink;
45

46
use const DATE_ATOM;
47
use const JSON_PRETTY_PRINT;
48
use const JSON_THROW_ON_ERROR;
49
use const JSON_UNESCAPED_SLASHES;
50
use const PHP_OS_FAMILY;
51

52
/**
53
 * Generic binary/asset manager for downloading and managing tool binaries and assets from GitHub releases.
54
 * Supports tailwindcss, esbuild, daisyui, and other tools.
55
 */
56
final class BinaryAssetManager
57
{
58
    private const string VERSION_FILE = 'version.json';
59

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

80
    /**
81
     * @throws JsonException
82
     */
83
    public function ensureLatest(
84
        ?callable $logger = null,
85
    ): string {
86
        $targetDir = $this->toolConfig['target_dir'];
6✔
87
        $this->ensureDirectory($targetDir);
6✔
88

89
        $downloadStrategy = $this->toolConfig['download_strategy'] ?? 'release';
6✔
90
        $latest = match (true) {
1✔
91
            'npm' === $this->toolConfig['source'] => $this->fetchLatestNpmVersion(),
6✔
92
            'tag' === $downloadStrategy => $this->fetchLatestTag(),
5✔
93
            'commit' === $downloadStrategy => $this->fetchLatestCommit(),
4✔
94
            default => $this->fetchLatestRelease(),
3✔
95
        };
1✔
96

97
        $currentTag = $this->readCurrentTag($targetDir);
1✔
98
        $assetsPresent = $this->assetsPresent($targetDir);
1✔
99

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

103
            return $currentTag;
×
104
        }
105

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

108
        if ('npm' === $this->toolConfig['source']) {
1✔
109
            $this->downloadNpmAsset($latest['version'], $targetDir);
1✔
110
        } elseif ('github-zip' === $this->toolConfig['source'] || in_array($downloadStrategy, ['tag', 'commit'], true)) {
×
NEW
111
            $this->downloadGithubZipAsset($latest['tag'], $targetDir);
×
112
        } else {
113
            foreach ($this->toolConfig['assets'] as $assetConfig) {
×
114
                $this->downloadAsset($latest['tag'], $assetConfig, $targetDir);
×
115
            }
116
        }
117

118
        $this->writeVersionFile($targetDir, $latest['tag'], $latest['version']);
×
119
        $this->log($logger, sprintf('%s assets updated.', $this->toolConfig['name']));
×
120

121
        return $latest['tag'];
×
122
    }
123

124
    /**
125
     * Factory method from custom tool definition array.
126
     */
127
    public static function createFromDefinition(
128
        array $definition,
129
    ): self {
130
        if (!isset($definition['name'], $definition['source'], $definition['assets'], $definition['target_dir'])) {
×
131
            throw new RuntimeException('Tool definition must include name, source, assets, and target_dir.');
×
132
        }
133

134
        if (isset($definition['repo'])) {
×
135
            throw new RuntimeException('GitHub source requires repo parameter.');
×
136
        }
137

138
        if (isset($definition['npm_package'])) {
×
139
            throw new RuntimeException('npm source requires npm_package parameter.');
×
140
        }
141

142
        return new self($definition);
×
143
    }
144

145
    /**
146
     * Detect current platform for binary downloads.
147
     */
148
    public static function detectPlatform(): string
149
    {
UNCOV
150
        $os = PHP_OS_FAMILY;
×
UNCOV
151
        $arch = php_uname('m');
×
152

UNCOV
153
        if ('Darwin' === $os) {
×
154
            return str_contains($arch, 'arm') || str_contains($arch, 'aarch64') ? 'darwin-arm64' : 'darwin-x64';
×
155
        }
156

UNCOV
157
        if ('Linux' === $os) {
×
UNCOV
158
            return str_contains($arch, 'arm') || str_contains($arch, 'aarch64') ? 'linux-arm64' : 'linux-x64';
×
159
        }
160

161
        if ('Windows' === $os) {
×
162
            return 'windows-x64';
×
163
        }
164

165
        return 'linux-x64'; // Default fallback
×
166
    }
167

168
    private function assetsPresent(
169
        string $targetDir,
170
    ): bool {
171
        foreach ($this->toolConfig['assets'] as $assetConfig) {
2✔
172
            if (!is_file($targetDir . '/' . $assetConfig['target'])) {
1✔
173
                return false;
1✔
174
            }
175
        }
176

177
        return true;
2✔
178
    }
179

180
    /**
181
     * @param array{pattern:string,target:string,executable:bool} $assetConfig
182
     */
183
    private function downloadAsset(
184
        string $tag,
185
        array $assetConfig,
186
        string $targetDir,
187
    ): void {
188
        $url = sprintf(
×
189
            'https://github.com/%s/releases/download/%s/%s',
×
190
            $this->toolConfig['repo'],
×
191
            $tag,
×
192
            $assetConfig['pattern'],
×
193
        );
×
194

195
        $context = stream_context_create([
×
196
            'http' => [
×
197
                'method' => 'GET',
×
198
                'header' => [
×
199
                    'User-Agent: valksor-binary-manager',
×
200
                ],
×
201
                'follow_location' => 1,
×
202
                'timeout' => 30,
×
203
            ],
×
204
        ]);
×
205

206
        $content = @file_get_contents($url, false, $context);
×
207

208
        if (false === $content) {
×
209
            throw new RuntimeException(sprintf('Failed to download %s asset: %s', $this->toolConfig['name'], $assetConfig['pattern']));
×
210
        }
211

212
        $targetPath = $targetDir . '/' . $assetConfig['target'];
×
213

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

218
        if ($assetConfig['executable']) {
×
219
            @chmod($targetPath, 0o755);
×
220
        }
221
    }
222

223
    private function downloadGithubZipAsset(
224
        string $tag,
225
        string $targetDir,
226
    ): void {
227
        $downloadStrategy = $this->toolConfig['download_strategy'] ?? 'release';
×
228
        $assetConfig = $this->toolConfig['assets'][0];
×
229

230
        $url = match ($downloadStrategy) {
×
NEW
231
            'tag', 'commit' => sprintf(
×
232
                'https://api.github.com/repos/%s/tarball/%s',
×
233
                $this->toolConfig['repo'],
×
234
                $tag,
×
235
            ),
×
236
            default => sprintf(
×
237
                'https://github.com/%s/releases/download/%s/%s',
×
238
                $this->toolConfig['repo'],
×
239
                $tag,
×
240
                sprintf($assetConfig['pattern'], ltrim($tag, 'v')),
×
241
            ),
×
242
        };
×
243

244
        $headers = ['User-Agent: valksor-binary-manager'];
×
245

246
        if (in_array($downloadStrategy, ['tag', 'commit'], true)) {
×
247
            $headers[] = 'Accept: application/vnd.github+json';
×
248
        }
249

250
        $context = stream_context_create([
×
251
            'http' => [
×
252
                'method' => 'GET',
×
253
                'header' => $headers,
×
254
                'follow_location' => 1,
×
255
                'timeout' => 30,
×
256
            ],
×
257
        ]);
×
258

259
        $zipContent = @file_get_contents($url, false, $context);
×
260

261
        if (false === $zipContent) {
×
262
            throw new RuntimeException(sprintf('Failed to download %s zip: %s', $this->toolConfig['name'], $url));
×
263
        }
264

265
        $tmpZip = sys_get_temp_dir() . '/valksor-' . uniqid(more_entropy: true) . '.zip';
×
266

267
        if (false === file_put_contents($tmpZip, $zipContent)) {
×
268
            throw new RuntimeException(sprintf('Failed to write temporary zip for %s.', $this->toolConfig['name']));
×
269
        }
270

271
        try {
272
            $zip = new ZipArchive();
×
273

274
            if (true !== $zip->open($tmpZip)) {
×
275
                throw new RuntimeException(sprintf('Unable to open %s zip archive.', $this->toolConfig['name']));
×
276
            }
277

278
            $zip->extractTo($targetDir);
×
279
            $zip->close();
×
280
        } finally {
281
            @unlink($tmpZip);
×
282
        }
283
    }
284

285
    private function downloadNpmAsset(
286
        string $version,
287
        string $targetDir,
288
    ): void {
289
        $assetConfig = $this->toolConfig['assets'][0];
1✔
290

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

294
        // For scoped packages (@scope/package), use the correct URL format
295
        if (str_starts_with($npmPackage, '@')) {
1✔
296
            $packageName = substr($npmPackage, strpos($npmPackage, '/') + 1);
×
297
            $url = sprintf('https://registry.npmjs.org/%s/-/%s-%s.tgz', $npmPackage, $packageName, $version);
×
298
        } else {
299
            // For regular packages, download the full package
300
            $url = sprintf('https://registry.npmjs.org/%s/-/%s-%s.tgz', $npmPackage, $npmPackage, $version);
1✔
301
        }
302

303
        $context = stream_context_create([
1✔
304
            'http' => [
1✔
305
                'method' => 'GET',
1✔
306
                'header' => 'User-Agent: valksor-binary-manager',
1✔
307
                'follow_location' => 1,
1✔
308
                'timeout' => 30,
1✔
309
            ],
1✔
310
        ]);
1✔
311

312
        $tgzContent = @file_get_contents($url, false, $context);
1✔
313

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

318
        $tmpDir = sys_get_temp_dir() . '/valksor-binary-' . uniqid(more_entropy: true);
1✔
319
        $this->ensureDirectory($tmpDir);
1✔
320

321
        try {
322
            $tgzPath = $tmpDir . '/package.tgz';
1✔
323

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

328
            $extractPath = $assetConfig['extract_path'] ?? 'package/bin/esbuild';
1✔
329
            $extractDir = $tmpDir . '/extracted';
1✔
330
            $this->ensureDirectory($extractDir);
1✔
331

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

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

339
            // Handle both file and directory extraction
340
            $sourcePath = $extractDir . '/' . $extractPath;
1✔
341
            $targetPath = $targetDir . '/' . $assetConfig['target'];
1✔
342

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

348
                if (0 !== $returnCode) {
×
349
                    throw new RuntimeException(sprintf('Failed to copy %s package: %s', $this->toolConfig['name'], implode("\n", $output)));
×
350
                }
351
            } else {
352
                // Original behavior: move specific file
353
                if (!is_file($sourcePath)) {
1✔
354
                    throw new RuntimeException(sprintf('Extracted binary not found at %s', $sourcePath));
1✔
355
                }
356

357
                if (!rename($sourcePath, $targetPath)) {
×
358
                    throw new RuntimeException(sprintf('Failed to move %s binary to %s', $this->toolConfig['name'], $targetPath));
×
359
                }
360
            }
361

362
            if ($assetConfig['executable']) {
×
363
                @chmod($targetPath, 0o755);
×
364
            }
365
        } finally {
366
            exec(sprintf('rm -rf %s 2>&1', escapeshellarg($tmpDir)));
1✔
367
        }
368
    }
369

370
    private function ensureDirectory(
371
        string $directory,
372
    ): void {
373
        static $_helper = null;
7✔
374

375
        if (null === $_helper) {
7✔
376
            $_helper = new class {
1✔
377
                use _MkDir;
378
            };
1✔
379
        }
380

381
        $_helper->mkdir($directory);
7✔
382
    }
383

384
    /**
385
     * Extract HTTP status code from response headers.
386
     */
387
    private function extractHttpStatusCode(
388
        array $headers,
389
    ): int {
390
        foreach ($headers as $header) {
4✔
391
            if (str_starts_with($header, 'HTTP/')) {
4✔
392
                $parts = explode(' ', $header);
4✔
393

394
                return (int) ($parts[1] ?? 0);
4✔
395
            }
396
        }
397

NEW
398
        return 0;
×
399
    }
400

401
    /**
402
     * @return array{tag: string, version: string}
403
     */
404
    private function fetchLatestCommit(): array
405
    {
406
        $commitRef = $this->toolConfig['commit_ref'] ?? $this->getDefaultBranch();
2✔
407

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

410
        $context = stream_context_create([
2✔
411
            'http' => [
2✔
412
                'method' => 'GET',
2✔
413
                'header' => [
2✔
414
                    'User-Agent: valksor-binary-manager',
2✔
415
                    'Accept: application/vnd.github+json',
2✔
416
                ],
2✔
417
                'timeout' => 15,
2✔
418
            ],
2✔
419
        ]);
2✔
420

421
        $response = @file_get_contents($apiUrl, false, $context);
2✔
422

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

427
        try {
428
            $commit = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
429
        } catch (JsonException $exception) {
×
430
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s commit: %s', $this->toolConfig['name'], $exception->getMessage()));
×
431
        }
432

433
        if (!array_key_exists('sha', $commit)) {
×
434
            throw new RuntimeException(sprintf('Unexpected GitHub API commit response structure for %s.', $this->toolConfig['name']));
×
435
        }
436

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

439
        return [
×
440
            'tag' => $shortSha,
×
441
            'version' => $shortSha,
×
442
        ];
×
443
    }
444

445
    /**
446
     * @return array{tag: string, version: string}
447
     */
448
    private function fetchLatestNpmVersion(): array
449
    {
450
        $distTag = $this->toolConfig['npm_dist_tag'] ?? 'latest';
1✔
451
        $packageUrl = sprintf('https://registry.npmjs.org/%s/%s', $this->toolConfig['npm_package'], $distTag);
1✔
452

453
        $context = stream_context_create([
1✔
454
            'http' => [
1✔
455
                'method' => 'GET',
1✔
456
                'header' => 'User-Agent: valksor-binary-manager',
1✔
457
                'timeout' => 15,
1✔
458
            ],
1✔
459
        ]);
1✔
460

461
        $response = @file_get_contents($packageUrl, false, $context);
1✔
462

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

467
        try {
468
            $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
1✔
469
        } catch (JsonException $exception) {
×
470
            throw new RuntimeException(sprintf('Invalid JSON response from npm registry for %s: %s', $this->toolConfig['name'], $exception->getMessage()));
×
471
        }
472

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

477
        return [
1✔
478
            'tag' => $data['version'],
1✔
479
            'version' => $data['version'],
1✔
480
        ];
1✔
481
    }
482

483
    /**
484
     * @return array{tag: string, version: string}
485
     */
486
    private function fetchLatestRelease(): array
487
    {
488
        if ('npm' === $this->toolConfig['source']) {
4✔
489
            return $this->fetchLatestNpmVersion();
×
490
        }
491

492
        $maxRetries = 3;
4✔
493
        $baseDelay = 2; // seconds
4✔
494

495
        for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
4✔
496
            try {
497
                return $this->fetchLatestReleaseWithRetry($attempt);
4✔
498
            } catch (RuntimeException $exception) {
4✔
499
                // Don't retry on rate limit errors with explicit guidance
500
                if ($this->isRateLimitError($exception->getMessage())) {
4✔
501
                    throw $exception;
2✔
502
                }
503

504
                // Retry on other failures with exponential backoff
505
                if ($attempt < $maxRetries) {
3✔
506
                    $delay = $baseDelay ** $attempt;
3✔
507
                    sleep($delay);
3✔
508

509
                    continue;
3✔
510
                }
511

512
                throw $exception;
2✔
513
            }
514
        }
515

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

519
    /**
520
     * @return array{tag: string, version: string}
521
     */
522
    private function fetchLatestReleaseWithRetry(
523
        int $attempt,
524
    ): array {
525
        $apiUrl = sprintf('https://api.github.com/repos/%s/releases/latest', $this->toolConfig['repo']);
4✔
526

527
        // Build headers with optional GitHub token
528
        $headers = [
4✔
529
            'User-Agent: valksor-binary-manager',
4✔
530
            'Accept: application/vnd.github+json',
4✔
531
        ];
4✔
532

533
        $githubToken = $this->getGitHubToken();
4✔
534

535
        if ($githubToken) {
4✔
NEW
536
            $headers[] = 'Authorization: token ' . $githubToken;
×
537
        }
538

539
        $context = stream_context_create([
4✔
540
            'http' => [
4✔
541
                'method' => 'GET',
4✔
542
                'header' => $headers,
4✔
543
                'timeout' => 15,
4✔
544
                'ignore_errors' => true, // Allow us to read response headers even on errors
4✔
545
            ],
4✔
546
        ]);
4✔
547

548
        $response = @file_get_contents($apiUrl, false, $context);
4✔
549

550
        // Check HTTP response code
551
        if (isset($http_response_header)) {
4✔
552
            $statusCode = $this->extractHttpStatusCode($http_response_header);
4✔
553

554
            if (403 === $statusCode) {
4✔
555
                $rateLimitInfo = $this->parseRateLimitHeaders($http_response_header);
2✔
556

557
                if (0 === $rateLimitInfo['remaining']) {
2✔
558
                    $resetTime = $rateLimitInfo['reset'] ? date('Y-m-d H:i:s', $rateLimitInfo['reset']) : 'unknown';
2✔
559
                    $message = sprintf(
2✔
560
                        'GitHub API rate limit exceeded for %s. Reset time: %s. ' .
2✔
561
                        'To increase rate limit, set valksor.build.github_token parameter or GITHUB_TOKEN environment variable.',
2✔
562
                        $this->toolConfig['name'],
2✔
563
                        $resetTime,
2✔
564
                    );
2✔
565

566
                    throw new RuntimeException($message);
2✔
567
                }
568

NEW
569
                throw new RuntimeException(sprintf('Access forbidden to GitHub API for %s (HTTP 403).', $this->toolConfig['name']));
×
570
            }
571

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

NEW
576
            if ($statusCode >= 400) {
×
NEW
577
                throw new RuntimeException(sprintf('GitHub API returned HTTP %d for %s.', $statusCode, $this->toolConfig['name']));
×
578
            }
579
        }
580

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

NEW
584
            if ($attempt > 1) {
×
NEW
585
                $errorMsg .= sprintf(' (attempt %d/%d)', $attempt, 3);
×
586
            }
587

NEW
588
            throw new RuntimeException($errorMsg . '.');
×
589
        }
590

591
        try {
592
            $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
593
        } catch (JsonException $exception) {
×
594
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s: %s', $this->toolConfig['name'], $exception->getMessage()));
×
595
        }
596

597
        if (!array_key_exists('tag_name', $data)) {
×
598
            throw new RuntimeException(sprintf('Unexpected GitHub API response structure for %s.', $this->toolConfig['name']));
×
599
        }
600

601
        return [
×
602
            'tag' => $data['tag_name'],
×
603
            'version' => $data['name'] ?? $data['tag_name'],
×
604
        ];
×
605
    }
606

607
    /**
608
     * @return array{tag: string, version: string}
609
     */
610
    private function fetchLatestTag(): array
611
    {
612
        $apiUrl = sprintf('https://api.github.com/repos/%s/tags', $this->toolConfig['repo']);
2✔
613

614
        $context = stream_context_create([
2✔
615
            'http' => [
2✔
616
                'method' => 'GET',
2✔
617
                'header' => [
2✔
618
                    'User-Agent: valksor-binary-manager',
2✔
619
                    'Accept: application/vnd.github+json',
2✔
620
                ],
2✔
621
                'timeout' => 15,
2✔
622
            ],
2✔
623
        ]);
2✔
624

625
        $response = @file_get_contents($apiUrl, false, $context);
2✔
626

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

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

637
        if (!is_array($tags) || empty($tags)) {
×
638
            throw new RuntimeException(sprintf('No tags found for %s repository.', $this->toolConfig['name']));
×
639
        }
640

641
        $latestTag = $tags[0];
×
642

643
        if (!array_key_exists('name', $latestTag)) {
×
644
            throw new RuntimeException(sprintf('Unexpected GitHub API tags response structure for %s.', $this->toolConfig['name']));
×
645
        }
646

647
        $tagName = $latestTag['name'];
×
648

649
        return [
×
650
            'tag' => $tagName,
×
651
            'version' => $tagName,
×
652
        ];
×
653
    }
654

655
    /**
656
     * Get the default branch for the repository.
657
     */
658
    private function getDefaultBranch(): string
659
    {
660
        $apiUrl = sprintf('https://api.github.com/repos/%s', $this->toolConfig['repo']);
1✔
661

662
        $context = stream_context_create([
1✔
663
            'http' => [
1✔
664
                'method' => 'GET',
1✔
665
                'header' => [
1✔
666
                    'User-Agent: valksor-binary-manager',
1✔
667
                    'Accept: application/vnd.github+json',
1✔
668
                ],
1✔
669
                'timeout' => 15,
1✔
670
            ],
1✔
671
        ]);
1✔
672

673
        $response = @file_get_contents($apiUrl, false, $context);
1✔
674

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

679
        try {
680
            $repo = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
681
        } catch (JsonException $exception) {
×
682
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s repository: %s', $this->toolConfig['name'], $exception->getMessage()));
×
683
        }
684

685
        if (!array_key_exists('default_branch', $repo)) {
×
686
            throw new RuntimeException(sprintf('Unexpected GitHub API repository response structure for %s.', $this->toolConfig['name']));
×
687
        }
688

689
        return $repo['default_branch'];
×
690
    }
691

692
    /**
693
     * Get GitHub token from parameter bag or environment variable.
694
     */
695
    private function getGitHubToken(): ?string
696
    {
697
        // Try parameter bag first
698
        if ($this->parameterBag && $this->parameterBag->has('valksor.build.github_token')) {
4✔
NEW
699
            return $this->parameterBag->get('valksor.build.github_token');
×
700
        }
701

702
        // Fallback to environment variable
703
        return $_ENV['GITHUB_TOKEN'] ?? $_SERVER['GITHUB_TOKEN'] ?? null;
4✔
704
    }
705

706
    /**
707
     * Check if an error message indicates a rate limit issue.
708
     */
709
    private function isRateLimitError(
710
        string $message,
711
    ): bool {
712
        return str_contains($message, 'rate limit') || str_contains($message, 'Rate limit');
4✔
713
    }
714

715
    private function log(
716
        ?callable $logger,
717
        string $message,
718
    ): void {
719
        if (null !== $logger) {
2✔
720
            $logger($message);
1✔
721
        }
722
    }
723

724
    /**
725
     * Parse rate limit information from response headers.
726
     */
727
    private function parseRateLimitHeaders(
728
        array $headers,
729
    ): array {
730
        $rateLimitInfo = [
2✔
731
            'limit' => null,
2✔
732
            'remaining' => null,
2✔
733
            'reset' => null,
2✔
734
        ];
2✔
735

736
        foreach ($headers as $header) {
2✔
737
            if (str_starts_with($header, 'X-RateLimit-Limit:')) {
2✔
738
                $rateLimitInfo['limit'] = (int) trim(substr($header, 19));
2✔
739
            } elseif (str_starts_with($header, 'X-RateLimit-Remaining:')) {
2✔
740
                $rateLimitInfo['remaining'] = (int) trim(substr($header, 23));
2✔
741
            } elseif (str_starts_with($header, 'X-RateLimit-Reset:')) {
2✔
742
                $rateLimitInfo['reset'] = (int) trim(substr($header, 19));
2✔
743
            }
744
        }
745

746
        return $rateLimitInfo;
2✔
747
    }
748

749
    private function readCurrentTag(
750
        string $targetDir,
751
    ): ?string {
752
        $versionFile = $targetDir . '/' . self::VERSION_FILE;
2✔
753

754
        if (!is_file($versionFile)) {
2✔
755
            return null;
2✔
756
        }
757

758
        $raw = @file_get_contents($versionFile);
1✔
759

760
        if (false === $raw || '' === $raw) {
1✔
761
            return null;
×
762
        }
763

764
        try {
765
            $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
1✔
766
        } catch (JsonException) {
1✔
767
            return null;
1✔
768
        }
769

770
        return array_key_exists('tag', $data) ? (string) $data['tag'] : null;
1✔
771
    }
772

773
    /**
774
     * @throws JsonException
775
     */
776
    private function writeVersionFile(
777
        string $targetDir,
778
        string $tag,
779
        string $version,
780
    ): void {
781
        $data = [
1✔
782
            'tag' => $tag,
1✔
783
            'version' => $version,
1✔
784
            'downloaded_at' => new DateTimeImmutable()->format(DATE_ATOM),
1✔
785
        ];
1✔
786

787
        $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
1✔
788
        $versionFile = $targetDir . '/' . self::VERSION_FILE;
1✔
789

790
        if (false === file_put_contents($versionFile, $json)) {
1✔
791
            throw new RuntimeException(sprintf('Failed to write version file for %s.', $this->toolConfig['name']));
×
792
        }
793
    }
794
}
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