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

valksor / php-dev-build / 19384258487

15 Nov 2025 04:07AM UTC coverage: 19.747% (+2.5%) from 17.283%
19384258487

push

github

k0d3r1s
prettier

16 of 30 new or added lines in 4 files covered. (53.33%)

516 existing lines in 7 files now uncovered.

484 of 2451 relevant lines covered (19.75%)

1.03 hits per line

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

51.51
/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 Valksor\Functions\Local\Traits\_MkDir;
19
use ZipArchive;
20

21
use function array_key_exists;
22
use function chmod;
23
use function escapeshellarg;
24
use function exec;
25
use function file_get_contents;
26
use function file_put_contents;
27
use function implode;
28
use function in_array;
29
use function is_array;
30
use function is_file;
31
use function json_decode;
32
use function json_encode;
33
use function ltrim;
34
use function php_uname;
35
use function rename;
36
use function sprintf;
37
use function str_contains;
38
use function stream_context_create;
39
use function sys_get_temp_dir;
40
use function uniqid;
41
use function unlink;
42

43
use const DATE_ATOM;
44
use const JSON_PRETTY_PRINT;
45
use const JSON_THROW_ON_ERROR;
46
use const JSON_UNESCAPED_SLASHES;
47
use const PHP_OS_FAMILY;
48

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

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

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

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

93
        $currentTag = $this->readCurrentTag($targetDir);
1✔
94
        $assetsPresent = $this->assetsPresent($targetDir);
1✔
95

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

99
            return $currentTag;
×
100
        }
101

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

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

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

UNCOV
117
        return $latest['tag'];
×
118
    }
119

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

UNCOV
130
        if (isset($definition['repo'])) {
×
UNCOV
131
            throw new RuntimeException('GitHub source requires repo parameter.');
×
132
        }
133

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

UNCOV
138
        return new self($definition);
×
139
    }
140

141
    /**
142
     * Detect current platform for binary downloads.
143
     */
144
    public static function detectPlatform(): string
145
    {
146
        $os = PHP_OS_FAMILY;
1✔
147
        $arch = php_uname('m');
1✔
148

149
        if ('Darwin' === $os) {
1✔
150
            return str_contains($arch, 'arm') || str_contains($arch, 'aarch64') ? 'darwin-arm64' : 'darwin-x64';
×
151
        }
152

153
        if ('Linux' === $os) {
1✔
154
            return str_contains($arch, 'arm') || str_contains($arch, 'aarch64') ? 'linux-arm64' : 'linux-x64';
1✔
155
        }
156

UNCOV
157
        if ('Windows' === $os) {
×
UNCOV
158
            return 'windows-x64';
×
159
        }
160

UNCOV
161
        return 'linux-x64'; // Default fallback
×
162
    }
163

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

173
        return true;
2✔
174
    }
175

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

191
        $context = stream_context_create([
×
UNCOV
192
            'http' => [
×
193
                'method' => 'GET',
×
194
                'header' => [
×
UNCOV
195
                    'User-Agent: valksor-binary-manager',
×
UNCOV
196
                ],
×
197
                'follow_location' => 1,
×
UNCOV
198
                'timeout' => 30,
×
199
            ],
×
200
        ]);
×
201

UNCOV
202
        $content = @file_get_contents($url, false, $context);
×
203

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

UNCOV
208
        $targetPath = $targetDir . '/' . $assetConfig['target'];
×
209

UNCOV
210
        if (false === file_put_contents($targetPath, $content)) {
×
UNCOV
211
            throw new RuntimeException(sprintf('Failed to write %s asset to %s', $this->toolConfig['name'], $targetPath));
×
212
        }
213

UNCOV
214
        if ($assetConfig['executable']) {
×
UNCOV
215
            @chmod($targetPath, 0o755);
×
216
        }
217
    }
218

