• 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

59.35
/Binary/GenericNpmBinaryProvider.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 FilesystemIterator;
16
use JsonException;
17
use RecursiveDirectoryIterator;
18
use RecursiveIteratorIterator;
19
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
20
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
21

22
use function array_map;
23
use function count;
24
use function explode;
25
use function file_exists;
26
use function is_dir;
27
use function is_readable;
28
use function json_decode;
29
use function mkdir;
30
use function sprintf;
31
use function str_contains;
32
use function str_replace;
33
use function trim;
34

35
/**
36
 * Generic provider for multiple npm packages from a comma-separated list.
37
 *
38
 * Usage: new GenericNpmBinaryProvider('@valksor/valksor,@valksor/ui,@valksor/icons')
39
 */
40
#[AutoconfigureTag('valksor.binary_provider')]
41
final readonly class GenericNpmBinaryProvider implements BinaryInterface
42
{
43
    /** @var array<int,array{package:string,tag:string}> */
44
    private array $packages;
45

46
    public function __construct(
47
        private ParameterBagInterface $bag,
48
        ?string $packageList = null,
49
    ) {
50
        // Read package list from constructor parameter or valksor configuration
51
        $packageList ??=
8✔
52
            $this->bag->get('valksor.build.services.binaries.options.generic_npm_packages');
8✔
53

54
        $this->packages = $packageList ?
8✔
55
            array_map(
8✔
56
                static fn (string $packageSpec): array => self::parsePackageWithTag(trim($packageSpec)),
8✔
57
                explode(',', $packageList),
8✔
58
            ) : [];
8✔
59
    }
60

61
    public function createManager(
62
        string $varDir,
63
        ?string $requestedName = null,
64
    ): ?BinaryAssetManager {
65
        // Return manager for the first package (for compatibility with BinaryInterface)
66
        if (empty($this->packages)) {
3✔
NEW
67
            return null;
×
68
        }
69

70
        // Use requested name if provided and it's in our packages, otherwise use first package
71
        $packageData = $this->packages[0] ?? null;
3✔
72
        $package = $packageData['package'];
3✔
73
        $tag = $packageData['tag'];
3✔
74

75
        if ($requestedName) {
3✔
76
            foreach ($this->packages as $pkg) {
2✔
77
                if ($pkg['package'] === $requestedName) {
2✔
78
                    $package = $pkg['package'];
2✔
79
                    $tag = $pkg['tag'];
2✔
80

81
                    break;
2✔
82
                }
83
            }
84
        }
85

86
        return $this->createForPackage($package, $varDir . '/' . $this->getPackageDir($package), $requestedName, $tag);
3✔
87
    }
88

89
    /**
90
     * Download all configured packages.
91
     *
92
     * @param callable|null $logger Optional logger callback
93
     *
94
     * @return array<int,string> Package versions downloaded
95
     *
96
     * @throws JsonException
97
     */
98
    public function ensureAll(
99
        ?callable $logger = null,
100
    ): array {
101
        // If no packages configured, return empty array
102
        if (empty($this->packages)) {
1✔
NEW
103
            return [];
×
104
        }
105

106
        $versions = [];
1✔
107

108
        foreach ($this->packages as $pkg) {
1✔
109
            $package = $pkg['package'];
1✔
110
            $tag = $pkg['tag'];
1✔
111
            $targetDir = $this->getTargetDirectory($package);
1✔
112

113
            // Check if package is already up-to-date before creating manager
114
            if ($this->isPackageUpToDate($targetDir)) {
1✔
115
                // Read existing version to return
116
                $versionFile = $targetDir . '/version.json';
1✔
117
                $versionData = json_decode(file_get_contents($versionFile), true, 512, JSON_THROW_ON_ERROR);
1✔
118
                $versions[] = $versionData['version'] ?? 'unknown';
1✔
119

120
                if ($logger) {
1✔
NEW
121
                    $logger(sprintf('%s assets already current (%s)', $package, $versionData['version'] ?? 'unknown'));
×
122
                }
123

124
                continue;
1✔
125
            }
126

127
            // Package needs update - create manager and download
NEW
128
            $version = $this->createForPackage($package, $targetDir, null, $tag)->ensureLatest($logger);
×
NEW
129
            $versions[] = $version;
×
130
        }
131

132
        return $versions;
1✔
133
    }
134

135
    public function getName(): string
136
    {
137
        return 'generic_npm';
2✔
138
    }
139

140
    /**
141
     * Get the number of configured packages.
142
     */
143
    public function getPackageCount(): int
144
    {
145
        return count($this->packages);
2✔
146
    }
147

148
    /**
149
     * Get all configured package names.
150
     *
151
     * @return array<int,string>
152
     */
153
    public function getPackages(): array
154
    {
155
        return array_map(
3✔
156
            static fn (array $pkg): string => $pkg['package'],
3✔
157
            $this->packages,
3✔
158
        );
3✔
159
    }
160

161
    /**
162
     * Check if this provider handles a specific package name.
163
     */
164
    public function hasPackage(
165
        string $packageName,
166
    ): bool {
167
        return array_any($this->packages, fn ($pkg) => $pkg['package'] === $packageName);
2✔
168
    }
169

170
    /**
171
     * Sync packages from /var to /public/vendor.
172
     *
173
     * @param callable|null $logger Optional logger callback
174
     *
175
     * @return array<int,string> Package names that were synced
176
     */
177
    public function syncToPublicVendor(
178
        ?callable $logger = null,
179
    ): array {
NEW
180
        if (empty($this->packages)) {
×
NEW
181
            return [];
×
182
        }
183

NEW
184
        $projectRoot = $this->bag->get('kernel.project_dir');
×
NEW
185
        $varBaseDir = $projectRoot . '/var';
×
NEW
186
        $publicVendorBaseDir = $projectRoot . '/public/vendor';
×
187

NEW
188
        $syncedPackages = [];
×
189

NEW
190
        foreach ($this->packages as $pkg) {
×
NEW
191
            $package = $pkg['package'];
×
NEW
192
            $sourceDir = $varBaseDir . '/' . $this->getPackageDir($package);
×
NEW
193
            $targetDir = $publicVendorBaseDir . '/' . $this->getPackageDir($package);
×
194

NEW
195
            if (is_dir($sourceDir) && $this->recursiveCopy($sourceDir, $targetDir)) {
×
NEW
196
                $syncedPackages[] = $package;
×
197

NEW
198
                if ($logger) {
×
NEW
199
                    $logger(sprintf('Synced %s to public/vendor', $package));
×
200
                }
NEW
201
            } elseif ($logger) {
×
NEW
202
                $logger(sprintf('Warning: Source directory not found for %s: %s', $package, $sourceDir));
×
203
            }
204
        }
205

NEW
206
        return $syncedPackages;
×
207
    }
208

209
    /**
210
     * Create BinaryAssetManager for a specific npm package.
211
     */
212
    private function createForPackage(
213
        string $package,
214
        string $targetDir,
215
        ?string $requestedName = null,
216
        ?string $tag = null,
217
    ): BinaryAssetManager {
218
        // Use provided tag, or parse from requested name, or default to latest
219
        $distTag = $tag ?? 'latest';
3✔
220

221
        if ($requestedName && str_contains($requestedName, '@')) {
3✔
222
            $parts = explode('@', $requestedName);
2✔
223

224
            if (count($parts) >= 3) {
2✔
225
                // Format: @valksor/valksor@tag
NEW
226
                $distTag = end($parts);
×
227
            }
228
        }
229

230
        return new BinaryAssetManager([
3✔
231
            'name' => $package,
3✔
232
            'source' => 'npm',
3✔
233
            'npm_package' => $package,
3✔
234
            'npm_dist_tag' => $distTag,
3✔
235
            'assets' => [
3✔
236
                [
3✔
237
                    'pattern' => 'package',
3✔
238
                    'target' => '.',
3✔
239
                    'executable' => false,
3✔
240
                    'extract_path' => 'package',
3✔
241
                ],
3✔
242
            ],
3✔
243
            'target_dir' => $targetDir,
3✔
244
        ], $this->bag);
3✔
245
    }
246

247
    /**
248
     * Convert package name to directory name.
249
     *
250
     * @valksor/valksor -> valksor
251
     *
252
     * @valksor/ui -> valksor-ui
253
     */
254
    private function getPackageDir(
255
        string $package,
256
    ): string {
257
        return str_replace(['/', '@'], ['-', ''], $package);
4✔
258
    }
259

260
    /**
261
     * Get the target directory for a package.
262
     */
263
    private function getTargetDirectory(
264
        string $package,
265
    ): string {
266
        $projectRoot = $this->bag->get('kernel.project_dir');
1✔
267

268
        return $projectRoot . '/var/' . $this->getPackageDir($package);
1✔
269
    }
270

271
    /**
272
     * Check if the target directory has valid assets.
273
     */
274
    private function hasValidAssets(
275
        string $targetDir,
276
    ): bool {
277
        // Basic check - ensure directory is not empty and has some expected files
278
        $packageJson = $targetDir . '/package.json';
1✔
279
        $versionJson = $targetDir . '/version.json';
1✔
280

281
        return file_exists($packageJson) && file_exists($versionJson);
1✔
282
    }
283

284
    /**
285
     * Check if a package is already up-to-date in the target directory.
286
     *
287
     * @throws JsonException
288
     */
289
    private function isPackageUpToDate(
290
        string $targetDir,
291
    ): bool {
292
        // Check if target directory exists
293
        if (!is_dir($targetDir)) {
1✔
NEW
294
            return false;
×
295
        }
296

297
        // Check if version.json exists and is readable
298
        $versionFile = $targetDir . '/version.json';
1✔
299

300
        if (!is_file($versionFile) || !is_readable($versionFile)) {
1✔
NEW
301
            return false;
×
302
        }
303

304
        // Read and validate version.json
305
        $versionData = json_decode(file_get_contents($versionFile), true, 512, JSON_THROW_ON_ERROR);
1✔
306

307
        if (!$versionData || !isset($versionData['version'])) {
1✔
NEW
308
            return false;
×
309
        }
310

311
        // Check if basic assets are present (avoid empty directories)
312
        return $this->hasValidAssets($targetDir);
1✔
313
    }
314

315
    /**
316
     * Recursively copy directory from source to target.
317
     */
318
    private function recursiveCopy(
319
        string $source,
320
        string $target,
321
    ): bool {
NEW
322
        if (!is_dir($source)) {
×
NEW
323
            return false;
×
324
        }
325

NEW
326
        if (!is_dir($target) && !mkdir($target, 0o755, true)) {
×
NEW
327
            return false;
×
328
        }
329

NEW
330
        $iterator = new RecursiveIteratorIterator(
×
NEW
331
            new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS),
×
NEW
332
            RecursiveIteratorIterator::SELF_FIRST,
×
NEW
333
        );
×
334

NEW
335
        foreach ($iterator as $item) {
×
NEW
336
            $targetPath = $target . '/' . $iterator->getSubPathName();
×
337

NEW
338
            if ($item->isDir()) {
×
NEW
339
                if (!is_dir($targetPath) && !mkdir($targetPath, 0o755, true)) {
×
NEW
340
                    return false;
×
341
                }
NEW
342
            } elseif (!copy($item->getPathname(), $targetPath)) {
×
NEW
343
                return false;
×
344
            }
345
        }
346

NEW
347
        return true;
×
348
    }
