• 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

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

23
use function array_any;
24
use function array_key_exists;
25
use function array_map;
26
use function copy;
27
use function count;
28
use function end;
29
use function explode;
30
use function file_exists;
31
use function file_get_contents;
32
use function is_dir;
33
use function is_file;
34
use function is_readable;
35
use function json_decode;
36
use function mkdir;
37
use function sprintf;
38
use function str_contains;
39
use function str_replace;
40
use function stream_context_create;
41
use function strpos;
42
use function strrpos;
43
use function substr;
44
use function trim;
45

46
use const JSON_THROW_ON_ERROR;
47

48
/**
49
 * Generic provider for multiple npm packages from a comma-separated list.
50
 *
51
 * Usage: new GenericNpmBinaryProvider('@valksor/valksor,@valksor/ui,@valksor/icons')
52
 */
53
#[AutoconfigureTag('valksor.binary_provider')]
54
readonly class GenericNpmBinaryProvider implements BinaryInterface
55
{
56
    /** @var array<int,array{package:string,tag:string}> */
57
    private array $packages;
58

59
    public function __construct(
60
        private ParameterBagInterface $bag,
61
        ?string $packageList = null,
62
    ) {
63
        // Read package list from constructor parameter or valksor configuration
64
        $packageList ??=
7✔
65
            $this->bag->get('valksor.build.services.binaries.options.generic_npm_packages');
7✔
66

67
        $this->packages = $packageList ?
7✔
68
            array_map(
7✔
69
                static fn (string $packageSpec): array => self::parsePackageWithTag(trim($packageSpec)),
7✔
70
                explode(',', $packageList),
7✔
71
            ) : [];
7✔
72
    }
73

74
    public function createManager(
75
        string $varDir,
76
        ?string $requestedName = null,
77
    ): ?BinaryAssetManager {
78
        // Return manager for the first package (for compatibility with BinaryInterface)
79
        if (empty($this->packages)) {
3✔
UNCOV
80
            return null;
×
81
        }
82

83
        // Use requested name if provided and it's in our packages, otherwise use first package
84
        $packageData = $this->packages[0] ?? null;
3✔
85
        $package = $packageData['package'];
3✔
86
        $tag = $packageData['tag'];
3✔
87

88
        if ($requestedName) {
3✔
89
            foreach ($this->packages as $pkg) {
2✔
90
                if ($pkg['package'] === $requestedName) {
2✔
91
                    $package = $pkg['package'];
2✔
92
                    $tag = $pkg['tag'];
2✔
93

94
                    break;
2✔
95
                }
96
            }
97
        }
98

99
        return $this->createForPackage($package, $varDir . '/' . $this->getPackageDir($package), $requestedName, $tag);
3✔
100
    }
101

102
    /**
103
     * Download all configured packages.
104
     *
105
     * @param callable|null $logger Optional logger callback
106
     *
107
     * @return array<int,string> Package versions downloaded
108
     *
109
     * @throws JsonException
110
     */
111
    public function ensureAll(
112
        ?callable $logger = null,
113
    ): array {
114
        // If no packages configured, return empty array
115
        if (empty($this->packages)) {
1✔
UNCOV
116
            return [];
×
117
        }
118

119
        $versions = [];
1✔
120

121
        foreach ($this->packages as $pkg) {
1✔
122
            $package = $pkg['package'];
1✔
123
            $tag = $pkg['tag'];
1✔
124
            $targetDir = $this->getTargetDirectory($package);
1✔
125

126
            // Check if package is already up-to-date before creating manager
127
            if ($this->isPackageUpToDate($targetDir, $package, $tag)) {
1✔
128
                // Read existing version to return
129
                $versionFile = $targetDir . '/version.json';
1✔
130
                $versionData = json_decode(file_get_contents($versionFile), true, 512, JSON_THROW_ON_ERROR);
1✔
131
                $versions[] = $versionData['version'] ?? 'unknown';
1✔
132

133
                if ($logger) {
1✔
UNCOV
134
                    $logger(sprintf('%s assets already current (%s)', $package, $versionData['version'] ?? 'unknown'));
×
135
                }
136

137
                continue;
1✔
138
            }
139

140
            // Package needs update - create manager and download
UNCOV
141
            $version = $this->createForPackage($package, $targetDir, null, $tag)->ensureLatest($logger);
×
UNCOV
142
            $versions[] = $version;
×
143
        }
144

145
        return $versions;
1✔
146
    }
147

148
    public function getName(): string
149
    {
150
        return 'generic_npm';
2✔
151
    }
152

153
    /**
154
     * Get the number of configured packages.
155
     */
156
    public function getPackageCount(): int
157
    {
158
        return count($this->packages);
2✔
159
    }
160

161
    /**
162
     * Get all configured package names.
163
     *
164
     * @return array<int,string>
165
     */
166
    public function getPackages(): array
167
    {
168
        return array_map(
3✔
169
            static fn (array $pkg): string => $pkg['package'],
3✔
170
            $this->packages,
3✔
171
        );
3✔
172
    }
173

174
    /**
175
     * Check if this provider handles a specific package name.
176
     */
177
    public function hasPackage(
178
        string $packageName,
179
    ): bool {
180
        return array_any($this->packages, static fn ($pkg) => $pkg['package'] === $packageName);
2✔
181
    }
182

183
    /**
184
     * Sync packages from /var to /public/vendor.
185
     *
186
     * @param callable|null $logger Optional logger callback
187
     *
188
     * @return array<int,string> Package names that were synced
189
     */
190
    public function syncToPublicVendor(
191
        ?callable $logger = null,
192
    ): array {
193
        if (empty($this->packages)) {
×
UNCOV
194
            return [];
×
195
        }
196

UNCOV
197
        $projectRoot = $this->bag->get('kernel.project_dir');
×
198
        $varBaseDir = $projectRoot . '/var';
×
199
        $publicVendorBaseDir = $projectRoot . '/public/vendor';
×
200

201
        $syncedPackages = [];
×
202

UNCOV
203
        foreach ($this->packages as $pkg) {
×
UNCOV
204
            $package = $pkg['package'];
×
UNCOV
205
            $sourceDir = $varBaseDir . '/' . $this->getPackageDir($package);
×
206
            $targetDir = $publicVendorBaseDir . '/' . $this->getPackageDir($package);
×
207

UNCOV
208
            if (is_dir($sourceDir) && $this->recursiveCopy($sourceDir, $targetDir)) {
×
UNCOV
209
                $syncedPackages[] = $package;
×
210

UNCOV
211
                if ($logger) {
×
UNCOV
212
                    $logger(sprintf('Synced %s to public/vendor', $package));
×
213
                }
UNCOV
214
            } elseif ($logger) {
×
UNCOV
215
                $logger(sprintf('Warning: Source directory not found for %s: %s', $package, $sourceDir));
×
216
            }
217
        }
218

UNCOV
219
        return $syncedPackages;
×
220
    }
221

222
    /**
223
     * Check if a package is already up-to-date in the target directory.
224
     *
225
     * @throws JsonException
226
     */
227
    protected function isPackageUpToDate(
228
        string $targetDir,
229
        string $package,
230
        string $tag = 'latest',
231
    ): bool {
232
        // Check if target directory exists
233
        if (!is_dir($targetDir)) {
1✔
UNCOV
234
            return false;
×
235
        }
236

237
        // Check if version.json exists and is readable
238
        $versionFile = $targetDir . '/version.json';
1✔
239

240
        if (!is_file($versionFile) || !is_readable($versionFile)) {
1✔
UNCOV
241
            return false;
×
242
        }
243

244
        // Read and validate version.json
245
        $versionData = json_decode(file_get_contents($versionFile), true, 512, JSON_THROW_ON_ERROR);
1✔
246

247
        if (!$versionData || !isset($versionData['version'])) {
1✔
UNCOV
248
            return false;
×
249
        }
250

251
        // Check if basic assets are present (avoid empty directories)
252
        if (!$this->hasValidAssets($targetDir)) {
1✔
UNCOV
253
            return false;
×
254
        }
255

256
        // Compare with latest version from NPM registry
257
        try {
258
            $packageUrl = sprintf('https://registry.npmjs.org/%s/%s', $package, $tag);
1✔
259

260
            $context = stream_context_create([
1✔
261
                'http' => [
1✔
262
                    'method' => 'GET',
1✔
263
                    'header' => 'User-Agent: valksor-binary-manager',
1✔
264
                    'timeout' => 15,
1✔
265
                ],
1✔
266
            ]);
1✔
267

268
            $response = @file_get_contents($packageUrl, false, $context);
1✔
269

270
            if (false === $response) {
1✔
UNCOV
271
                return false; // Assume need update if we can't check
×
272
            }
273

274
            $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
1✔
275

276
            if (!array_key_exists('version', $data)) {
1✔
UNCOV
277
                return false; // Assume need update if response is invalid
×
278
            }
279

280
            // Compare local version with NPM registry version
281
            return $versionData['version'] === $data['version'];
1✔
UNCOV
282
        } catch (Exception) {
×
283
            // If we can't check the registry, assume we need to update
UNCOV
284
            return false;
×
285
        }
286
    }
287

288
    /**
289
     * Create BinaryAssetManager for a specific npm package.
290
     */
291
    private function createForPackage(
292
        string $package,
293
        string $targetDir,
294
        ?string $requestedName = null,
295
        ?string $tag = null,
296
    ): BinaryAssetManager {
297
        // Use provided tag, or parse from requested name, or default to latest
298
        $distTag = $tag ?? 'latest';
3✔
299

300
        if ($requestedName && str_contains($requestedName, '@')) {
3✔
301
            $parts = explode('@', $requestedName);
2✔
302

303
            if (count($parts) >= 3) {
2✔
304
                // Format: @valksor/valksor@tag
UNCOV
305
                $distTag = end($parts);
×
306
            }
307
        }
308

309
        return new BinaryAssetManager([
3✔
310
            'name' => $package,
3✔
311
            'source' => 'npm',
3✔
312
            'npm_package' => $package,
3✔
313
            'npm_dist_tag' => $distTag,
3✔
314
            'assets' => [
3✔
315
                [
3✔
316
                    'pattern' => 'package',
3✔
317
                    'target' => '.',
3✔
318
                    'executable' => false,
3✔
319
                    'extract_path' => 'package',
3✔
320
                ],
3✔
321
            ],
3✔
322
            'target_dir' => $targetDir,
3✔
323
        ], $this->bag);
3✔
324
    }
325

326
    /**
327
     * Convert package name to directory name.
328
     *
329
     * @valksor/valksor -> valksor
330
     *
331
     * @valksor/ui -> valksor-ui
332
     */
333
    private function getPackageDir(
334
        string $package,
335
    ): string {
336
        return str_replace(['/', '@'], ['-', ''], $package);
4✔
337
    }
338

339
    /**
340
     * Get the target directory for a package.
341
     */
342
    private function getTargetDirectory(
343
        string $package,
344
    ): string {
345
        $projectRoot = $this->bag->get('kernel.project_dir');
1✔
346

347
        return $projectRoot . '/var/' . $this->getPackageDir($package);
1✔
348
    }
349

350
    /**
351
     * Check if the target directory has valid assets.
352
     */
353
    private function hasValidAssets(
354
        string $targetDir,
355
    ): bool {
356
        // Basic check - ensure directory is not empty and has some expected files
357
        $packageJson = $targetDir . '/package.json';
1✔
358
        $versionJson = $targetDir . '/version.json';
1✔
359

360
        return file_exists($packageJson) && file_exists($versionJson);
1✔
361
    }
362

363
    /**
364
     * Recursively copy directory from source to target.
365
     */
366
    private function recursiveCopy(
367
        string $source,
368
        string $target,
369
    ): bool {
UNCOV
370
        if (!is_dir($source)) {
×
UNCOV
371
            return false;
×
372
        }
373

374
        if (!is_dir($target) && !mkdir($target, 0o755, true)) {
×
375
            return false;
×
376
        }
377

UNCOV
378
        $iterator = new RecursiveIteratorIterator(
×
379
            new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS),
×
UNCOV
380
            RecursiveIteratorIterator::SELF_FIRST,
×
UNCOV
381
        );
×
382

383
        foreach ($iterator as $item) {
×
384
            $targetPath = $target . '/' . $iterator->getSubPathName();
×
385

386
            if ($item->isDir()) {
×
UNCOV
387
                if (!is_dir($targetPath) && !mkdir($targetPath, 0o755, true)) {
×
UNCOV
388
                    return false;
×
389
                }
UNCOV
390
            } elseif (!copy($item->getPathname(), $targetPath)) {
×
UNCOV
391
                return false;
×
392
            }
393
        }
394

UNCOV
395
        return true;
×
396
    }