219
    private function downloadGithubZipAsset(
220
        string $tag,
221
        string $version,
222
        string $targetDir,
223
    ): void {
UNCOV
224
        $downloadStrategy = $this->toolConfig['download_strategy'] ?? 'release';
×
225
        $assetConfig = $this->toolConfig['assets'][0];
×
226

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

UNCOV
246
        $headers = ['User-Agent: valksor-binary-manager'];
×
247

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

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

UNCOV
261
        $zipContent = @file_get_contents($url, false, $context);
×
262

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

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

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

273
        try {
UNCOV
274
            $zip = new ZipArchive();
×
275

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

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

287
    private function downloadNpmAsset(
288
        string $version,
289
        string $targetDir,
290
    ): void {
291
        $assetConfig = $this->toolConfig['assets'][0];
1✔
292
        $platform = self::detectPlatform();
1✔
293

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

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

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

316
        $tgzContent = @file_get_contents($url, false, $context);
1✔
317

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

322
        $tmpDir = sys_get_temp_dir() . '/valksor-binary-' . uniqid(more_entropy: true);
1✔
323
        $this->ensureDirectory($tmpDir);
1✔
324

325
        try {
326
            $tgzPath = $tmpDir . '/package.tgz';
1✔
327

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

332
            $extractPath = $assetConfig['extract_path'] ?? 'package/bin/esbuild';
1✔
333
            $extractDir = $tmpDir . '/extracted';
1✔
334
            $this->ensureDirectory($extractDir);
1✔
335

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

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

343
            // Handle both file and directory extraction
344
            $sourcePath = $extractDir . '/' . $extractPath;
1✔
345
            $targetPath = $targetDir . '/' . $assetConfig['target'];
1✔
346

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

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

NEW
361
                if (!rename($sourcePath, $targetPath)) {
×
NEW
362
                    throw new RuntimeException(sprintf('Failed to move %s binary to %s', $this->toolConfig['name'], $targetPath));
×
363
                }
364
            }
365

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

374
    private function ensureDirectory(
375
        string $directory,
376
    ): void {
377
        static $_helper = null;
7✔
378

379
        if (null === $_helper) {
7✔
380
            $_helper = new class {
1✔
381
                use _MkDir;
382
            };
1✔
383
        }
384

385
        $_helper->mkdir($directory);
7✔
386
    }
387

388
    /**
389
     * @return array{tag: string, version: string}
390
     */
391
    private function fetchLatestCommit(): array
392
    {
393
        $commitRef = $this->toolConfig['commit_ref'] ?? $this->getDefaultBranch();
2✔
394

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

397
        $context = stream_context_create([
2✔
398
            'http' => [
2✔
399
                'method' => 'GET',
2✔
400
                'header' => [
2✔
401
                    'User-Agent: valksor-binary-manager',
2✔
402
                    'Accept: application/vnd.github+json',
2✔
403
                ],
2✔
404
                'timeout' => 15,
2✔
405
            ],
2✔
406
        ]);
2✔
407

408
        $response = @file_get_contents($apiUrl, false, $context);
2✔
409

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

414
        try {
UNCOV
415
            $commit = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
UNCOV
416
        } catch (JsonException $exception) {
×
UNCOV
417
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s commit: %s', $this->toolConfig['name'], $exception->getMessage()));
×
418
        }
419

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

UNCOV
424
        $shortSha = substr($commit['sha'], 0, 7);
×
425

UNCOV
426
        return [
×
UNCOV
427
            'tag' => $shortSha,
×
UNCOV
428
            'version' => $shortSha,
×
429
        ];
×
430
    }
431

432
    /**
433
     * @return array{tag: string, version: string}
434
     */
435
    private function fetchLatestNpmVersion(): array
436
    {
437
        $distTag = $this->toolConfig['npm_dist_tag'] ?? 'latest';
1✔
438
        $packageUrl = sprintf('https://registry.npmjs.org/%s/%s', $this->toolConfig['npm_package'], $distTag);
1✔
439

440
        $context = stream_context_create([
1✔
441
            'http' => [
1✔
442
                'method' => 'GET',
1✔
443
                'header' => 'User-Agent: valksor-binary-manager',
1✔
444
                'timeout' => 15,
1✔
445
            ],
1✔
446
        ]);
1✔
447

448
        $response = @file_get_contents($packageUrl, false, $context);
1✔
449

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

454
        try {
455
            $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
1✔
UNCOV
456
        } catch (JsonException $exception) {
×
UNCOV
457
            throw new RuntimeException(sprintf('Invalid JSON response from npm registry for %s: %s', $this->toolConfig['name'], $exception->getMessage()));
×
458
        }
459

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

464
        return [
1✔
465
            'tag' => $data['version'],
1✔
466
            'version' => $data['version'],
1✔
467
        ];
1✔
468
    }
469

470
    /**
471
     * @return array{tag: string, version: string}
472
     */
473
    private function fetchLatestRelease(): array
474
    {
475
        if ('npm' === $this->toolConfig['source']) {
4✔
UNCOV
476
            return $this->fetchLatestNpmVersion();
×
477
        }
478

479
        $apiUrl = sprintf('https://api.github.com/repos/%s/releases/latest', $this->toolConfig['repo']);
4✔
480

481
        $context = stream_context_create([
4✔
482
            'http' => [
4✔
483
                'method' => 'GET',
4✔
484
                'header' => [
4✔
485
                    'User-Agent: valksor-binary-manager',
4✔
486
                    'Accept: application/vnd.github+json',
4✔
487
                ],
4✔
488
                'timeout' => 15,
4✔
489
            ],
4✔
490
        ]);
4✔
491

492
        $response = @file_get_contents($apiUrl, false, $context);
4✔
493

494
        if (false === $response) {
4✔
495
            throw new RuntimeException(sprintf('Failed to fetch latest release for %s from GitHub API.', $this->toolConfig['name']));
4✔
496
        }
497

498
        try {
UNCOV
499
            $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
UNCOV
500
        } catch (JsonException $exception) {
×
UNCOV
501
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s: %s', $this->toolConfig['name'], $exception->getMessage()));
×
502
        }
503

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

UNCOV
508
        return [
×
UNCOV
509
            'tag' => $data['tag_name'],
×
UNCOV
510
            'version' => $data['name'] ?? $data['tag_name'],
×
UNCOV
511
        ];
×
512
    }
513

514
    /**
515
     * @return array{tag: string, version: string}
516
     */
517
    private function fetchLatestTag(): array
518
    {
519
        $apiUrl = sprintf('https://api.github.com/repos/%s/tags', $this->toolConfig['repo']);
2✔
520

521
        $context = stream_context_create([
2✔
522
            'http' => [
2✔
523
                'method' => 'GET',
2✔
524
                'header' => [
2✔
525
                    'User-Agent: valksor-binary-manager',
2✔
526
                    'Accept: application/vnd.github+json',
2✔
527
                ],
2✔
528
                'timeout' => 15,
2✔
529
            ],
2✔
530
        ]);
2✔
531

532
        $response = @file_get_contents($apiUrl, false, $context);
2✔
533

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

538
        try {
UNCOV
539
            $tags = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
UNCOV
540
        } catch (JsonException $exception) {
×
UNCOV
541
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s tags: %s', $this->toolConfig['name'], $exception->getMessage()));
×
542
        }
543

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

UNCOV
548
        $latestTag = $tags[0];
×
549

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

UNCOV
554
        $tagName = $latestTag['name'];
×
555

UNCOV
556
        return [
×
UNCOV
557
            'tag' => $tagName,
×
UNCOV
558
            'version' => $tagName,
×
UNCOV
559
        ];
×
560
    }
561

562
    /**
563
     * Get the default branch for the repository.
564
     */
565
    private function getDefaultBranch(): string
566
    {
567
        $apiUrl = sprintf('https://api.github.com/repos/%s', $this->toolConfig['repo']);
1✔
568

569
        $context = stream_context_create([
1✔
570
            'http' => [
1✔
571
                'method' => 'GET',
1✔
572
                'header' => [
1✔
573
                    'User-Agent: valksor-binary-manager',
1✔
574
                    'Accept: application/vnd.github+json',
1✔
575
                ],
1✔
576
                'timeout' => 15,
1✔
577
            ],
1✔
578
        ]);
1✔
579

580
        $response = @file_get_contents($apiUrl, false, $context);
1✔
581

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

586
        try {
UNCOV
587
            $repo = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
×
UNCOV
588
        } catch (JsonException $exception) {
×
UNCOV
589
            throw new RuntimeException(sprintf('Invalid JSON response from GitHub API for %s repository: %s', $this->toolConfig['name'], $exception->getMessage()));
×
590
        }
591

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

UNCOV
596
        return $repo['default_branch'];
×
597
    }
598

599
    private function log(
600
        ?callable $logger,
601
        string $message,
602
    ): void {
603
        if (null !== $logger) {
2✔
604
            $logger($message);
1✔
605
        }
606
    }
607

608
    private function readCurrentTag(
609
        string $targetDir,
610
    ): ?string {
611
        $versionFile = $targetDir . '/' . self::VERSION_FILE;
2✔
612

613
        if (!is_file($versionFile)) {
2✔
614
            return null;
2✔
615
        }
616

617
        $raw = @file_get_contents($versionFile);
1✔
618

619
        if (false === $raw || '' === $raw) {
1✔
UNCOV
620
            return null;
×
621
        }
622

623
        try {
624
            $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
1✔
625
        } catch (JsonException) {
1✔
626
            return null;
1✔
627
        }
628

629
        return array_key_exists('tag', $data) ? (string) $data['tag'] : null;
1✔
630
    }
631

632
    /**
633
     * @throws JsonException
634
     */
635
    private function writeVersionFile(
636
        string $targetDir,
637
        string $tag,
638
        string $version,
639
    ): void {
640
        $data = [
1✔
641
            'tag' => $tag,
1✔
642
            'version' => $version,
1✔
643
            'downloaded_at' => new DateTimeImmutable()->format(DATE_ATOM),
1✔
644
        ];
1✔
645

646
        $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
1✔
647
        $versionFile = $targetDir . '/' . self::VERSION_FILE;
1✔
648

649
        if (false === file_put_contents($versionFile, $json)) {
1✔
UNCOV
650
            throw new RuntimeException(sprintf('Failed to write version file for %s.', $this->toolConfig['name']));
×
651
        }
652
    }
653
}
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