Skip to content

Commit fad9aff

Browse files
committed
feat(license): implement CopyLicenseCommand for generating LICENSE files
Signed-off-by: Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
1 parent 35bcf2e commit fad9aff

17 files changed

+512
-129
lines changed

src/Command/CopyLicenseCommand.php

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\Command;
20+
21+
use Composer\Factory;
22+
use FastForward\DevTools\License\Generator;
23+
use FastForward\DevTools\License\GeneratorInterface;
24+
use FastForward\DevTools\License\PlaceholderResolver;
25+
use FastForward\DevTools\License\Reader;
26+
use FastForward\DevTools\License\Resolver;
27+
use FastForward\DevTools\License\TemplateLoader;
28+
use SplFileObject;
29+
use Symfony\Component\Console\Input\InputInterface;
30+
use Symfony\Component\Console\Output\OutputInterface;
31+
use Symfony\Component\Filesystem\Filesystem;
32+
33+
/**
34+
* Generates and copies LICENSE files to projects.
35+
*
36+
* This command generates a LICENSE file if one does not exist and a supported
37+
* license is declared in composer.json.
38+
*/
39+
final class CopyLicenseCommand extends AbstractCommand
40+
{
41+
/**
42+
* Creates a new CopyLicenseCommand instance.
43+
*
44+
* @param Filesystem|null $filesystem the filesystem component
45+
* @param GeneratorInterface|null $generator the generator component
46+
*/
47+
public function __construct(
48+
?Filesystem $filesystem = null,
49+
private readonly ?GeneratorInterface $generator = null,
50+
) {
51+
parent::__construct($filesystem);
52+
}
53+
54+
/**
55+
* @return GeneratorInterface
56+
*/
57+
private function getGenerator(): GeneratorInterface
58+
{
59+
return $this->generator ?? new Generator(
60+
new Reader(new SplFileObject(Factory::getComposerFile())),
61+
new Resolver(),
62+
new TemplateLoader(),
63+
new PlaceholderResolver(),
64+
$this->filesystem,
65+
);
66+
}
67+
68+
/**
69+
* Configures the current command.
70+
*
71+
* This method MUST define the name, description, and help text for the command.
72+
*/
73+
protected function configure(): void
74+
{
75+
$this
76+
->setName('license')
77+
->setDescription('Generates a LICENSE file from composer.json license information.')
78+
->setHelp(
79+
'This command generates a LICENSE file if one does not exist and a supported license is declared in composer.json.'
80+
);
81+
}
82+
83+
/**
84+
* Executes the license generation process.
85+
*
86+
* Generates a LICENSE file if one does not exist and a supported license is declared in composer.json.
87+
*
88+
* @param InputInterface $input the input interface
89+
* @param OutputInterface $output the output interface
90+
*
91+
* @return int the status code
92+
*/
93+
protected function execute(InputInterface $input, OutputInterface $output): int
94+
{
95+
$targetPath = $this->getConfigFile('LICENSE', true);
96+
97+
if ($this->filesystem->exists($targetPath)) {
98+
$output->writeln('<info>LICENSE file already exists. Skipping generation.</info>');
99+
100+
return self::SUCCESS;
101+
}
102+
103+
$license = $this->getGenerator()
104+
->generate($targetPath);
105+
106+
if (null === $license) {
107+
$output->writeln(
108+
'<comment>No supported license found in composer.json or license is unsupported. Skipping LICENSE generation.</comment>'
109+
);
110+
111+
return self::SUCCESS;
112+
}
113+
114+
$output->writeln('<info>LICENSE file generated successfully.</info>');
115+
116+
return self::SUCCESS;
117+
}
118+
}

src/Command/SyncCommand.php

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,6 @@
1919
namespace FastForward\DevTools\Command;
2020

2121
use Composer\Factory;
22-
use FastForward\DevTools\License\Generator;
23-
use FastForward\DevTools\License\Reader;
24-
use FastForward\DevTools\License\Resolver;
25-
use FastForward\DevTools\License\TemplateLoader;
26-
use FastForward\DevTools\License\PlaceholderResolver;
2722
use Composer\Json\JsonManipulator;
2823
use Symfony\Component\Console\Input\InputInterface;
2924
use Symfony\Component\Console\Output\OutputInterface;
@@ -81,7 +76,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
8176
$this->addRepositoryWikiGitSubmodule();
8277
$this->runCommand('gitignore', $output);
8378
$this->runCommand('skills', $output);
84-
$this->generateLicense($output);
79+
$this->runCommand('license', $output);
8580

8681
return self::SUCCESS;
8782
}
@@ -241,43 +236,4 @@ private function getGitRepositoryUrl(): string
241236

242237
return trim($process->getOutput());
243238
}
244-
245-
/**
246-
* Generates a LICENSE file if one does not exist and a supported license is declared in composer.json.
247-
*
248-
* @param OutputInterface $output the console output stream
249-
*
250-
* @return void
251-
*/
252-
private function generateLicense(OutputInterface $output): void
253-
{
254-
$targetPath = $this->getConfigFile('LICENSE', true);
255-
256-
if ($this->filesystem->exists($targetPath)) {
257-
$output->writeln('<info>LICENSE file already exists. Skipping generation.</info>');
258-
259-
return;
260-
}
261-
262-
$composer = $this->requireComposer();
263-
264-
$reader = new Reader($composer);
265-
$resolver = new Resolver();
266-
$templateLoader = new TemplateLoader();
267-
$placeholderResolver = new PlaceholderResolver();
268-
269-
$generator = new Generator($reader, $resolver, $templateLoader, $placeholderResolver, $this->filesystem);
270-
271-
$license = $generator->generate($targetPath);
272-
273-
if (null === $license) {
274-
$output->writeln(
275-
'<comment>No supported license found in composer.json or license is unsupported. Skipping LICENSE generation.</comment>'
276-
);
277-
278-
return;
279-
}
280-
281-
$output->writeln('<info>LICENSE file generated successfully.</info>');
282-
}
283239
}