397

398
    /**
399
     * Parse package specification in format "package" or "package@tag".
400
     *
401
     * @param string $packageSpec Package specification like "@valksor/valksor" or "@valksor/valksor@next"
402
     *
403
     * @return array{package:string, tag:string}
404
     */
405
    private static function parsePackageWithTag(
406
        string $packageSpec,
407
    ): array {
408
        // Find the last '@' symbol - everything after it is the tag
409
        $lastAtPos = strrpos($packageSpec, '@');
7✔
410

411
        if (false === $lastAtPos) {
7✔
412
            // No @ symbol found - use entire string as package name
UNCOV
413
            return ['package' => $packageSpec, 'tag' => 'latest'];
×
414
        }
415

416
        if (0 === $lastAtPos) {
7✔
417
            // @ at position 0 but no other @ found - this is just a scoped package without tag
418
            return ['package' => $packageSpec, 'tag' => 'latest'];
7✔
419
        }
420

421
        // Check if there's a '/' after the first @ (scoped package)
UNCOV
422
        $firstAtPos = strpos($packageSpec, '@');
×
UNCOV
423
        $slashPos = strpos($packageSpec, '/', $firstAtPos + 1);
×
424

UNCOV
425
        if (false === $slashPos || $lastAtPos < $slashPos) {
×
426
            // No slash in scope or @ is before slash - this is just a package without tag
UNCOV
427
            return ['package' => $packageSpec, 'tag' => 'latest'];
×
428
        }
429

430
        // We have a scoped package with tag: @valksor/valksor@next
UNCOV
431
        $package = substr($packageSpec, 0, $lastAtPos);
×
UNCOV
432
        $tag = substr($packageSpec, $lastAtPos + 1);
×
433

UNCOV
434
        return ['package' => $package, 'tag' => $tag ?: 'latest'];
×
435
    }
436
}
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