349

350
    /**
351
     * Parse package specification in format "package" or "package@tag".
352
     *
353
     * @param string $packageSpec Package specification like "@valksor/valksor" or "@valksor/valksor@next"
354
     *
355
     * @return array{package:string, tag:string}
356
     */
357
    private static function parsePackageWithTag(
358
        string $packageSpec,
359
    ): array {
360
        // Find the last '@' symbol - everything after it is the tag
361
        $lastAtPos = strrpos($packageSpec, '@');
8✔
362

363
        if (false === $lastAtPos) {
8✔
364
            // No @ symbol found - use entire string as package name
NEW
365
            return ['package' => $packageSpec, 'tag' => 'latest'];
×
366
        }
367

368
        if (0 === $lastAtPos) {
8✔
369
            // @ at position 0 but no other @ found - this is just a scoped package without tag
370
            return ['package' => $packageSpec, 'tag' => 'latest'];
8✔
371
        }
372

373
        // Check if there's a '/' after the first @ (scoped package)
NEW
374
        $firstAtPos = strpos($packageSpec, '@');
×
NEW
375
        $slashPos = strpos($packageSpec, '/', $firstAtPos + 1);
×
376

NEW
377
        if (false === $slashPos || $lastAtPos < $slashPos) {
×
378
            // No slash in scope or @ is before slash - this is just a package without tag
NEW
379
            return ['package' => $packageSpec, 'tag' => 'latest'];
×
380
        }
381

382
        // We have a scoped package with tag: @valksor/valksor@next
NEW
383
        $package = substr($packageSpec, 0, $lastAtPos);
×
NEW
384
        $tag = substr($packageSpec, $lastAtPos + 1);
×
385

NEW
386
        return ['package' => $package, 'tag' => $tag ?: 'latest'];
×
387
    }
388
}
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