src/Composer/Capability/DevToolsCommandProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use FastForward\DevTools\Command\AbstractCommand;
2222
use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
2323
use FastForward\DevTools\Command\CodeStyleCommand;
24+
use FastForward\DevTools\Command\CopyLicenseCommand;
2425
use FastForward\DevTools\Command\DependenciesCommand;
2526
use FastForward\DevTools\Command\DocsCommand;
2627
use FastForward\DevTools\Command\GitIgnoreCommand;
@@ -62,6 +63,7 @@ public function getCommands()
6263
new SyncCommand(),
6364
new GitIgnoreCommand(),
6465
new SkillsCommand(),
66+
new CopyLicenseCommand(),
6567
];
6668
}
6769
}

src/License/Generator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
use Symfony\Component\Filesystem\Filesystem;
2222

23-
final readonly class Generator
23+
final readonly class Generator implements GeneratorInterface
2424
{
2525
/**
2626
* @param Reader $reader

src/License/GeneratorInterface.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\License;
20+
21+
interface GeneratorInterface
22+
{
23+
/**
24+
* @param string $targetPath
25+
*
26+
* @return string|null
27+
*/
28+
public function generate(string $targetPath): ?string;
29+
30+
/**
31+
* @return bool
32+
*/
33+
public function hasLicense(): bool;
34+
}

src/License/PlaceholderResolver.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
use function Safe\preg_replace;
2222

23-
final class PlaceholderResolver
23+
final class PlaceholderResolver implements PlaceholderResolverInterface
2424
{
2525
/**
2626
* @param array{year?: int, organization?: string, author?: string, project?: string} $metadata
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\License;
20+
21+
interface PlaceholderResolverInterface
22+
{
23+
/**
24+
* @param string $template
25+
* @param array{year?: int, organization?: string, author?: string, project?: string} $metadata
26+
*/
27+
public function resolve(string $template, array $metadata): string;
28+
}

src/License/Reader.php

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,45 +18,65 @@
1818

1919
namespace FastForward\DevTools\License;
2020

21-
use Composer\Composer;
22-
use Composer\Package\RootPackageInterface;
21+
use Safe\Exceptions\JsonException;
22+
use SplFileObject;
2323

24-
final readonly class Reader
24+
use function Safe\json_decode;
25+
26+
final readonly class Reader implements ReaderInterface
2527
{
28+
private array $data;
29+
2630
/**
27-
* @param Composer $composer
31+
* @param SplFileObject $source The source file to read from, typically composer.json
2832
*/
29-
public function __construct(
30-
private Composer $composer
31-
) {}
33+
public function __construct(SplFileObject $source)
34+
{
35+
$this->data = $this->readData($source);
36+
}
37+
38+
/**
39+
* @param SplFileObject $source The source file to read from, typically composer.json
40+
*
41+
* @return array
42+
*
43+
* @throws JsonException if the JSON is invalid
44+
*/
45+
private function readData(SplFileObject $source): array
46+
{
47+
$content = $source->fread($source->getSize());
48+
49+
return json_decode($content, true);
50+
}
3251

3352
/**
3453
* @return string|null
3554
*/
3655
public function getLicense(): ?string
3756
{
38-
$package = $this->composer->getPackage();
57+
$license = $this->data['license'] ?? [];
3958

40-
return $this->extractLicense($package);
59+
if (\is_string($license)) {
60+
return $license;
61+
}
62+
63+
return $this->extractLicense($license);
4164
}
4265

4366
/**
4467
* @return string
4568
*/
4669
public function getPackageName(): string
4770
{
48-
$package = $this->composer->getPackage();
49-
50-
return $package->getName();
71+
return $this->data['name'] ?? '';
5172
}
5273

5374
/**
5475
* @return array
5576
*/
5677
public function getAuthors(): array
5778
{
58-
$package = $this->composer->getPackage();
59-
$authors = $package->getAuthors();
79+
$authors = $this->data['authors'] ?? [];
6080

6181
if ([] === $authors) {
6282
return [];
@@ -80,7 +100,7 @@ public function getVendor(): ?string
80100
{
81101
$packageName = $this->getPackageName();
82102

83-
if (null === $packageName) {
103+
if ('' === $packageName) {
84104
return null;
85105
}
86106

@@ -102,14 +122,12 @@ public function getYear(): int
102122
}
103123

104124
/**
105-
* @param RootPackageInterface $package
125+
* @param array $license
106126
*
107127
* @return string|null
108128
*/
109-
private function extractLicense(RootPackageInterface $package): ?string
129+
private function extractLicense(array $license): ?string
110130
{
111-
$license = $package->getLicense();
112-
113131
if ([] === $license) {
114132
return null;
115133
}

0 commit comments

Comments
 (0)