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

CPS-IT / project-builder / 26716980779

31 May 2026 03:41PM UTC coverage: 98.1% (-0.1%) from 98.21%
26716980779

push

github

web-flow
Merge pull request #957 from CPS-IT/fix/root-package-version

[BUGFIX] Properly determine root package version on local installs

7 of 10 new or added lines in 1 file covered. (70.0%)

2530 of 2579 relevant lines covered (98.1%)

14.81 hits per line

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

96.55
/src/Template/Provider/BaseProvider.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the Composer package "cpsit/project-builder".
7
 *
8
 * Copyright (C) 2022 Elias Häußler <e.haeussler@familie-redlich.de>
9
 *
10
 * This program is free software: you can redistribute it and/or modify
11
 * it under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, either version 3 of the License, or
13
 * (at your option) any later version.
14
 *
15
 * This program is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18
 * GNU General Public License for more details.
19
 *
20
 * You should have received a copy of the GNU General Public License
21
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22
 */
23

24
namespace CPSIT\ProjectBuilder\Template\Provider;
25

26
use Composer\Factory;
27
use Composer\InstalledVersions;
28
use Composer\IO as ComposerIO;
29
use Composer\Package;
30
use Composer\Repository;
31
use Composer\Semver;
32
use Composer\Util;
33
use CPSIT\ProjectBuilder\Exception;
34
use CPSIT\ProjectBuilder\Helper;
35
use CPSIT\ProjectBuilder\IO;
36
use CPSIT\ProjectBuilder\Paths;
37
use CPSIT\ProjectBuilder\Resource;
38
use CPSIT\ProjectBuilder\Template;
39
use OutOfBoundsException;
40
use Symfony\Component\Console;
41
use Symfony\Component\Filesystem;
42
use Twig\Environment;
43
use Twig\Loader;
44
use UnexpectedValueException;
45

46
use function getenv;
47
use function is_string;
48
use function sprintf;
49

50
/**
51
 * BaseProvider.
52
 *
53
 * @author Elias Häußler <e.haeussler@familie-redlich.de>
54
 * @license GPL-3.0-or-later
55
 */
