Skip to content

Commit 08eb927

Browse files
committed
feat(sync): add license file generation to dev-tools:sync
Implements issue #11 - auto-create LICENSE file from composer.json - Added new License namespace with isolated classes: - Reader: reads license metadata from composer.json - Resolver: resolves SPDX license identifiers to templates - TemplateLoader: loads license templates from resources - PlaceholderResolver: resolves template placeholders (year, organization, author, project) - Generator: orchestrates license file generation - Added license templates for: MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, GPL-3.0-or-later, LGPL-3.0-or-later, MPL-2.0, Unlicense - Updated SyncCommand to call generateLicense() method - Added unit tests for all License classes Behavior: - If LICENSE already exists, skip generation - If no license in composer.json, warn and skip - If unsupported license, warn and skip - Only generates for single supported SPDX license
1 parent 7e4e54d commit 08eb927

19 files changed

Lines changed: 1308 additions & 0 deletions

src/Command/SyncCommand.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
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;
2227
use Composer\Json\JsonManipulator;
2328
use Symfony\Component\Console\Input\InputInterface;
2429
use Symfony\Component\Console\Output\OutputInterface;
@@ -76,6 +81,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7681
$this->addRepositoryWikiGitSubmodule();
7782
$this->runCommand('gitignore', $output);
7883
$this->runCommand('skills', $output);
84+
$this->generateLicense($output);
7985

8086
return self::SUCCESS;
8187
}
@@ -235,4 +241,43 @@ private function getGitRepositoryUrl(): string
235241

236242
return trim($process->getOutput());
237243
}
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+
}
238283
}

src/License/Generator.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
use Symfony\Component\Filesystem\Filesystem;
22+
23+
final readonly class Generator
24+
{
25+
/**
26+
* @param Reader $reader
27+
* @param Resolver $resolver
28+
* @param TemplateLoader $templateLoader
29+
* @param PlaceholderResolver $placeholderResolver
30+
* @param Filesystem $filesystem
31+
*/
32+
public function __construct(
33+
private Reader $reader,
34+
private Resolver $resolver,
35+
private TemplateLoader $templateLoader,
36+
private PlaceholderResolver $placeholderResolver,
37+
private Filesystem $filesystem = new Filesystem()
38+
) {}
39+
40+
/**
41+
* @param string $targetPath
42+
*
43+
* @return string|null
44+
*/
45+
public function generate(string $targetPath): ?string
46+
{
47+
$license = $this->reader->getLicense();
48+
49+
if (null === $license) {
50+
return null;
51+
}
52+
53+
if (! $this->resolver->isSupported($license)) {
54+
return null;
55+
}
56+
57+
if ($this->filesystem->exists($targetPath)) {
58+
return null;
59+
}
60+
61+
$templateFilename = $this->resolver->resolve($license);
62+
63+
if (null === $templateFilename) {
64+
return null;
65+
}
66+
67+
$template = $this->templateLoader->load($templateFilename);
68+
69+
$authors = $this->reader->getAuthors();
70+
$firstAuthor = $authors[0] ?? null;
71+
72+
$metadata = [
73+
'year' => $this->reader->getYear(),
74+
'organization' => $this->reader->getVendor(),
75+
'author' => null !== $firstAuthor ? ($firstAuthor['name'] ?: ($firstAuthor['email'] ?? '')) : '',
76+
'project' => $this->reader->getPackageName(),
77+
];
78+
79+
$content = $this->placeholderResolver->resolve($template, $metadata);
80+
81+
$this->filesystem->dumpFile($targetPath, $content);
82+
83+
return $content;
84+
}
85+
86+
/**
87+
* @return bool
88+
*/
89+
public function hasLicense(): bool
90+
{
91+
$license = $this->reader->getLicense();
92+
93+
if (null === $license) {
94+
return false;
95+
}
96+
97+
return $this->resolver->isSupported($license);
98+
}
99+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
use function Safe\preg_replace;
22+
23+
final class PlaceholderResolver
24+
{
25+
/**
26+
* @param array{year?: int, organization?: string, author?: string, project?: string} $metadata
27+
* @param string $template
28+
*/
29+
public function resolve(string $template, array $metadata): string
30+
{
31+
$replacements = [
32+
'{{ year }}' => (string) ($metadata['year'] ?? date('Y')),
33+
'{{ organization }}' => $metadata['organization'] ?? '',
34+
'{{ author }}' => $metadata['author'] ?? '',
35+
'{{ project }}' => $metadata['project'] ?? '',
36+
'{{ copyright_holder }}' => $metadata['organization'] ?? $metadata['author'] ?? '',
37+
];
38+
39+
$result = $template;
40+
41+
foreach ($replacements as $placeholder => $value) {
42+
$result = str_replace($placeholder, $value, $result);
43+
}
44+
45+
$result = preg_replace('/\{\{\s*\w+\s*\}\}/', '', $result);
46+
47+
$result = preg_replace('/\n{3,}/', "\n\n", $result);
48+
49+
return trim((string) $result);
50+
}
51+
}

src/License/Reader.php

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
use Composer\Composer;
22+
use Composer\Package\RootPackageInterface;
23+
24+
final readonly class Reader
25+
{
26+
/**
27+
* @param Composer $composer
28+
*/
29+
public function __construct(
30+
private Composer $composer
31+
) {}
32+
33+
/**
34+
* @return string|null
35+
*/
36+
public function getLicense(): ?string
37+
{
38+
$package = $this->composer->getPackage();
39+
40+
return $this->extractLicense($package);
41+
}
42+
43+
/**
44+
* @return string
45+
*/
46+
public function getPackageName(): string
47+
{
48+
$package = $this->composer->getPackage();
49+
50+
return $package->getName();
51+
}
52+
53+
/**
54+
* @return array
55+
*/
56+
public function getAuthors(): array
57+
{
58+
$package = $this->composer->getPackage();
59+
$authors = $package->getAuthors();
60+
61+
if ([] === $authors) {
62+
return [];
63+
}
64+
65+
return array_map(
66+
static fn(array $author): array => [
67+
'name' => $author['name'] ?? '',
68+
'email' => $author['email'] ?? '',
69+
'homepage' => $author['homepage'] ?? '',
70+
'role' => $author['role'] ?? '',
71+
],
72+
$authors
73+
);
74+
}
75+
76+
/**
77+
* @return string|null
78+
*/
79+
public function getVendor(): ?string
80+
{
81+
$packageName = $this->getPackageName();
82+
83+
if (null === $packageName) {
84+
return null;
85+
}
86+
87+
$parts = explode('/', $packageName, 2);
88+
89+
if (! isset($parts[1])) {
90+
return null;
91+
}
92+
93+
return $parts[0];
94+
}
95+
96+
/**
97+
* @return int
98+
*/
99+
public function getYear(): int
100+
{
101+
return (int) date('Y');
102+
}
103+
104+
/**
105+
* @param RootPackageInterface $package
106+
*
107+
* @return string|null
108+
*/
109+
private function extractLicense(RootPackageInterface $package): ?string
110+
{
111+
$license = $package->getLicense();
112+
113+
if ([] === $license) {
114+
return null;
115+
}
116+
117+
if (1 === \count($license)) {
118+
return $license[0];
119+
}
120+
121+
return null;
122+
}
123+
}

0 commit comments

Comments
 (0)