56
abstract class BaseProvider implements ProviderInterface
57
{
58
    protected Resource\Local\Composer $composer;
59
    protected Environment $renderer;
60
    protected ComposerIO\IOInterface $io;
61
    protected Package\Version\VersionParser $versionParser;
62
    protected bool $acceptInsecureConnections = false;
63
    protected bool $disableCache = false;
64

65
    public function __construct(
47✔
66
        protected IO\Messenger $messenger,
67
        protected Filesystem\Filesystem $filesystem,
68
    ) {
69
        $this->composer = new Resource\Local\Composer($this->filesystem);
47✔
70
        $this->renderer = new Environment(
47✔
71
            new Loader\FilesystemLoader([
47✔
72
                Filesystem\Path::join(
47✔
73
                    Helper\FilesystemHelper::getPackageDirectory(),
47✔
74
                    Paths::PROJECT_INSTALLER,
47✔
75
                ),
47✔
76
            ]),
47✔
77
        );
47✔
78
        $this->io = new ComposerIO\BufferIO();
47✔
79
        $this->versionParser = new Package\Version\VersionParser();
47✔
80
    }
81

82
    public function listTemplateSources(): array
11✔
83
    {
84
        $maintainedPackageTemplateSources = [];
11✔
85
        $abandonedPackageTemplateSources = [];
11✔
86

87
        $repository = $this->createRepository();
11✔
88

89
        $constraint = new Semver\Constraint\MatchAllConstraint();
11✔
90
        $searchResult = $repository->search(
11✔
91
            '',
11✔
92
            Repository\RepositoryInterface::SEARCH_FULLTEXT,
11✔
93
            self::PACKAGE_TYPE,
11✔
94
        );
11✔
95

96
        foreach ($searchResult as ['name' => $packageName]) {
11✔
97
            $package = $repository->findPackage($packageName, $constraint);
7✔
98

99
            if (null !== $package && $this->isPackageSupported($package)) {
7✔
100
                if (
101
                    $package instanceof Package\CompletePackageInterface
7✔
102
                    && $package->isAbandoned()
7✔
103
                ) {
104
                    $abandonedPackageTemplateSources[] = $this->createTemplateSource($package);
1✔
105
                    continue;
1✔
106
                }
107

108
                $maintainedPackageTemplateSources[] = $this->createTemplateSource($package);
7✔
109
            }
110
        }
111

112
        return array_merge($maintainedPackageTemplateSources, $abandonedPackageTemplateSources);
11✔
113
    }
114

115
    /**
116
     * @throws Exception\IOException
117
     * @throws Exception\InvalidTemplateSourceException
118
     * @throws Exception\MisconfiguredValidatorException
119
     */
120
    public function installTemplateSource(Template\TemplateSource $templateSource): void
9✔
121
    {
122
        $package = $templateSource->getPackage();
9✔
123

124
        // @codeCoverageIgnoreStart
125
        if ($package instanceof Package\AliasPackage) {
126
            $package = $package->getAliasOf();
127
            $templateSource->setPackage($package);
128
        }
129
        // @codeCoverageIgnoreEnd
130

131
        if ($package instanceof Package\Package) {
9✔
132
            $this->requestPackageVersionConstraint($templateSource);
9✔
133
        }
134

135
        $composerJson = $this->createComposerJson([$templateSource]);
8✔
136
        $output = new Console\Output\BufferedOutput();
8✔
137

138
        $this->messenger->progress(
8✔
139
            sprintf(
8✔
140
                'Installing project template%s...',
8✔
141
                $templateSource->shouldUseDynamicVersionConstraint()
8✔
142
                    ? ''
6✔
143
                    : sprintf(' (<info>%s</info>)', $templateSource->getPackage()->getPrettyVersion()),
8✔
144
            ),
8✔
145
            ComposerIO\IOInterface::NORMAL,
8✔
146
        );
8✔
147

148
        $exitCode = $this->composer->install($composerJson, false, $output);
8✔
149

150
        if (0 !== $exitCode) {
8✔
151
            $this->messenger->failed();
3✔
152
            $this->messenger->write($output->fetch());
3✔
153

154
            throw Exception\InvalidTemplateSourceException::forFailedInstallation($templateSource);
3✔
155
        }
156

157
        // Make sure installed sources are handled by Composer's class loader
158
        $loader = Resource\Local\Composer::createClassLoader(dirname($composerJson));
6✔
159
        $loader->register(true);
6✔
160

161
        // Look up installed package
162
        $composer = Resource\Local\Composer::createComposer(dirname($composerJson));
6✔
163
        $repository = $composer->getRepositoryManager()->getLocalRepository();
6✔
164
        $installedPackage = $repository->findPackage($package->getName(), new Semver\Constraint\MatchAllConstraint());
6✔
165

166
        // Overwrite package from template source with actually installed template
167
        if (null !== $installedPackage) {
6✔
168
            $templateSource->setPackage($installedPackage);
6✔
169
        }
170

171
        // Show installed template version
172
        $this->messenger->progress(
6✔
173
            sprintf(
6✔
174
                'Installing project template (<info>%s</info>)...',
6✔
175
                $templateSource->getPackage()->getPrettyVersion(),
6✔
176
            ),
6✔
177
            ComposerIO\IOInterface::NORMAL,
6✔
178
            true,
6✔
179
        );
6✔
180
        $this->messenger->done();
6✔
181
        $this->messenger->newLine();
6✔
182
    }
183

184
    /**
185
     * @throws Exception\IOException
186
     * @throws Exception\InvalidTemplateSourceException
187
     * @throws Exception\MisconfiguredValidatorException
188
     */
189
    protected function requestPackageVersionConstraint(Template\TemplateSource $templateSource): void
9✔
190
    {
191
        $inputReader = $this->messenger->createInputReader();
9✔
192
        $repository = $templateSource->getPackage()->getRepository() ?? $this->createRepository();
9✔
193

194
        $this->messenger->writeWithEmoji(
9✔
195
            IO\Emoji::WhiteHeavyCheckMark->value,
9✔
196
            sprintf('Well done! You\'ve selected <comment>%s</comment>.', $templateSource->getPackage()->getName()),
9✔
197
        );
9✔
198

199
        $this->messenger->newLine();
9✔
200
        $this->messenger->write(
9✔
201
            sprintf('Do you require a specific version of <comment>%s</comment>?', $templateSource->getPackage()->getName()),
9✔
202
        );
9✔
203
        $this->messenger->comment(
9✔
204
            'If so, you may specify it here. Leave it empty and we\'ll find a current version for you.',
9✔
205
        );
9✔
206
        $this->messenger->newLine();
9✔
207
        $this->messenger->comment('Example: <fg=cyan>2.1.0</> or <fg=cyan>dev-feature/xyz</>');
9✔
208
        $this->messenger->newLine();
9✔
209

210
        $constraint = $inputReader->staticValue(
9✔
211
            'Enter the version constraint to require: ',
9✔
212
            validator: new IO\Validator\CallbackValidator([
9✔
213
                'callback' => $this->validateConstraint(...),
9✔
214
            ]),
9✔
215
        );
9✔
216

217
        $this->messenger->newLine();
9✔
218

219
        if (null === $constraint) {
9✔
220
            $templateSource->useDynamicVersionConstraint();
6✔
221

222
            return;
6✔
223
        }
224

225
        $package = $repository->findPackage($templateSource->getPackage()->getName(), $constraint);
4✔
226

227
        if ($package instanceof Package\BasePackage) {
4✔
228
            $templateSource->setPackage($package);
2✔
229

230
            return;
2✔
231
        }
232

233
        $this->messenger->error('Unable to find a package version for the given constraint.');
2✔
234

235
        if (!$inputReader->ask('Do you want to try another version constraint instead?')) {
2✔
236
            throw Exception\InvalidTemplateSourceException::forInvalidPackageVersionConstraint($templateSource, $constraint);
1✔
237
        }
238

239
        $this->messenger->newLine();
1✔
240

241
        $this->requestPackageVersionConstraint($templateSource);
1✔
242
    }
243

244
    protected function isPackageSupported(Package\BasePackage $package): bool
3✔
245
    {
246
        if (self::PACKAGE_TYPE !== $package->getType()) {
3✔
247
            return false;
×
248
        }
249

250
        /** @var array<string, mixed> $extra */
251
        $extra = $package->getExtra();
3✔
252
        $excludeFromListing = (bool) Helper\ArrayHelper::getValueByPath(
3✔
253
            $extra,
3✔
254
            'cpsit/project-builder.exclude-from-listing',
3✔
255
        );
3✔
256

257
        return !$excludeFromListing;
3✔
258
    }
259

260
    protected function createTemplateSource(Package\BasePackage $package): Template\TemplateSource
7✔
261
    {
262
        return new Template\TemplateSource($this, $package);
7✔
263
    }
264

265
    /**
266
     * @param list<Template\TemplateSource>          $templateSources
267
     * @param list<array{type: string, url: string}> $repositories
268
     */
269
    protected function createComposerJson(array $templateSources, array $repositories = []): string
8✔
270
    {
271
        $repositories = [
8✔
272
            [
8✔
273
                'type' => $this->getRepositoryType(),
8✔
274
                'url' => $this->getUrl(),
8✔
275
            ],
8✔
276
            ...$repositories,
8✔
277
        ];
8✔
278

279
        $targetDirectory = Helper\FilesystemHelper::getNewTemporaryDirectory();
8✔
280
        $targetFile = Filesystem\Path::join($targetDirectory, 'composer.json');
8✔
281
        $composerJson = $this->renderer->render('composer.json.twig', [
8✔
282
            'templateSources' => $templateSources,
8✔
283
            'rootDir' => Helper\FilesystemHelper::getPackageDirectory(),
8✔
284
            'tempDir' => $targetDirectory,
8✔
285
            'repositories' => $repositories,
8✔
286
            'acceptInsecureConnections' => $this->acceptInsecureConnections,
8✔
287
            'rootPackageVersion' => $this->resolveRootPackageVersion(),
8✔
288
        ]);
8✔
289

290
        $this->filesystem->dumpFile($targetFile, $composerJson);
8✔
291

292
        return $targetFile;
8✔
293
    }
294

295
    protected function resolveRootPackageVersion(): string
8✔
296
    {
297
        $version = getenv('PROJECT_BUILDER_SIMULATE_VERSION');
8✔
298

299
        if (false !== $version) {
8✔
NEW
300
            return $version;
×
301
        }
302

303
        try {
304
            $version = InstalledVersions::getVersion('cpsit/project-builder');
8✔
NEW
305
        } catch (OutOfBoundsException) {
×
306
        }
307

308
        if (!is_string($version)) {
8✔
NEW
309
            $version = 'self.version';
×
310
        }
311

312
        return $version;
8✔
313
    }
314

315
    protected function createRepository(): Repository\RepositoryInterface
8✔
316
    {
317
        $customConfiguration = [
8✔
318
            'config' => [
8✔
319
                'secure-http' => !$this->acceptInsecureConnections,
8✔
320
            ],
8✔
321
        ];
8✔
322

323
        if ($this->disableCache) {
8✔
324
            $customConfiguration['config']['cache-dir'] = Util\Platform::isWindows() ? 'nul' : '/dev/null';
1✔
325
        }
326

327
        $config = Factory::createConfig($this->io);
8✔
328
        $config->merge($customConfiguration);
8✔
329

330
        return Repository\RepositoryFactory::createRepo(
8✔
331
            $this->io,
8✔
332
            $config,
8✔
333
            [
8✔
334
                'type' => $this->getRepositoryType(),
8✔
335
                'url' => $this->getUrl(),
8✔
336
            ],
8✔
337
            Repository\RepositoryFactory::manager($this->io, $config, Factory::createHttpDownloader($this->io, $config)),
8✔
338
        );
8✔
339
    }
340

341
    /**
342
     * @throws Exception\ValidationException
343
     *
344
     * @internal
345
     */
346
    public function validateConstraint(?string $input): ?string
8✔
347
    {
348
        if (null === $input) {
8✔
349
            return null;
5✔
350
        }
351

352
        try {
353
            $this->versionParser->parseConstraints($input);
5✔
354
        } catch (UnexpectedValueException $exception) {
1✔
355
            throw Exception\ValidationException::create($exception->getMessage());
1✔
356
        }
357

358
        return $input;
4✔
359
    }
360

361
    public function disableCache(): void
2✔
362
    {
363
        $this->disableCache = true;
2✔
364
    }
365

366
    public function enableCache(): void
×
367
    {
368
        $this->disableCache = false;
×
369
    }
370

371
    /**
372
     * Get supported Composer repository type for the configured URL.
373
     *
374
     * @see https://getcomposer.org/doc/05-repositories.md#types
375
     */
376
    abstract protected function getRepositoryType(): string;
377
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc